ユーザーとして、システムの名前と電子メールを変更したい。
この単純なユーザーストーリーを実装するには、要求を受信し、検証し、データベース内の既存のレコードを更新し、ユーザーに確認メールを送信し、ブラウザーに応答を返す必要があります。 コードは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
幸せな旅から逸脱
ストーリーを追加しましょう:
ユーザーとして、システムの名前とメールを変更したい
何か問題が発生した場合は、エラーメッセージを参照してください。
何がおかしいのでしょうか?
- 名前が空で、メールが正しくない可能性があります
- このIDを持つユーザーがデータベースに見つからない可能性があります
- 確認メールの送信中に、SMTPサーバーが応答しない場合があります
- ...
エラー処理コードを追加する
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つではなく4つの値を返す方法(エラーの種類ごとに1つの戻り値)
関数が複数の値を返すにはどうすればよいですか?
機能的PLでは、 ユニオン型が広く普及しています。 それらの助けを借りて、1つのタイプのフレームワーク内でいくつかの可能な状態をシミュレートできます。 この関数には1つの戻り値がありますが、現在は成功またはエラーのタイプの4つの可能な値のいずれかを取ります。 データのアプローチを一般化するためにのみ残っています。
Success
と
Failure
の2つの値で構成される結果タイプを宣言し、データとともに汎用引数を追加します。
type Result<'TEntity> = | Success of 'TEntity | Failure of string
機能設計
- 各ユースケースは単一の関数で実装されます。
- 関数は
Success
とFailure
から和集合を返します - ユースケースを処理する関数は、それぞれが1つのデータ変換ステップに対応する、より小さな関数の構成を使用して作成されます
- 各ステップでのエラーは組み合わされて単一の値を返します
機能的なスタイルでエラーを処理する方法は?
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
ません。 したがって、次のような一連の手法を含む統合アプローチを提供します。
-
Either String a
なく、特殊なエラータイプのリスト - パイプラインでモナド関数を構成するための
bind (>>=)
- モナド関数の合成のためのクレイズリー合成(
>=>
) - パイプラインに非モナド
fmap
を統合fmap
ためのmap
およびfmap
-
unit
を返す関数を統合するtee
関数(F#のvoid
に類似) - 例外をエラーコードにマッピングする
- 並列処理でモナド関数を結合するための
&&&
(検証など) - ドメイン駆動設計(DDD)でエラーコードを使用する利点
- ロギング、ドメインイベント、補償トランザクションなどの明らかな拡張
「どちらかのモナドを使用する」以上のことを楽しんでください。
鉄道の類推
私はその機能を鉄道と変換トンネルとして表すのが好きです。 リンゴをバナナに変換する(
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トラックモデル」に変換し、合成を使用してそれらを結合します。 すべてが正常になるまで緑のわだちに沿って移動するか、エラーの場合は赤に変わります。
しかし、それだけではありません。 このモデルに適合する必要があります
- エラーのないシングルトラック機能
- 行き止まりの機能
- 例外スロー機能
- 制御機能
エラーのないシングルトラック機能
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の左側の関数をラップします。
フレームワークの拡大
- 考えられる設計エラーを考慮します
- 並列化
- ドメインイベント
考えられる設計エラーを考慮します
エラー処理はソフトウェア要件の一部であることを強調したいと思います 。 成功するシナリオにのみ焦点を当てます。 成功したシナリオと権利のエラーを平準化する必要があります。
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つの関数に変更を加えるだけで済みます。 要約すると、このアプローチには次の利点があります。
- 何かがうまくいかなかったすべての場合のドキュメント
- タイプセーフ、期限切れにすることはできません
- 隠されたシステム要件を明らかにする
- 単体テストを簡素化
- 国際化を簡素化する
並列化
検証のある例では、シーケンシャルモデルはパラレルモデルに比べて使い勝手が劣ります。各フィールドの検証エラーを受け取る代わりに、すべてのエラーを一度に取得して同時に修正する方が便利です。
ペアに操作を適用し、結果として同じタイプのオブジェクトを取得できる場合、そのような操作をリストにも適用できます。 これはモノイドの特性です。 このトピックをより深く理解するには、「 涙のないモノイド 」という記事を読むことができます。
ドメインイベント
場合によっては、追加情報を伝える必要があるかもしれません。 これらはエラーではなく、操作のコンテキストでさらに重要なものです。 これらのメッセージを「成功したパス」の戻り値に追加できます。
この記事の範囲外
- サービスの境界を越えるエラーの処理
- 非同期モデル
- 補償取引
- ロギング
まとめ 機能的なスタイルのエラー処理
-
Result
タイプを作成します。 古典的なEither
さらに抽象的で、Left
プロパティとRight
プロパティが含まれています。 私のResult
タイプResult
、より専門的なものです。 - バインドを使用して、関数を「ダブルトラックモデル」に変換します
- コンポジションを使用して個々の機能をリンクします
- エラーコードは最初のクラスのオブジェクトと見なします
参照資料
- サンプル付きのソースコードはgithubで入手できます
- C#で実装されたレポートに基づくHabréの記事