少し前に「これは何だろう?」と思ったことについて調べてみました。
SwiftのResultとは?
Success
とFailure
の2つのケースを持ったenum
です。
多くの方が自前で今まで実装してきましたが
Swif5.0で標準ライブラリに導入されました。
鉄道指向プログラミング(Railway Oriented Programming)とは?
2014年にScott Wlaschinさんが提唱された
関数型プログラミングを行っていくなかで
エラーハンドリングをどう扱っていくかに主に焦点を当てたプログラミング手法です。
Many examples in functional programming assume
that you are always on the “happy path”.
But to create a robust real world application
you must deal with validation, logging, network
and service errors, and other annoyances.
So, how do you handle all this in a clean functional way?
This talk will provide a brief introduction to this topic,
using a fun and easy-to-understand railway analogy.
多くの関数型プログラミングの例は
いつも「ハッピーパス(いわゆる正常系のこと)」を通っていることを想定しているが
現実にがバリデーションやログ、ネットワーク通信やサービスのエラー
その他の腹立たしいことを扱わなければならない。
で、それをこれらをどうやってクリーンに関数型の手法で対処できるだろうか?
今回のトークでは、楽しく、わかりやすい鉄道との共通点を使ってこの問題について
簡単な導入部分を紹介する。
とあり
抽象的な概念というよりは
より具体的な問題に焦点を当てており
すぐに実践に応用できるようになっています。
Resultと鉄道指向プログラミング
鉄道指向プログラミングでは
Result
を用いたエラーハンドリングを行います。
Result
を用いることでそのままの型を用いる以上の
多くのメリットを得ることができます。
今回は
鉄道指向プログラミングはどういうものなのかが気になったのと
Result
がどのように機能するのかを理解するために
記録として残してみました。
※
記事の中で出てくる図はScottさんの講演スライドから拝借しています。
ブログの中で「自由にして良い」という記載がございましたので
活用させていただきました。
The powerpoint slides are also available from Github. Feel free to borrow from them!
今回の例
以下の処理を行います。
- リクエストを受け取る
- バリデーションチェックをする
- リクエストをDBに保存(更新)する
- 認証済みのメールに送信する
- ユーザに結果を返す
命令型プログラミングで書いた例
下記の例を見てみます
命令型プログラミングの実装
struct DB {
func updateDb(from request: Request) throws -> Bool{
return true
}
}
struct SmtpServer {
func sendEmail(to request: Request) throws -> Bool {
return true
}
}
func validateRequest(_ request: Request) -> Bool {
return true
}
func executeUseCase(request: Request, db: DB, stmpServer: SmtpServer) -> String {
if !validateRequest(request) {
return "validation error"
}
do {
let result = try db.updateDb(from: request)
if !result {
return "Record not found"
}
if !stmpServer.sendEmail(to: request) {
return "Fail to send mail"
}
} catch {
return "Fail to update"
}
return "OK"
}
これはいわゆる命令型と呼ばれるような形で書かれています。
特に問題はないのですが
こうすると
if
で判定をしたり
do catch
文が途中で入ってくるため
本来のやりたいことが見えづらくなってしまいます。
では
このようなエラーハンドリングを
どうやって関数型プログラミングを使って
綺麗な形で処理できるでしょうか?
結果のパターンを考えてみる
上記の例の処理の流れを考えてみます。
Request
を受け取り
処理が成功した場合は次の処理へ
エラーの場合はエラーになった時点で
レスポンスを返しています。
次に関数型プログラミングの形で考えてみます。
関数型では処理を上から下へ向かう
データの流れとして捉えます。
ここで上記の図のように処理の結果は
4つのパターンが考えられます。
このようなレスポンスをどうやって表現することができるでしょうか?
enum Result {
case Success
case ValidationError
case UpdateError
case SendMailError
}
パターンということでenum
として捉えました。
これですべてのケースを網羅できていますが
他の処理でも同じように使えるようにしたいですね。
それがSwiftのResult
です。
public enum Result<Success, Failure: Error> {
case success(Success)
case failure(Failure)
}
※ F#にもResultはありますがSwiftのFailureはErrorプロトコルに適合する必要があります。
Result
を使うと処理の流れは下記のようになります。
こうすることで各関数が同じレスポンスを返すようになります。
下記にResult
を返す関数を提示してみます。
Resultを返す関数
struct ValidationError: LocalizedError {
let field: String
let value: Any
let reason: String
var localizedDescription: String {
return "\(field) \(value) is not valid because \(reason)"
}
}
enum UseCaseError: LocalizedError {
case validation(ValidationError)
case update(Error)
case sendMail(Error)
var localizedDescription: String {
switch self {
case .validation(let error):
return error.localizedDescription
case .update(let error):
return "update error \(error)"
case .sendMail(let error):
return "sendMail error \(error)"
}
}
}
struct DB {
func updateDb(from request: Request) -> Result<Request, UseCaseError> {
return Result { try updateDb(request) }.mapError(UseCaseError.update)
}
private func updateDb(_ request: Request) throws -> Request {
return request
}
}
struct SmtpServer {
func sendEmail(to request: Request) -> Result<Request, UseCaseError> {
return Result {
try sendEmail(request.email)
return request
}.mapError(UseCaseError.sendMail)
}
private func sendEmail(_ email: String) throws -> Void {
return
}
}
func validateRequest(_ request: Request) -> Result<Request, UseCaseError> {
if request.name.isEmpty {
let error = ValidationError(field: "name", value: request.name, reason: "name should not be empty")
return .failure(.validation(error))
}
return .success(request)
}
同じレスポンスを返すということは
各関数を一つのワークフローとして組み合わせて
全体の処理を構成できそうですね。
※
値はなんでも良いsuccess
またはUseCaseError
を持ったfailure
を返す
しかし
それぞれの型を見ると
(Request) -> Result<Request, UseCaseError>
ということで型が合いません。
どうやって各処理を連結できるようになるでしょうか?
鉄道の車線をイメージしてみる(鉄道指向プログラミング)
下記の図を見てください。
一つのインプットを与えると一つのアウトプットを出力する関数を
鉄道の車線に例えています。
次の図を見てください。
もう一つ車線が出てきました。
左の関数のアウトプットと右の関数のインプットが一致しているため
この二つの車線を繋げることができます。
このような場合は
シンプルですぐに理解できると思います。
Result
の場合はアウトプットが2つの可能性があります。
これを表現するためには車線の分岐が必要になります。
Success
車線とFailure
車線ができます。
このような分岐を生じる関数を
スイッチ関数
と呼びます。
ではスイッチ関数を連結した場合の動きはどうなるでしょうか?
上記の例で説明すると
Validate
関数が
成功した場合 -> Success
車線を通りUpdateDb
を実行する
失敗した場合 -> Failure
車線を通りUpdateDb
は実行せずにFailure
車線を通り続ける
となります。
言い換えると
処理が成功している場合のみ処理を継続し
エラーが発生した場合は以降の処理を行わずに最終的なアウトプットまで進む
ことになります。
なんとなくイメージはできたでしょうか?
では
問題のResult
の連結方法について車線で考えてみます。
上記で示した1つの車線の関数の連結はシンプルでした。
同様にインプットとアウトプットが2車線同士の関数の場合もシンプルです。
インプットとアウトプットが一致すればそのまま繋げることができます。
しかしResult
を返すような関数は通常の値をインプットとして受け取るため
インプットとアウトプットが一致するため連結できません。
ではどうすれば良いのか?
車線の数を合わせれば良い
のです。
1車線インプット、2車線アウトプットの関数から
2車線インプット、2車線アウトプットの関数へ変換する
ことで2車線関数同士の関数を繋ぎ合わせることと同じになります。
それを実現するのがflatMapです。
Scottさんの講演ではアダプターブロックと読んでいました。
flatMapの実装
public enum Result<Success, Failure: Error> {
case success(Success)
case failure(Failure)
public func flatMap<NewSuccess>(
_ transform: (Success) -> Result<NewSuccess, Failure>
) -> Result<NewSuccess, Failure> {
switch self {
case let .success(success):
return transform(success)
case let .failure(failure):
return .failure(failure)
}
}
}
引数に
Result
のSuccess
型を引数にして
変換してResult<NewSuccess, Error>
を返す関数を受け取り
Result<NewSuccess, Error>
を返します。
このtransform
の形に注目すると
(Success) -> Result<NewSuccess, Failure>
これはまさにスイッチ関数と同じ形です。
Scottさんの講演では下記のようなbind関数を定義しています。
func bind<A,B>(_ switchFuntion: @escaping (A) -> Result<B, Error>) -> (Result<A, Error>) -> Result<B, Error> {
return { (a: Result<A, Error>) in
switch a {
case .success(let x):
return switchFuntion(x)
case .failure(let error):
return .failure(error)
}
}
}
これを活用することもできますが
Swiftの標準で使われているメソッドで考えていきたいと思います。
Resultを用いた処理の例
最初の方で命令型で書いた例をResult
を使った形で考えてみます。
Resultを使った実装例
...
struct DB {
func updateDb(from request: Request) -> Result<Request, UseCaseError> {
return Result { try updateDb(request) }.mapError(UseCaseError.update)
}
func updateDb(_ request: Request) throws -> Request {
return request
}
}
struct SmtpServer {
func sendEmail(to request: Request) -> Result<Request, UseCaseError> {
return Result {
try sendEmail(request.email)
return request
}.mapError(UseCaseError.sendMail)
}
func sendEmail(_ email: String) throws -> Void {
return
}
}
func validateRequest(_ request: Request) -> Result<Request, UseCaseError> {
if request.name.isEmpty {
let error = ValidationError(field: "name", value: request.name, reason: "name should not be empty")
return .failure(.validation(error))
}
return .success(request)
}
func executeUserCase(request: Request, db: DB, stmpServer: SmtpServer) -> String {
switch validateRequest(request)
.flatMap(db.updateDb)
.flatMap(stmpServer.sendEmail(to:)) {
case .success:
return "OK"
case .failure(let error):
return error.localizedDescription
}
}
flatMap
を使うことで連結ができるようになりました。
それでは動作を確認してみます。
let request = Request(userId: 1, name: "hoge", email: "hoge@hoge.com")
let result = executeUseCase(request: request, db: DB(), stmpServer: SmtpServer())
print(result) // success("OK")
値がきちんと設定されている場合はsuccess
になります。
ではname
を空文字にしてみたいと思います。
let request = Request(userId: 1, name: "", email: "hoge@hoge.com")
let result = executeUseCase(request: request, db: DB(), stmpServer: SmtpServer())
print(result)
// failure(UseCaseError.validation(ValidationError(field: "name", value: "", reason: "name should not be empty")))
UseCaseError.validation
が出力されました。
想定だとそれ以降のメソッドは呼ばれていないはずですので確認をします。
struct DB {
func updateDb(from request: Request) -> Result<Request, UseCaseError> {
print("updateDb")
return Result { try updateDb(request) }.mapError(UseCaseError.update)
}
}
struct SmtpServer {
func sendEmail(to request: Request) -> Result<Request, UseCaseError> {
print("sendEmail")
return Result {
try sendEmail(request.email)
return request
}.mapError(UseCaseError.sendMail)
}
}
この状態でもう一度success
を出力すると
// updateDb
// sendEmail
// success("OK")
と出ますが
name
を空文字すると
// failure(UseCaseError.validation(ValidationError(field: "name", value: "", reason: "name should not be empty")))
とエラーのみ出力され
その先のメソッドが呼ばれていないことが確認できました。
他のメソッドと組み合わせるには?
下記のメソッドを追加したいとします。
func canonicalizeEmail(_ request: Request) -> Request {
let canonicalized = request.email.trimmingCharacters(in: .whitespaces).lowercased()
return Request(userId: request.userId, name: request.name, email: canonicalized)
}
これはResult
は登場しない1車線関数です。
これも連結して処理できるようにしたいですが
1車線関数のアプトプットと2車線関数のインプットをそのまま繋げることはできません。
同様に2車線関数のインプットと1車線関数のアウトプットをそのまま繋げることはできません。
ではどうするか?
これもflatMap
の時と同じように考えます。
つまり1車線関数を2車線関数に変換するようにします。
これを実現するのはmapです。
Mapの実装
public enum Result<Success, Failure: Error> {
public func map<NewSuccess>(
_ transform: (Success) -> NewSuccess
) -> Result<NewSuccess, Failure> {
switch self {
case let .success(success):
return .success(transform(success))
case let .failure(failure):
return .failure(failure)
}
}
}
transform
を見てみると1車線関数を渡してResult
型に変換して返します。
これを使用すると
func executeUserCase(request: Request, db: DB, stmpServer: SmtpServer) -> String {
switch validateRequest(request)
.map(canonicalizeEmail) // ← ここ
.flatMap(db.updateDb)
.flatMap(stmpServer.sendEmail(to:)) {
case .success:
return "OK"
case .failure(let error):
return error.localizedDescription
}
と上記のように連結することができました。
次に下記のメソッドを考えてみたいと思います。
struct DB {
func updateDbVoid(_ request: Request) throws -> Void {
return
}
}
これはまず1車線関数のため連結できません。
さらにアウトプットがないためmap
を使っても連結ができません。
※こういう関数はデッドエンド関数と呼ばれているそうです。
ではどうするのか?
インプットで受け取った値を内部で関数を実行した後に
そのままインプットの値を返すようにします。
今回はメソッドを一つ追加します。
extension Result {
static func tee(_ f: @escaping (Success) -> ()) -> (Success) -> Result<Success, Failure> {
return { a in
f(a)
return .success(a)
}
}
static func tee(_ f: @escaping (Success) throws -> ()) -> (Success) -> Result<Success, Error> {
return { a in
do {
try f(a)
return .success(a)
} catch {
return .failure(error)
}
}
}
}
これを先ほどのupdateDbVoid
に適用します。
struct DB {
func updateDb(_ request: Request) -> Result<Request, UseCaseError> {
return Result<Request, UseCaseError>
.tee(self.updateDbVoid)(request).mapError(UseCaseError.update)
}
func updateDbVoid(_ request: Request) throws -> Void {
return
}
}
こうすると今までと同じように扱うことができます。
func executeUserCase(request: Request, db: DB, stmpServer: SmtpServer) -> String {
switch validateRequest(request)
.map(canonicalizeEmail)
.flatMap(db.updateDb)
.flatMap(stmpServer.sendEmail(to:)) {
case .success:
return "OK"
case .failure(let error):
return error.localizedDescription
}
}
Resultを使うことで得られること
これまで見てきたように
Result
を使うことで
全体で統一的にエラーハンドリングを行えるようになりました。
さらに各メソッドの型を見てみると
executeUserCase: (Request, DB, SmtpServer) -> String
validateRequest: (Request) -> Result<Request, UseCaseError>
canonicalizeEmail: (Request) -> Request
updateDb: (Request) -> Result<Request, UseCaseError>
sendEmail(Request) -> Result<Request, UseCaseError>
となっています。
Result
を使ったことで
メソッドが失敗するかもしれないということを
目で見てわかるようになりました。
具体的なエラーの内容はenum
で確認できます。
enum UseCaseError {
case validation(ValidationError)
case update(Error)
case sendMail(Error)
}
... 一部省略
こうすることで
他の人が見てもどういう処理をしているのかを型で伝えやすくなり
いわゆる**自己文書化(Self-documenting)**につながります。
※ 自己文書化については下記のサイトなどに詳しく書かれています
https://www.webprofessional.jp/self-documenting-javascript/
その他の鉄道指向プログラミング
この他にも色々な場合に関しての
鉄道指向プログラミングのアプローチが紹介されています。
いくつか挙げたいと思います。
複数のエラーが欲しい場合は?
これまで見てきたケースですと
エラーは一つしか扱うことができません。
例えばバリデーションチェックのエラーは
発生した全てのエラーが欲しい場合があるかもしれません。
そのような場合
講演の中では詳細には触れていませんが
それぞれの処理をPairとして組み合わせていけば
いくつでも組み合わせることができる
といったことをおっしゃっています。
講演で紹介されていたブログの内容はこちら↓
https://fsharpforfunandprofit.com/posts/monoids-without-tears/
SwiftではiOS13よりCombineというフレームワークが増え
その中でZip
が定義されています。
https://developer.apple.com/documentation/combine/publishers/zip
※2019/6/22現在のベータ版の情報です。
また
zip
を定義しているライブラリがあります。
例えば下記のライブラリではzip
というメソッドで複数の処理結果をペアの組み合わせにしています。
https://github.com/pointfreeco/swift-validated
※ちなみにzip
はHaskellなどの関数型プログラミングでも定義されており
同様の使われ方をされています。
RxSwiftでも使用されています。
https://github.com/ReactiveX/RxSwift/blob/master/RxSwift/Observables/Zip.swift
https://github.com/ReactiveX/RxSwift/blob/master/RxSwift/Observables/Zip+arity.swift#L23
ドメインイベントなどのメッセージを渡したい場合
処理結果以外に他の機能にメッセージを送りたい場合もあるかもしれません。
(講演ではメールの送信に成功したことをCRMに伝えるなど)
このような場合は
success
の場合にメッセージをリストとして持ち
処理結果とメッセージのリストの組み合わせを伝達するようにします。
Githubを参考にSwiftでも実装してみました(結構無理あり)
struct Request {
let userId: Int
let name: String
let email: String
}
struct ValidationError: LocalizedError {
let field: String
let value: Any
let reason: String
var localizedDescription: String {
return "\(field): \(value) is not valid because \(reason)"
}
}
enum UseCaseMessage: LocalizedError {
case validation(ValidationError)
case update(Error)
case sendMail(Error)
case UpdateSuccess
case SendMailSuccess
var localizedDescription: String {
switch self {
case .validation(let error):
return error.localizedDescription
case .update(let error):
return "update error \(error)"
case .sendMail(let error):
return "sendMail error \(error)"
case .UpdateSuccess:
return "Update Success"
case .SendMailSuccess:
return "Send Mail Success"
}
}
}
extension Array: Error where Element: Error {}
enum ROPResult<Success, Message> {
case success(Success, [Message])
case failure([Message])
func map<NewSuccess>(_ f: (Success) -> NewSuccess) -> ROPResult<NewSuccess, Message> {
switch self {
case .success(let x, let msgs):
return .success(f(x), msgs)
case .failure(let errors):
return .failure(errors)
}
}
func flatMap<NewSuccess>(
_ transform: (Success) -> ROPResult<NewSuccess, Message>
) -> ROPResult<NewSuccess, Message> {
switch self {
case .success(let x, let msgs):
do {
let result = try transform(x).get()
return .success(result.0, result.1 + msgs)
} catch let errors as [Error] {
return .failure(errors as! [Message])
} catch {
return .failure([error] as! [Message])
}
case .failure(let errors):
return .failure(errors)
}
}
func get() throws -> (Success, [Message]) {
switch self {
case .success(let x, let msgs):
return (x, msgs)
case .failure(let errors):
throw errors as! [Error]
}
}
func mapError(
_ transform: (Message) -> Message
) -> ROPResult<Success, Message> {
switch self {
case .success(let x, let msgs):
return .success(x, msgs)
case .failure(let errors):
let newErrors = errors.map(transform)
return .failure(newErrors)
}
}
static func tee(_ f: @escaping (Success) throws -> (), msgs: [Message] = []) -> (Success) -> ROPResult {
return { a in
do {
try f(a)
return .success(a, msgs)
} catch {
return .failure([error] as! [Message])
}
}
}
}
extension ROPResult where Message == Swift.Error {
init(catching body: () throws -> (Success, Message)) {
do {
let result = try body()
self = .success(result.0, [result.1])
} catch {
self = .failure([error])
}
}
}
struct DB {
func updateDb(_ request: Request) -> ROPResult<Request, UseCaseMessage> {
return ROPResult<Request, UseCaseMessage>
.tee(self.updateDbVoid, msgs: [UseCaseMessage.UpdateSuccess])(request)
.mapError(UseCaseMessage.update)
}
func updateDbVoid(_ request: Request) throws -> Void {
return
}
}
struct SmtpServer {
func sendEmail(to request: Request) -> ROPResult<Request, UseCaseMessage> {
do {
try sendEmail(request.email)
return .success(request, [UseCaseMessage.SendMailSuccess])
} catch {
return .failure([UseCaseMessage.sendMail(error)])
}
}
func sendEmail(_ email: String) throws -> Void {
return
}
}
func validateRequest(_ request: Request) -> ROPResult<Request, UseCaseMessage> {
if request.name.isEmpty {
let error = ValidationError(field: "name", value: request.name, reason: "name should not be empty")
return .failure([.validation(error)])
}
return .success(request, [])
}
func canonicalizeEmail(_ request: Request) -> Request {
let canonicalized = request.email.trimmingCharacters(in: .whitespaces).lowercased()
return Request(userId: request.userId, name: request.name, email: canonicalized)
}
func executeUserCase(request: Request, db: DB, stmpServer: SmtpServer) -> String {
switch validateRequest(request)
.map(canonicalizeEmail)
.flatMap(db.updateDb)
.flatMap(stmpServer.sendEmail(to:)) {
case .success(let x, let messages):
// Request(userId: 1, name: "hoge", email: "hoge@hoge.com")
print("\(x)")
// Messages are [UseCaseMessage.SendMailSuccess, UseCaseMessage.UpdateSuccess]
print("Messages are \(messages)")
return "OK"
case .failure(let errors):
return errors.reduce("", { total, error in
return total + error.localizedDescription
})
}
}
let request = Request(userId: 1, name: "hoge", email: "hoge@hoge.com")
let result = executeUserCase(request: request, db: DB(), stmpServer: SmtpServer())
print("result is \(result)") // result is OK
let errorRequest = Request(userId: 1, name: "", email: "hoge@hoge.com")
let errorResult = executeUserCase(request: errorRequest, db: DB(), stmpServer: SmtpServer())
print("errorResult is \(errorResult)") // errorResult is name: is not valid because name should not be empty
※ 講演の中では一覧ができるから見やすいとのことで
エラーとドメインのメッセージを一緒に扱っていますが
エラーとメッセージは別に違う型でも
良いのではないかなと個人的には思っています。
そもそもResultで分岐しているので分かるから良いのかもしれませんが。
Failureを分けてみた例(これも結構無理あり)
struct Request {
let userId: Int
let name: String
let email: String
}
struct ValidationError: LocalizedError {
let field: String
let value: Any
let reason: String
var localizedDescription: String {
return "\(field): \(value) is not valid because \(reason)"
}
}
enum UseCaseError: LocalizedError {
case validation(ValidationError)
case update(Error)
case sendMail(Error)
var localizedDescription: String {
switch self {
case .validation(let error):
return error.localizedDescription
case .update(let error):
return "update error \(error)"
case .sendMail(let error):
return "sendMail error \(error)"
}
}
}
enum UseCaseMessage {
case UpdateSuccess
case SendMailSuccess
}
extension Array: Error where Element: Error {}
enum ROPResult<Success, Failure: Error, Message> {
case success(Success, [Message])
case failure([Failure])
func map<NewSuccess>(_ f: (Success) -> NewSuccess) -> ROPResult<NewSuccess, Failure, Message> {
switch self {
case .success(let x, let msgs):
return .success(f(x), msgs)
case .failure(let errors):
return .failure(errors)
}
}
func flatMap<NewSuccess>(
_ transform: (Success) -> ROPResult<NewSuccess, Failure, Message>
) -> ROPResult<NewSuccess, Failure, Message> {
switch self {
case .success(let x, let msgs):
do {
let result = try transform(x).get()
return .success(result.0, result.1 + msgs)
} catch let errors as [Error] {
return .failure(errors as! [Failure])
} catch {
return .failure([error] as! [Failure])
}
case .failure(let errors):
return .failure(errors)
}
}
func get() throws -> (Success, [Message]) {
switch self {
case .success(let x, let msgs):
return (x, msgs)
case .failure(let errors):
throw errors
}
}
func mapError<NewFailure>(
_ transform: (Failure) -> NewFailure
) -> ROPResult<Success, NewFailure, Message> {
switch self {
case .success(let x, let msgs):
return .success(x, msgs)
case .failure(let errors):
let newErrors = errors.map(transform)
return .failure(newErrors)
}
}
static func tee(_ f: @escaping (Success) throws -> (), msgs: [Message] = []) -> (Success) -> ROPResult {
return { a in
do {
try f(a)
return .success(a, msgs)
} catch {
return .failure([error] as! [Failure])
}
}
}
}
extension ROPResult where Failure == Swift.Error {
init(catching body: () throws -> (Success, Message)) {
do {
let result = try body()
self = .success(result.0, [result.1])
} catch {
self = .failure([error])
}
}
}
struct DB {
func updateDb(_ request: Request) -> ROPResult<Request, UseCaseError, UseCaseMessage> {
return ROPResult<Request, UseCaseError, UseCaseMessage>
.tee(self.updateDbVoid, msgs: [UseCaseMessage.UpdateSuccess])(request)
.mapError(UseCaseError.update)
}
func updateDbVoid(_ request: Request) throws -> Void {
return
}
}
struct SmtpServer {
func sendEmail(to request: Request) -> ROPResult<Request, UseCaseError, UseCaseMessage> {
do {
try sendEmail(request.email)
return .success(request, [UseCaseMessage.SendMailSuccess])
} catch {
return .failure([UseCaseError.sendMail(error)])
}
}
func sendEmail(_ email: String) throws -> Void {
return
}
}
func validateRequest(_ request: Request) -> ROPResult<Request, UseCaseError, UseCaseMessage> {
if request.name.isEmpty {
let error = ValidationError(field: "name", value: request.name, reason: "name should not be empty")
return .failure([.validation(error)])
}
return .success(request, [])
}
func canonicalizeEmail(_ request: Request) -> Request {
let canonicalized = request.email.trimmingCharacters(in: .whitespaces).lowercased()
return Request(userId: request.userId, name: request.name, email: canonicalized)
}
func executeUserCase(request: Request, db: DB, stmpServer: SmtpServer) -> String {
switch validateRequest(request)
.map(canonicalizeEmail)
.flatMap(db.updateDb)
.flatMap(stmpServer.sendEmail(to:)) {
case .success(let x, let messages):
print("\(x)")
print("Messages are \(messages)")
return "OK"
case .failure(let errors):
return errors.reduce("", { total, error in
return total + error.localizedDescription
})
}
}
非同期処理
講演内では具体的に扱っていませんでしたが
鉄道指向プログラミングは
全ての処理をシーケンシャルに扱うという訳ではなく
インプットとアウトプットをどうやって繋げていくのかということを示しています。
なのでレールの途中は並列に処理を行うこともあります。
F#ではasync
のような
非同期を同期的に扱う仕組みがあるので
それを活用することで変わらず形でコードを書くことができます。
より複雑な処理の場合は
メッセージを送ることで他のワークフローに任せてしまうなどを挙げていました。
SwiftでもCombineフレームワークの中で
Future
が定義されています。
https://developer.apple.com/documentation/combine/publishers/future
またFuture
の中で
非同期処理のコールバックでPromise
という型を受け取っていますが
これは(Result<Output, Failure>) -> Void
のtypealias
です。
https://developer.apple.com/documentation/combine/publishers/future/promise
※2019/6/22現在のベータ版の情報です。
他にも非同期を同期的に扱うライブラリが活用できます。
ライブラリの参考例
https://github.com/malcommac/Hydra
また将来的には標準として採用される予定のasync/await
などが活用できる可能性があります。
これらの他にもログや処理失敗時DBのロールバックなどについても少し言及されていたので
ご興味のある方はスライドや動画をご参照ください。
補足: EitherやMonadとの違い
Scottさんもサイトで言及されていましたが
鉄道指向プログラミングは
下記のような理由でHaskellの用語を用いていないと言っています。
より具体的な形で多くの人に理解して欲しい
これは特定のエラーハンドリングの問題を解決するためのものであり
モナドを知らない人にも
まずはより目に見える具体的な形で見てもらいたかった。
具体的なものから抽象概念を理解する方が理解の進むが早いと強く思っている。
正確にモナド則に従っているわけではない
flatMap
はmonadに必ずしも従っているわけではなく
モナドの方がもっと複雑。
Eitherは抽象的すぎる
道具ではなくレシピを提示したかった。
パンを作るためのレシピが欲しいのに
「小麦粉とオーブンを使え」とだけ言うのが役に立たないのと同様に
エラーハンドリングのためのレシピが欲しいのに
「Either
とbind(flatMap)
を使え」
とだけ言うのは役に立たない。
なので具体的なカスタムオペレーターや
map
、tee
などの数多くの状況に使えるけれども
書き方は一つに限定されるようなテンプレートを提供したかった。
こうすることで後々誰がメンテナンスしても全体像が理解しやすくなって楽になる。
最後に
鉄道指向プログラミングの概要と
Result
の動きを見てみました。
鉄道指向プログラミングでは
型を合わせていくことに焦点を当てており
型を通して処理を考えることの大切さや効果といったことを学べました。
またF#というあまり触れる機会がない言語に触れ
普段とは違う考え方やコードの書き方を知り
すごい勉強になりました。
今回は出てきませんが
鉄道指向プログラミングは
ドメイン駆動設計などの話にも繋がっており
Scottさんもドメインモデルについての本や講演もされています。
https://fsharpforfunandprofit.com/books/
https://www.youtube.com/watch?v=Up7LcbGZFuo
まだまだ私が理解できていない部分も多々あると思いますので
引き続き学んでみたいと思います。
間違いなどございましたらご指摘して頂けますとうれしいです