鉄道指向のプログラミング。 機能的なスタイルのエラー処理







ユーザーとして、システムの名前と電子メールを変更したい。


この単純なユーザーストーリーを実装するには、要求を受信し、検証し、データベース内の既存のレコードを更新し、ユーザーに確認メールを送信し、ブラウザーに応答を返す必要があります。 コードはC#でほぼ同じになります。



string ExecuteUseCase() { var request = receiveRequest(); validateRequest(request); canonicalizeEmail(request); db.updateDbFromRequest(request); smtpServer.sendEmail(request.Email); return "Success"; }
      
      





およびF#:



 let executeUseCase = receiveRequest >> validateRequest >> canonicalizeEmail >> updateDbFromRequest >> sendEmail >> returnMessage
      
      





幸せな旅から逸脱







ストーリーを追加しましょう:

ユーザーとして、システムの名前とメールを変更したい

何か問題が発生した場合は、エラーメッセージを参照してください。


何がおかしいのでしょうか?







  1. 名前が空で、メールが正しくない可能性があります
  2. このIDを持つユーザーがデータベースに見つからない可能性があります
  3. 確認メールの送信中に、SMTPサーバーが応答しない場合があります
  4. ...


エラー処理コードを追加する



 string ExecuteUseCase() { var request = receiveRequest(); var isValidated = validateRequest(request); if (!isValidated) { return "Request is not valid" } canonicalizeEmail(request); try { var result = db.updateDbFromRequest(request); if (!result) { return "Customer record not found" } } catch { return "DB error: Customer record not updated" } if (!smtpServer.sendEmail(request.Email)) { log.Error "Customer email not sent" } return "OK"; }
      
      





突然、6行ではなく18行のコードに分岐とネストが追加され、読みやすさが大幅に低下しました。 このコードの機能的に同等なものは何ですか? 見た目はまったく同じですが、今ではエラー処理があります。 あなたは私を信じていないかもしれませんが、私たちが最後に到達すると、これが真実であることがわかります。



命令型の要求応答アーキテクチャ







リクエスト、回答があります。 データは、あるメソッドから別のメソッドにチェーンで送信されます。 エラーが発生した場合は、早期復帰を使用します。



機能的なスタイルのリクエスト/レスポンスアーキテクチャ







「幸せな旅」では、すべてがまったく同じです。 関数の構成を使用して、メッセージをチェーンで受け渡し処理します。 ただし、何か問題が発生した場合は、関数からの戻り値としてエラーメッセージを渡す必要があります。 したがって、2つの問題があります。



  1. エラーが発生した場合に残りの機能を無視する方法は?
  2. 1つではなく4つの値を返す方法(エラーの種類ごとに1つの戻り値)


関数が複数の値を返すにはどうすればよいですか?



機能的PLでは、 ユニオン型が広く普及しています。 それらの助けを借りて、1つのタイプのフレームワーク内でいくつかの可能な状態をシミュレートできます。 この関数には1つの戻り値がありますが、現在は成功またはエラーのタイプの4つの可能な値のいずれかを取ります。 データのアプローチを一般化するためにのみ残っています。 Success



Failure



の2つの値で構成される結果タイプを宣言し、データとともに汎用引数を追加します。



 type Result<'TEntity> = | Success of 'TEntity | Failure of string
      
      





機能設計







  1. 各ユースケースは単一の関数で実装されます。
  2. 関数はSuccess



    Failure



    から和集合を返します
  3. ユースケースを処理する関数は、それぞれが1つのデータ変換ステップに対応する、より小さな関数の構成を使用して作成されます
  4. 各ステップでのエラーは組み合わされて単一の値を返します


機能的なスタイルでエラーを処理する方法は?







FPに精通している非常に賢い友人がいる場合は、次のような対話があります。





オリジナルの次は、 Maybe



(たぶん)とEither



(または、 Either



か一方)に基づいた翻訳不可能なしゃれです。 Maybe



Either



もモナド名です。 英語のユーモアが好きで、FPの用語も「アカデミック」だと思う場合は、必ず元のレポートをチェックしてください。


MonadとClaysleyのいずれかとの接続





Haskellのファンなら誰でも、私が説明したアプローチがEither



モナドであることに気付くでしょう。これは「左」の場合に特化したタイプのエラーリストです。 Haskellでは、次のように書くことができます。



 type Result ab = Either [a] (b,[a])
      
      





