LoginSignup
96
64

More than 3 years have passed since last update.

【Swift】鉄道指向プログラミング(Railway Oriented Programming)でResultの使い方を学ぶ

Posted at

少し前に「これは何だろう?」と思ったことについて調べてみました。

SwiftのResultとは?

SuccessFailureの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!

今回の例

以下の処理を行います。

  1. リクエストを受け取る
  2. バリデーションチェックをする
  3. リクエストをDBに保存(更新)する
  4. 認証済みのメールに送信する
  5. ユーザに結果を返す

命令型プログラミングで書いた例

下記の例を見てみます

命令型プログラミングの実装

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文が途中で入ってくるため
本来のやりたいことが見えづらくなってしまいます。

では
このようなエラーハンドリングを
どうやって関数型プログラミングを使って
綺麗な形で処理できるでしょうか?

結果のパターンを考えてみる

上記の例の処理の流れを考えてみます。

スクリーンショット 2019-06-01 5.09.47.png

Requestを受け取り
処理が成功した場合は次の処理へ
エラーの場合はエラーになった時点で
レスポンスを返しています。

次に関数型プログラミングの形で考えてみます。

スクリーンショット 2019-06-01 5.09.20.png

関数型では処理を上から下へ向かう
データの流れとして捉えます。

ここで上記の図のように処理の結果は
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を使うと処理の流れは下記のようになります。

スクリーンショット 2019-06-01 5.21.22.png

こうすることで各関数が同じレスポンスを返すようになります。
下記に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>

ということで型が合いません。

どうやって各処理を連結できるようになるでしょうか?

鉄道の車線をイメージしてみる(鉄道指向プログラミング)

下記の図を見てください。

スクリーンショット 2019-06-01 5.58.31.png

一つのインプットを与えると一つのアウトプットを出力する関数を
鉄道の車線に例えています。

次の図を見てください。

スクリーンショット 2019-06-01 9.44.17.png
スクリーンショット 2019-06-01 9.46.21.png
スクリーンショット 2019-06-01 9.46.28.png

もう一つ車線が出てきました。
左の関数のアウトプットと右の関数のインプットが一致しているため
この二つの車線を繋げることができます。

このような場合は
シンプルですぐに理解できると思います。

Resultの場合はアウトプットが2つの可能性があります。
これを表現するためには車線の分岐が必要になります。

スクリーンショット 2019-06-01 9.52.25.png

Success車線とFailure車線ができます。

このような分岐を生じる関数を
スイッチ関数
と呼びます。

ではスイッチ関数を連結した場合の動きはどうなるでしょうか?

スクリーンショット 2019-06-01 10.02.15.png
スクリーンショット 2019-06-01 10.02.23.png

上記の例で説明すると
Validate関数が
成功した場合 -> Success車線を通りUpdateDbを実行する
失敗した場合 -> Failure車線を通りUpdateDb実行せずFailure車線を通り続ける

となります。

言い換えると

処理が成功している場合のみ処理を継続し
エラーが発生した場合は以降の処理を行わずに最終的なアウトプットまで進む

ことになります。

なんとなくイメージはできたでしょうか?

では
問題のResultの連結方法について車線で考えてみます。

上記で示した1つの車線の関数の連結はシンプルでした。

同様にインプットとアウトプットが2車線同士の関数の場合もシンプルです。

スクリーンショット 2019-06-01 10.21.28.png

インプットとアウトプットが一致すればそのまま繋げることができます。

スクリーンショット 2019-06-01 10.27.22.png

しかしResultを返すような関数は通常の値をインプットとして受け取るため
インプットとアウトプットが一致するため連結できません。

ではどうすれば良いのか?

車線の数を合わせれば良い

のです。

1車線インプット、2車線アウトプットの関数から
2車線インプット、2車線アウトプットの関数へ変換する
ことで2車線関数同士の関数を繋ぎ合わせることと同じになります。

スクリーンショット 2019-06-01 10.27.33.png

スクリーンショット 2019-06-01 10.27.47.png

それを実現するのが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)
        }
    }
}

引数に
ResultSuccess型を引数にして
変換して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車線関数です。

これも連結して処理できるようにしたいですが

スクリーンショット 2019-06-01 15.44.01.png

1車線関数のアプトプットと2車線関数のインプットをそのまま繋げることはできません。
同様に2車線関数のインプットと1車線関数のアウトプットをそのまま繋げることはできません。

ではどうするか?

これもflatMapの時と同じように考えます。

スクリーンショット 2019-06-01 15.48.19.png

つまり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を使っても連結ができません。

※こういう関数はデッドエンド関数と呼ばれているそうです。

スクリーンショット 2019-06-01 16.33.01.png

ではどうするのか?

インプットで受け取った値を内部で関数を実行した後に
そのままインプットの値を返すようにします。

スクリーンショット 2019-06-01 16.35.56.png

今回はメソッドを一つ追加します。


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/

その他の鉄道指向プログラミング

この他にも色々な場合に関しての
鉄道指向プログラミングのアプローチが紹介されています。

いくつか挙げたいと思います。

複数のエラーが欲しい場合は?

これまで見てきたケースですと
エラーは一つしか扱うことができません。

スクリーンショット 2019-06-02 5.12.58.png

例えばバリデーションチェックのエラーは
発生した全てのエラーが欲しい場合があるかもしれません。

そのような場合
講演の中では詳細には触れていませんが
それぞれの処理をPairとして組み合わせていけば
いくつでも組み合わせることができる
といったことをおっしゃっています。

スクリーンショット 2019-06-02 5.15.09.png

講演で紹介されていたブログの内容はこちら↓
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に伝えるなど)

スクリーンショット 2019-06-02 5.35.12.png

このような場合は
successの場合にメッセージをリストとして持ち
処理結果とメッセージのリストの組み合わせを伝達するようにします。

スクリーンショット 2019-06-02 5.33.47.png

Githubを参考にSwiftでも実装してみました(結構無理あり:sweat_smile:)

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を分けてみた例(これも結構無理あり:sweat_smile:)

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>) -> Voidtypealiasです。
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は抽象的すぎる

道具ではなくレシピを提示したかった。

パンを作るためのレシピが欲しいのに
「小麦粉とオーブンを使え」とだけ言うのが役に立たないのと同様に
エラーハンドリングのためのレシピが欲しいのに
Eitherbind(flatMap)を使え」
とだけ言うのは役に立たない。

なので具体的なカスタムオペレーターや
mapteeなどの数多くの状況に使えるけれども
書き方は一つに限定されるようなテンプレートを提供したかった。

こうすることで後々誰がメンテナンスしても全体像が理解しやすくなって楽になる。

最後に

鉄道指向プログラミングの概要と
Resultの動きを見てみました。

鉄道指向プログラミングでは
型を合わせていくことに焦点を当てており
型を通して処理を考えることの大切さや効果といったことを学べました。

またF#というあまり触れる機会がない言語に触れ
普段とは違う考え方やコードの書き方を知り
すごい勉強になりました。

今回は出てきませんが
鉄道指向プログラミングは
ドメイン駆動設計などの話にも繋がっており
Scottさんもドメインモデルについての本や講演もされています。

https://fsharpforfunandprofit.com/books/
https://www.youtube.com/watch?v=Up7LcbGZFuo

まだまだ私が理解できていない部分も多々あると思いますので
引き続き学んでみたいと思います。

間違いなどございましたらご指摘して頂けますとうれしいです:bow_tone1:

96
64
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
96
64