もちろん、私はこのアプローチの発明者になりすまそうとはしていませんが、鉄道との愚かな類推の原作者だと主張しています。 では、なぜ標準的なHaskellの用語を使用しなかったのですか? まず、これは別のモナドガイドではありません。 代わりに、主な焦点は特定のエラー処理問題の解決にあります。 F#を学び始めるほとんどの人はモナドに慣れていないので、私は多くの人にとって、威圧的でなく、より視覚的で直感的なアプローチを好む。



第二に、特定から一般へのアプローチがより効果的であると確信しています。現在の抽象をよく理解していれば、次の抽象のレベルに登る方がはるかに簡単です。 「2トラック」アプローチをモナドと呼ぶと、私は間違っているでしょう。 モナドはより複雑であり、この資料ではモナドの法則扱いたくありません。



第三に、 Either



もあまりにも一般的な概念です。 ツールではなく、レシピを紹介したいと思います。 「小麦粉とオーブンを使うだけ」というパンのレシピはあまり役に立ちません。 「 bind



Either



使用bind



」というスタイルでエラーを処理するためのマニュアルもまったくEither



ません。 したがって、次のような一連の手法を含む統合アプローチを提供します。



  1. Either String a



    なく、特殊なエラータイプのリスト
  2. パイプラインでモナド関数を構成するためのbind (>>=)



  3. モナド関数の合成のためのクレイズリー合成( >=>



  4. パイプラインに非モナドfmap



    を統合fmap



    ためのmap



    およびfmap



  5. unit



    を返す関数を統合するtee



    関数(F#のvoid



    に類似)
  6. 例外をエラーコードにマッピングする
  7. 並列処理でモナド関数を結合するための&&&



    (検証など)
  8. ドメイン駆動設計(DDD)でエラーコードを使用する利点
  9. ロギング、ドメインイベント、補償トランザクションなどの明らかな拡張


「どちらかのモナドを使用する」以上のことを楽しんでください。



鉄道の類推





私はその機能を鉄道と変換トンネルとして表すのが好きです。 リンゴをバナナに変換する( apple → banana



)および他のバナナをチェリーに変換する( banana → cherry



)2つの関数があり、それらを組み合わせて、リンゴをチェリーに変換する( apple → cherry



)関数を取得します。 プログラマーの観点からは、この関数がコンポジションを使用して取得されるか、手動で作成されるかにかかわらず、主なものはその署名です。



フォーク



しかし、少し異なるケースがあります。1つの値が入力にあり、2つの可能な値が出力にあります。1つは正常終了、もう1つはエラーです。 「鉄道」の用語では、フォークが必要です。 Validate



およびUpdateDb



は、このようなフォーク関数です。 それらを互いに組み合わせることができます。 SendEmail



関数をValidate



およびSendEmail



ます。 私はそれを「複線モデル」と呼んでいます。 一部の人々は、「どちらのモナド」を処理するエラーに対してこのアプローチを呼び出すことを好みますが、私は自分の名前を好みます(「モナド」という単語が含まれていないためだけです)。





現在、「シングルトラック」および「ダブルトラック」機能があります。 別々に、両方とも配置されますが、互いに配置されません。 これを行うには、小さな「アダプター」が必要です。 成功した場合は、関数を呼び出して値を渡します。エラーが発生した場合は、エラー値を変更せずにそのまま渡します。 FPでは、この関数はbind



と呼ばれます。







縛る





 let bind switchFunction = fun twoTrackInput -> match twoTrackInput with | Success s -> switchFunction s | Failure f -> Failure f // ('a -> Result<'b>) -> Result<'a> -> Result<'b>
      
      





ご覧のとおり、この関数は非常に単純です。ほんの数行のコードです。 関数の署名に注意してください。 署名はFPで非常に重要です。 最初の引数は「アダプター」、2番目の引数は2トラックモデルの入力値、出力は2トラックモデルの値です。 list



asyn



feature



またはpromise



を使用して、他のタイプでこの署名が表示された場合、同じbind



ます。 この関数は、たとえばLINQ



SelectMany



など、別の方法で呼び出すことができますが、本質は変わりません。



検証



たとえば、3つの検証ルールがあります。 bind



(各ルールを「ダブルトラックモデル」に変換する)と関数構成を使用して、いくつかの検証ルールを「チェーン」できます。 それがエラー処理の秘密です。



 let validateRequest = bind nameNotBlank >> bind name50 >> bind emailNotBlank
      
      





これで、入力要求を受け入れて応答を返す「2レーン」関数ができました。 他の機能の構成要素として使用できます。

多くの場合、 bind



>>=



演算子で示されます。 Haskellから借用しています。 >>=



を使用する場合>>=



コードは次のようになります。



 let (>>=) twoTrackInput switchFunction = bind switchFunction twoTrackInput let validateRequest twoTrackInput = twoTrackInput >>= nameNotBlank >>= name50 >>= emailNotBlank
      
      





bind



型チェックは以前と同じように機能します。 構成可能な関数がある場合は、 bind



を適用した後も構成可能なままになります。 関数が構成可能でない場合、 bind



はそうしません。



したがって、エラー処理の基礎は次のとおりです。 bind



を使用して関数を「2トラックモデル」に変換し、合成を使用してそれらを結合します。 すべてが正常になるまで緑のわだちに沿って移動するか、エラーの場合は赤に変わります。



しかし、それだけではありません。 このモデルに適合する必要があります



  1. エラーのないシングルトラック機能
  2. 行き止まりの機能
  3. 例外スロー機能
  4. 制御機能


エラーのないシングルトラック機能





 let canonicalizeEmail input = { input with email = input.email.Trim().ToLower() }
      
      





canonicalizeEmail



関数は非常に簡単です。 余分なスペースを切り捨て、メールを小文字に変換します。 エラーと例外を含めるべきではありません(NREを除く)。 これは単なる文字列変換です。



問題は、2トラック機能のみをbind



して作成することを学んだことです。 もう1つのアダプターが必要です。 このアダプターはmap



と呼ばれmap



LINQ



Select



)。



 let map singleTrackFunction twoTrackInput = match twoTrackInput with | Success s -> Success (singleTrackFunction s) | Failure f -> Failure f // map : ('a -> 'b) -> Result<'a> -> Result<'b>
      
      





map



bind



を使用して作成できますが、その逆はできないため、 map



bind



よりも弱い関数です。



行き止まり機能





 let updateDb request = // do something // return nothing at all
      
      





デッドロック関数は、火と忘却の精神での書き込み操作です。データベースの値を更新するか、ファイルを書き込みます。 戻り値はありません。 また、ダブルトラック機能では構成しません。 必要なのは、入力値を取得し、「デッドエンド」関数を実行し、値をチェーンのさらに下に渡すことです。 bind



およびmap



との類推によりmap



tee



関数( tap



と呼ばれることもありmap



宣言します。



 let tee deadEndFunction oneTrackInput = deadEndFunction oneTrackInput oneTrackInput // tee : ('a -> unit) -> 'a -> 'a
      
      







例外スロー機能



おそらく、特定の「パターン」が現れ始めていることにお気づきでしょう。 特に、入力/出力で機能する機能。 このようなメソッドのシグネチャは、正常に完了したことに加えて、例外をスローする可能性があるため、追加の出口点が作成されるためです。 これは署名からは見えません。特定の関数がスローする例外を知るために、ドキュメントをよく理解する必要があります。



例外は、この2トラックモデルには適していません。 それらを処理しましょう: SendEmail



関数は安全に見えますが、例外をスローする可能性があります。 別の「アダプター」を追加し、そのようなすべての関数をtry / catchブロックでラップします。



やる、しない、試してはいけない 」-ヨーダでさえ、制御フローに例外使用することを推奨していません。 Adam Sitnikの例外的な例外 (英語)のレポートで、このトピックに関する多くの興味深いことがあります


制御機能





そのような関数では、たとえば、成功した操作またはエラー、あるいはその両方のみをログに記録するなど、追加のロジックを実装する必要があります。 複雑なことは何もありません。前のケースとの類推によって行います。



すべてをまとめる





Validate



Canonicalize



UpdateDb



、およびUpdateDb



の機能を組み合わせました。 1つの問題が残っています。 ブラウザは「ダブルトラックモデル」を理解しません。 ここで、「単一トラック」モデルに戻る必要があります。 関数returnMessage



を追加します。 成功の場合はhttpコード200とJSONを返し、エラーの場合はBadRequest



とメッセージをBadRequest



ます。



 let executeUseCase = receiveRequest >> validateRequest >> updateDbFromRequest >> sendEmail >> returnMessage
      
      





そのため、エラー処理のないコードはエラー処理のあるコードと同一になると約束しました。 少しだまして、新しい名前空間の新しい関数を発表しました。bindの左側の関数をラップします。



フレームワークの拡大



  1. 考えられる設計エラーを考慮します
  2. 並列化
  3. ドメインイベント


考えられる設計エラーを考慮します



エラー処理はソフトウェア要件の一部であることを強調したいと思います 。 成功するシナリオにのみ焦点を当てます。 成功したシナリオと権利のエラーを平準化する必要があります。



 let validateInput input = if input.name = "" then Failure "Name must not be blank" else if input.email = "" then Failure "Email must not be blank" else Success input // happy path type Result<'TEntity> = | Success of 'TEntity | Failure of string
      
      





検証機能を検討してください。 エラーには文字列を使用します。 これは嫌なアイデアです。 エラー用の特別なタイプを紹介します。 F#は通常、enumではなくunion型を使用します。 タイプErrorMessageを宣言します。 ここで、新しいエラーが発生した場合、ErrorMessageに別のオプションを追加する必要があります。 これは負担のように思えるかもしれませんが、そのようなコードは自己文書化されているため、逆に良いと思います。



 let validateInput input = if input.name = "" then Failure NameMustNotBeBlank else if input.email = "" then Failure EmailMustNotBeBlank else if (input.email doesn't match regex) then Failure EmailNotValid input.email else Success input // happy path type ErrorMessage = | NameMustNotBeBlank | EmailMustNotBeBlank | EmailNotValid of EmailAddress
      
      





レガシーコードを使用することを想像してください。 システムがどのように機能するかは想像できますが、何が間違っているのか正確にはわかりません。 考えられるすべてのエラーを説明するファイルがある場合はどうなりますか? さらに重要なことは、これは単なるテキストではなくコードであるため、この情報は関連性があります。



このアプローチは、Javaのチェック例外に非常に似ています。 彼らが離陸しなかったことは注目に値する。


DDDを実践すれば、このコードに基づいてビジネスユーザーとのコミュニケーションを構築できます。 この状況またはその状況をどのように処理するかについて質問する必要があります。これにより、設計段階でさらに多くのユースケースを検討するようになります。



文字列をエラータイプに置き換えた後、 retrunMessage



関数を変更して、タイプを文字列に変換する必要があります。



 let returnMessage result = match result with | Success _ -> "Success" | Failure err -> match err with | NameMustNotBeBlank -> "Name must not be blank" | EmailMustNotBeBlank -> "Email must not be blank" | EmailNotValid (EmailAddress email) -> sprintf "Email %s is not valid" email // database errors | UserIdNotValid (UserId id) -> sprintf "User id %i is not a valid user id" id | DbUserNotFoundError (UserId id) -> sprintf "User id %i was not found in the database" id | DbTimeout (_,TimeoutMs ms) -> sprintf "Could not connect to database within %i ms" ms | DbConcurrencyError -> sprintf "Another user has modified the record. Please resubmit" | DbAuthorizationError _ -> sprintf "You do not have permission to access the database" // SMTP errors | SmtpTimeout (_,TimeoutMs ms) -> sprintf "Could not connect to SMTP server within %i ms" ms | SmtpBadRecipient (EmailAddress email) -> sprintf "The email %s is not a valid recipient" email
      
      





変換ロジックはコンテキストに依存する場合があります。 これにより、国際化タスクが大幅に容易になります。コードベース全体に散在する行を探すのではなく、UIレイヤーに制御を移す直前に1つの関数に変更を加えるだけで済みます。 要約すると、このアプローチには次の利点があります。



  1. 何かがうまくいかなかったすべての場合のドキュメント
  2. タイプセーフ、期限切れにすることはできません
  3. 隠されたシステム要件を明らかにする
  4. 単体テストを簡素化
  5. 国際化を簡素化する


並列化





検証のある例では、シーケンシャルモデルはパラレルモデルに比べて使い勝手が劣ります。各フィールドの検証エラーを受け取る代わりに、すべてのエラーを一度に取得して同時に修正する方が便利です。



ペアに操作を適用し、結果として同じタイプのオブジェクトを取得できる場合、そのような操作をリストにも適用できます。 これはモノイドの特性です。 このトピックをより深く理解するには、「 涙のないモノイド 」という記事を読むことができます。



ドメインイベント







場合によっては、追加情報を伝える必要があるかもしれません。 これらはエラーではなく、操作のコンテキストでさらに重要なものです。 これらのメッセージを「成功したパス」の戻り値に追加できます。



この記事の範囲外



  1. サービスの境界を越えるエラーの処理
  2. 非同期モデル
  3. 補償取引
  4. ロギング


まとめ 機能的なスタイルのエラー処理







  1. Result



    タイプを作成します。 古典的なEither



    さらに抽象的で、 Left



    プロパティとRight



    プロパティが含まれています。 私のResult



    タイプResult



    、より専門的なものです。
  2. バインドを使用して、関数を「ダブルトラックモデル」に変換します
  3. コンポジションを使用して個々の機能をリンクします
  4. エラーコードは最初のクラスのオブジェクトと見なします


参照資料



  1. サンプル付きのソースコードはgithubで入手できます
  2. C#で実装されたレポートに基づくHabréの記事





All Articles