登録フローを関数型アーキテクチャにリファクタリングする
2025-04-08 17:00:25
原文: Refactoring registration flow to functional architecture by Mark Seemann
F#における部分適用による「依存関係の注入」から、不純/純粋/不純のサンドイッチパターンへのリファクタリング例を紹介します。
以前、依存関係の排除に関する記事のコメントでこのような質問をしました。
「不純/純粋/不純のサンドイッチパターンが実現できないような、単純だけれども具体的な例を教えてもらえませんか?」
Christer van der Meerenさんが親切にも一例を提案してくれました。
問題のコードはユーザーアカウントの検証に関するものです。詳細は元のコメントを参照していただくとして、ここではその概要を説明し、関数型アーキテクチャ、特に不純/純粋/不純のサンドイッチパターンへのリファクタリング方法を示します。
コードはGitHubで公開されています。
登録フロー
このシステムでは、携帯電話による二要素認証を使用しています。サービスにサインアップする際に電話番号を入力すると、SMSが送信されます。そのSMSの内容を使って電話番号の所有者であることを証明する必要があります。Christer van der Meerenさんはこのフローを次のように図示しています。
彼はサンプルコードも提供してくれました。
let completeRegistrationWorkflow
(createProof: Mobile -> Async<ProofId>)
(verifyProof: Mobile -> ProofId -> Async<bool>)
(completeRegistration: Registration -> Async<unit>)
(proofId: ProofId option)
(registration: Registration)
: Async<CompleteRegistrationResult> =
async {
match proofId with
| None ->
let! proofId = createProof registration.Mobile
return ProofRequired proofId
| Some proofId ->
let! isValid = verifyProof registration.Mobile proofId
if isValid then
do! completeRegistration registration
return RegistrationCompleted
else
let! proofId = createProof registration.Mobile
return ProofRequired proofId
}
これはF#で書かれていますが、部分適用を使って依存関係を注入しているため、真の関数型スタイルとは言えません。説明のため、AsyncをIOの代用とみなしても問題ないでしょう。
このコードは他の型が存在することを示唆しているので、私は次のような型を定義しました。
type Mobile = Mobile of int
type ProofId = ProofId of Guid
type Registration = { Mobile : Mobile }
type CompleteRegistrationResult = ProofRequired of ProofId | RegistrationCompleted
実際にはもっと複雑かもしれませんが、コードをコンパイルするにはこれで十分でしょう。
completeRegistrationWorkflow
を不純/純粋/不純のサンドイッチパターンにリファクタリングすることは可能でしょうか?
適用可能性
completeRegistrationWorkflow
を不純/純粋/不純のサンドイッチにリファクタリングすることは可能です。その方法は後で説明しますが、その前に一つ注意しておきたいことがあります。この問題設定は現実世界の微妙な点を完全には捉えていないかもしれませんし、私がChrister van der Meerenさんの問題の本質を誤解している可能性もあります。
プログラミングの基礎を教えることは比較的簡単です。初心者にキーワードや構文、プログラムのコンパイル方法などを教えればいいのです。
一方、複雑なコードの扱い方について説明するのは難しいものです。レガシーコードを改善する方法はありますが、必要なアプローチは無数の詳細によって異なります。複雑なコードは、定義上、理解が難しいものです。つまり、本当に複雑なレガシーコードは教育的な例としてあまり適していません。改善する価値があると感じられるほど複雑に見え、かつ理解できるほど単純である例を作成するという、微妙なバランスが求められます。
Christer van der Meerenさんはこのバランスをうまく取っていると思います。サンプルコードには3つの依存関係があり、リファクタリングの価値があるように見えつつ、数分で理解できる程度の複雑さです。ただし、この例が単純化されすぎているリスクはあります。その場合、以降のリファクタリングの結果が弱まる可能性があります。もっと複雑な問題でも同じリファクタリング手法が適用できるでしょうか?
私の経験では、不純/純粋/不純のサンドイッチパターンは非常に広範囲に適用可能です。
フェイクオブジェクト
この記事の残りの部分では、completeRegistrationWorkflow
を不純/純粋/不純のサンドイッチにリファクタリングする方法を示します。『リファクタリング』の著者Martin Fowlerが言うように:
「リファクタリングを行うための不可欠な前提条件は、[…]しっかりとしたテストです」
現時点ではテストがないので、まずいくつか追加しましょう。
テストでは3つの依存関数の代わりとなるテストダブルが必要です。可能であれば、相互作用ベースのテストよりも状態ベースのテストを優先します。まず、いくつかのフェイクオブジェクトを作成します。
completeRegistrationWorkflow
は3つの依存関数を受け取りますが、アーキテクチャ上の依存関係は2つだけのようです。
- 二要素認証サービス
- 登録データベース(またはサービス)
二要素認証のフェイクオブジェクトの定義は2つのうち複雑な方ですが、それでも扱いやすいレベルです。
type Fake2FA () =
let mutable proofs = Map.empty
member _.CreateProof mobile =
match Map.tryFind mobile proofs with
| Some (proofId, _) -> proofId
| None ->
let proofId = ProofId (Guid.NewGuid ())
proofs <- Map.add mobile (proofId, false) proofs
proofId
|> fun proofId -> async { return proofId }
member _.VerifyProof mobile proofId =
match Map.tryFind mobile proofs with
| Some (_, true) -> true
| _ -> false
|> fun b -> async { return b }
member _.VerifyMobile mobile =
match Map.tryFind mobile proofs with
| Some (proofId, _) ->
proofs <- Map.add mobile (proofId, true) proofs
| _ -> ()
F#では変更可能なリソースをモデル化する最も簡単な方法はオブジェクトを使うことでしょう。このオブジェクトは証明のコレクションを追跡するだけです。CreateProof
メソッドはcompleteRegistrationWorkflow
のcreateProof
関数引数と互換性のある関数シグネチャを持ちます。既存の携帯電話番号の証明を探し、同じ証明を何度も再利用できるようにします。携帯電話番号の証明がない場合は新しいGUIDを作成し、コレクションに追加して返します。
同様に、VerifyProof
メソッドはverifyProof
関数引数の型と互換性があります。証明は実際にはIDと検証済みフラグのタプルです。このメソッドは、フラグが存在すればそれを返し、存在しなければ false
を返します。
三番目のVerifyMobile
メソッドはテスト専用の機能で、テストで二要素認証によって証明が検証済みであることを示すことができます。
Fake2FA
と比べると、登録データベースのフェイクは単純です。
type FakeRegistrationDB () =
inherit Collection<Registration> ()
member this.CompleteRegistration r = async { this.Add r }
ここでも、CompleteRegistration
メソッドはcompleteRegistrationWorkflow
関数のcompleteRegistration
引数と互換性があります。継承されたAdd
メソッドをAsync
にラップしているだけです。
フィクスチャの作成
リファクタリングのための仕様化テストを追加する予定ですが、テスト対象システム(SUT)のAPIも変更します。これによりテストが壊れる可能性があり、テストの目的が損なわれます。これを防ぐために、ファサードに対してテストを行います。最初はこのファサードがcompleteRegistrationWorkflow
関数と同等ですが、リファクタリングを進めるにつれて変化します。
SUTファサードに加えて、テストでは「注入された」依存関係にもアクセスする必要があります。これはフィクスチャオブジェクトを作成することで対応できます。
let createFixture () =
let twoFA = Fake2FA ()
let db = FakeRegistrationDB ()
let sut =
completeRegistrationWorkflow
twoFA.CreateProof
twoFA.VerifyProof
db.CompleteRegistration
sut, twoFA, db
この関数はSUTファサードと2つのフェイクオブジェクトという3つの値を返します。
SUTファサードはProofId option -> Registration -> Async<CompleteRegistrationResult>
型の部分適用関数です。つまり、非純粋なアクションの実行詳細を抽象化したものです。残りの2つの入力引数であるProofId option
とRegistration
は実行時の値と考えられます。リファクタリングに関係なく、結果の関数はこれらの引数を受け取って望ましい結果を生成できる必要があります。
証明IDがない場合の仕様化
completeRegistrationWorkflow
の循環的複雑度は3のようなので、3つの仕様化テストが必要です。どの順序でも追加できますが、この場合はSUTのブランチが配置されている順序に従うのが自然です。
このテストケースでは、証明IDがない場合の動作を検証します。
[<Theory>]
[<InlineData 123>]
[<InlineData 432>]
let ``Missing proof ID`` mobile = async {
let sut, twoFA, db = createFixture ()
let r = { Mobile = Mobile mobile }
let! actual = sut None r
let! expectedProofId = twoFA.CreateProof r.Mobile
let expected = ProofRequired expectedProofId
expected =! actual
test <@ Seq.isEmpty db @> }
この記事のすべてのテストはxUnit.net 2.4.0とUnquote 5.0.0を使用しています。
このテストではNone
のProofId
と任意のRegistration r
を使ってSUTファサードを呼び出します。FsCheckやHedgehogのようなプロパティベースのテストフレームワークを使っていれば、Registration
値自体を任意のテスト引数にできたでしょうが、この状況では過剰だと判断しました。
expectedProofId
を特定するために、テストはFake2FA
クラスの動作に依存しています。CreateProof
メソッドはべき等なので、同じ番号で何度呼び出しても同じ証明が返されます。このテストケースではSUTが既にそのメソッドを呼び出していると想定しているため、テストから再度呼び出せばSUTが受け取ったのと同じ値が返されるはずです。次に、テストはその証明IDをProofRequired
ケースでラップし、Unquoteの=!
(等しくなければならない)演算子を使ってexpected
がactual
と等しいことを検証します。
最後に、テストは登録データベースが空のままであることも確認します。
これは仕様化テストなので、既に合格していますが、信頼性はまだ確認できていません。トートロジー(恒真命題)的なアサーションを書いていないことをどう確認すればよいでしょうか?
仕様化テストを作成するときは、SUTを変更してテストが適切な理由で失敗することを確認するのが常です。最初のアサーションを失敗させるために、SUTのNone
ブランチに次の変更を加えることができます。
match proofId with
| None ->
//let! proofId = createProof registration.Mobile
let proofId = ProofId (Guid.NewGuid ())
return ProofRequired proofId
これにより予想通り、expected =! actual
アサーションが失敗します。
同様に、次の変更を加えると二番目のアサーションを失敗させることができます。
match proofId with
| None ->
do! completeRegistration registration
let! proofId = createProof registration.Mobile
return ProofRequired proofId
completeRegistration
ステートメントを追加すると、予想通りテストの<@ Seq.isEmpty db @>
アサーションが失敗します。
これでこのテストの信頼性が確認できました。
有効な証明IDの仕様化
次に、すべてが正常なケース、つまり証明IDが存在し有効である場合のテストです。この動作は次のテストで仕様化できます。
[<Theory>]
[<InlineData 987>]
[<InlineData 247>]
let ``Valid proof ID`` mobile = async {
let sut, twoFA, db = createFixture ()
let r = { Mobile = Mobile mobile }
let! p = twoFA.CreateProof r.Mobile
twoFA.VerifyMobile r.Mobile
let! actual = sut (Some p) r
RegistrationCompleted =! actual
test <@ Seq.contains r db @> }
このテストではSUT実行前にCreateProof
を使って証明を作成し、テスト専用のVerifyMobile
メソッドを使って携帯電話番号(およびその証明)を有効としてマークします。
ここでも、戻り値actual
に対するアサーションと、登録データベースdb
に登録r
が含まれていることを検証するアサーションの2つがあります。
前述のように、仕様化テストは失敗を確認するまで信頼できないため、まずSUTのisValid
ブランチを次のように編集します。
if isValid then
do! completeRegistration registration
//return RegistrationCompleted
return ProofRequired proofId
これにより予想通り、RegistrationCompleted =! actual
アサーションが失敗します。
次に、以下の変更を加えます。
if isValid then
//do! completeRegistration registration
return RegistrationCompleted
これで予想通り、テストの<@ Seq.contains r db @>
アサーションが失敗します。
このテストも信頼できることが確認できました。
無効な証明IDの仕様化
最後のテストケースは、証明IDは存在するが無効な場合です。
[<Theory>]
[<InlineData 327>]
[<InlineData 666>]
let ``Invalid proof ID`` mobile = async {
let sut, twoFA, db = createFixture ()
let r = { Mobile = Mobile mobile }
let! p = twoFA.CreateProof r.Mobile
let! actual = sut (Some p) r
let! expectedProofId = twoFA.CreateProof r.Mobile
let expected = ProofRequired expectedProofId
expected =! actual
test <@ Seq.isEmpty db @> }
テストのarrangeフェーズは前のテストケースとほぼ同じです。
唯一の違いは、このテストがtwoFA.VerifyMobile r.Mobile
を呼び出さないことです。これにより、生成された証明ID p
は無効のままになります。
一方、アサーションは証明IDがないケースのアサーションと同一です。つまり、上記でNone
ブランチに対して行ったのと同じ編集をelse
ブランチに対して行うと、アサーションは当然失敗します。この仕様化テストも信頼できることが確認できました。
イータ展開
SUTファサードの型は変更せずに、その構成方法を変更したいと考えています。目標は不純/純粋/不純のサンドイッチパターンです。まず不純な処理を行い、その取得データで純粋関数を呼び出し、最後に純粋関数の出力を使って不純な処理を行います。
この構成ではSUTファサードへの入力値を操作する必要があります。これを簡単にするため、SUTに対してイータ変換を行います。
let createFixture () =
let twoFA = Fake2FA ()
let db = FakeRegistrationDB ()
let sut pid r =
completeRegistrationWorkflow
twoFA.CreateProof
twoFA.VerifyProof
db.CompleteRegistration
pid
r
sut, twoFA, db
これはSUTの動作や構成方法を変更するものではなく、pid
引数とr
引数を明示的に表示するだけです。
証明検証の移動
completeRegistrationWorkflow
の現在の実装を見ると、不純なアクションが意思決定コードと交互に配置されています。これらを分離するにはどうすればよいでしょうか?
最初の機会はverifyProof
をSome
ケースでのみ呼び出している点です。Some
ケースでのみメソッド呼び出しが必要でNone
では不要な場合は、Option.map
の使用が推奨されます。
Option.map (twoFA.VerifyProof r.Mobile) pid
を不純/純粋/不純のサンドイッチの最初の不純アクションとして実行できるはずです。そうすれば、その純粋関数の出力を引数としてcompleteRegistrationWorkflow
に渡せるようになります。
let completeRegistrationWorkflow
(createProof: Mobile -> Async<ProofId>)
(completeRegistration: Registration -> Async<unit>)
(proof: bool option)
(registration: Registration)
: Async<CompleteRegistrationResult> =
async {
match proof with
| None ->
let! proofId = createProof registration.Mobile
return ProofRequired proofId
| Some isValid ->
if isValid then
do! completeRegistration registration
return RegistrationCompleted
else
let! proofId = createProof registration.Mobile
return ProofRequired proofId
}
proof
引数がbool option
に変更されたことで、verifyProof
を呼び出す必要がなくなり、削除できました。
ただし問題があります。Option.map (twoFA.VerifyProof r.Mobile) pid
の結果はOption<Async<bool>>
ですが、必要なのはOption<bool>
です。
SUTファサードを非同期ワークフローで構成してlet!
バインディングを使うこともできますが、それでは問題は解決しません。let!
バインディングは外側のコンテナがAsync
の場合にのみ機能します。ここでは外側のコンテナはOption
です。Async<Option<bool>>
を取得するにはコンテナを反転させる必要があります。これによりlet!
バインディングが使えるようになります。
let sut pid r = async {
let! p =
match Option.map (twoFA.VerifyProof r.Mobile) pid with
| Some b -> async {
let! b' = b
return Some b' }
| None -> async { return None }
return! completeRegistrationWorkflow
twoFA.CreateProof
db.CompleteRegistration
p
r
}
Option.map (twoFA.VerifyProof r.Mobile) pid
にパターンマッチングを適用することで、2つの代替非同期ワークフローのいずれかを返すことができます。
let!
バインディングによりp
はbool option
になり、completeRegistrationWorkflow
に渡せるようになります。
トラバーサル
ここで「複雑な動作をcompleteRegistrationWorkflow
から移動しただけではないか」と思われるかもしれません。暗黙の前提として、completeRegistrationWorkflow
はコンポジションルートで構成されるトップレベルの動作だということです。このリファクタリング演習ではcreateFixture
関数がその役割を果たしています。
通常、コンポジションルートは循環的複雑度が1のため、テストカバレッジ対象外の質素なオブジェクトと見なされます。しかしこれはもはや当てはまりません。
ただし、Option<Async<bool>>
からAsync<Option<bool>>
への変換は広く知られた操作です。Haskellではこれはトラバーサルと呼ばれる一般的な操作です。
// ('a -> Async<'b>) -> 'a option -> Async<'b option>
let traverse f = function
| Some x -> async {
let! x' = f x
return Some x' }
| None -> async { return None }
この関数は必要に応じてAsyncOption
という汎用モジュールに配置し、単体テストでカバーすることもできます。このモジュールを別のライブラリに配置することも可能で、登録フローの詳細からは完全に分離されています。
これによりcompleteRegistrationWorkflow
は変更せずに、構成方法を変えることができます。
let sut pid r = async {
let! p = AsyncOption.traverse (twoFA.VerifyProof r.Mobile) pid
return! completeRegistrationWorkflow
twoFA.CreateProof
db.CompleteRegistration
p
r
}
これで元の状態に戻りました。つまり、1つの不純なアクションで生成された値を別の関数に渡せるようになりました。コードに明示的な分岐はなく、循環的複雑度は1のままです。
戻り値の型の変更
最初のリファクタリングで3つの不純な依存関係のうち1つを処理しました。次にcreateProof
を削除します。これはより難しいように見えます。Some
ケースだけで必要というわけではないため、map
やtraverse
は使えません。ただし、どちらの場合もcreateProof
の呼び出し結果は同じように処理されます。
関数型プログラミングでは、もう一つの一般的な手法として決定と効果を分離することがあります。関数が到達した決定を示す値を返し、不純/純粋/不純のサンドイッチの第二段階の不純なアクションがその決定に基づいて動作するようにします。
この場合、決定をMobile option
としてモデル化できます。意図をより明確に伝えるために専用の型を検討することもできますが、各リファクタリングステップを小さく保つほうが良いでしょう。
let completeRegistrationWorkflow
(completeRegistration: Registration -> Async<unit>)
(proof: bool option)
(registration: Registration)
: Async<Mobile option> =
async {
match proof with
| None -> return Some registration.Mobile
| Some isValid ->
if isValid then
do! completeRegistration registration
return None
else
return Some registration.Mobile
}
createProof
依存関係が不要になったため、completeRegistrationWorkflow
の引数リストから削除しました。
構成は次のようになります。
let createFixture () =
let twoFA = Fake2FA ()
let db = FakeRegistrationDB ()
let sut pid r = async {
let! p = AsyncOption.traverse (twoFA.VerifyProof r.Mobile) pid
let! res = completeRegistrationWorkflow db.CompleteRegistration p r
let! pidr = AsyncOption.traverse twoFA.CreateProof res
return pidr
|> Option.map ProofRequired
|> Option.defaultValue RegistrationCompleted }
let!
バインディングのおかげで、結果のres
はMobile option
になります。ここでtwoFA.CreateProof
メソッドをres
上でトラバースできます。これによりProofId option
型のpidr
であるAsync<Option<ProofId>>
が生成されます。
Option.map
を使って、ProofId
値をProofRequired
ケースでラップできます(存在する場合)。この最終パイプラインステップではCompleteRegistrationResult option
が生成されます。
最後にOption.defaultValue
を使って、オプションをCompleteRegistrationResult
に畳み込めます。デフォルト値はRegistrationCompleted
で、オプションがNone
の場合に使用されるケース値です。
ここでも構成の循環的複雑度は1で、sutの型はProofId option -> Registration -> Async<CompleteRegistrationResult>
のままです。これは真のリファクタリングです。SUTの型は同じで、動作も変わっていません。テストを編集する必要もなく、すべて合格したままです。
戻り値の型をResultに変更する
completeRegistrationWorkflow
の意図について考えてみましょう。この操作の目的は登録ワークフローを完了することです。名前が示す通りです。したがって、正常なパスは証明IDが有効で関数がcompleteRegistration
を呼び出せる場合です。
通常、オプションを返す関数を呼び出す場合、暗黙の契約としてSome
ケースが正常なパスを表します。しかし現状ではそうなっていません。Some
ケースがエラーパスの情報を伝えており、これは慣用的ではありません。
Result
型の戻り値を使うほうが適切でしょう。
let completeRegistrationWorkflow
(completeRegistration: Registration -> Async<unit>)
(proof: bool option)
(registration: Registration)
: Async<Result<unit, Mobile>> =
async {
match proof with
| None -> return Error registration.Mobile
| Some isValid ->
if isValid then
do! completeRegistration registration
return Ok ()
else
return Error registration.Mobile
}
この変更自体は小さいものですが、構成にいくつかの変更が必要です。戻り値の型がオプションの場合にOption.traverse
関数を追加したのと同様に、Result
にも同様の機能が必要です。Result
はEither
とも呼ばれ、双関手であるだけでなく両方の軸をトラバースすることもできます。Haskellではこれをbitraversable
ファンクターと呼びます。
// ('a -> Async<'b>) -> ('c -> Async<'d>) -> Result<'a,'c> -> Async<Result<'b,'d>>
let traverseBoth f g = function
| Ok x -> async {
let! x' = f x
return Ok x' }
| Error e -> async {
let! e' = g e
return Error e' }
ここでは関数をtraverseBoth
、モジュールをAsyncResult
と名付けました。
Result
用のOption.defaultValue
に相当するものも必要です。Result
の両方の次元を同じ型に変換するものです。これはEither
カタモルフィズムなので、例えばcata
と呼ばれる汎用関数を導入できます。
// ('a -> 'b) -> ('c -> 'b) -> Result<'a,'c> -> 'b
let cata f g = function
| Ok x -> f x
| Error e -> g e
これもResult
という汎用モジュール内の汎用ライブラリに配置でき、必要に応じて単体テストでカバーすることもできます。
これら2つの汎用関数を使ってワークフローを構成できます。
let createFixture () =
let twoFA = Fake2FA ()
let db = FakeRegistrationDB ()
let sut pid r = async {
let! p = AsyncOption.traverse (twoFA.VerifyProof r.Mobile) pid
let! res = completeRegistrationWorkflow db.CompleteRegistration p r
let! pidr =
AsyncResult.traverseBoth
(fun () -> async { return () })
twoFA.CreateProof
res
return pidr
|> Result.cata (fun () -> RegistrationCompleted) ProofRequired }
sut, twoFA, db
これは前の反復よりも複雑に見えるかもしれませんが、以降改善されていきます。最初の2行は前と同じですが、res
がResult<unit, Mobile>
になりました。
まだtwoFA.CreateProof
に「エラーパス」を処理させる必要がありますが、今回は「正常パス」も処理する必要があります。
Okの場合はunit値(()
)がありますが、traverseBoth
はf
関数とg
関数がAsync
値を返すことを期待します。より特殊なtraverseError
関数で修正することもできましたが、すぐに先に進むのでそれほど価値はありません。
Haskellでは純粋関数だけで値を簡単に「昇格」できますが、F#では同じ効果を得るには(fun () -> async { return () })
という面倒なコードが必要です。
トラバーサルはpidr
(Proof ID Result)を生成します。これはResult<unit, ProofId>
型の値です。
最後にResult.cata
を使って、Ok
次元とError
次元の両方を返すべき単一のCompleteRegistrationResult
に変換します。
最後の依存関係の削除
completeRegistration
関数という依存関係がまだ1つ残っていますが、削除は簡単になりました。completeRegistrationWorkflow
内から依存関数を呼び出す代わりに、前と同じ手法を使います。つまり決定と効果を分離します。
関数が下した決定に関する情報を返すようにします。上記のコードではOk
次元は現在unit
のみを返しているため空です。この「チャネル」を使って、登録を完了することを決定したことを伝えられます。
let completeRegistrationWorkflow
(proof: bool option)
(registration: Registration)
: Async<Result<Registration, Mobile>> =
async {
match proof with
| None -> return Error registration.Mobile
| Some isValid ->
if isValid then
return Ok registration
else
return Error registration.Mobile
}
これは小さな変更です。isValid
がtrue
の場合、関数はcompleteRegistration
を呼び出さなくなりました。代わりにOk registration
を返します。これにより戻り値の型がAsync<Result<Registration, Mobile>>
になり、completeRegistration
関数引数を削除できるようになりました。
このバージョンを構成するには、新たな汎用関数が1つ必要です。汎用関数の氾濫にうんざりするかもしれませんが、これはF#言語の設計思想の表れです。F#の基本ライブラリには汎用関数がほとんど含まれていません。対照的に、GHCのbaseライブラリにはこれらの関数がすべて組み込まれています。
新しい関数はResult.cata
に似ていますが、Async<Result<_>>
を対象としています。
// ('a -> 'b) -> ('c -> 'b) -> Async<Result<'a,'c>> -> Async<'b>
let cata f g r = async {
let! r' = r
return Result.cata f g r' }
この関数は概念的にResult.cata
と同じことを行うので、同じcata
という名前を使い、AsyncResult
モジュールに配置しました(これが厳密に正しいかは不明です。Async
のカタモルフィズムについて深く考えていないためです。より良い名前の提案があればぜひ教えてください。結局のところ、cata
はF#の慣用的な名前ではありません)。
AsyncResult.cata
を使うとシステムを構成できます。
let sut pid r = async {
let! p = AsyncOption.traverse (twoFA.VerifyProof r.Mobile) pid
let! res = completeRegistrationWorkflow p r
return!
res
|> AsyncResult.traverseBoth db.CompleteRegistration twoFA.CreateProof
|> AsyncResult.cata (fun () -> RegistrationCompleted) ProofRequired
}
completeRegistrationWorkflow
の呼び出しがさらに簡単になり、扱いにくい名前のpidr
値も避けられるようになりました。let!
バインディングのおかげで、res
の型はResult<Registration, Mobile>
です。
両方の不純なアクション(db.CompleteRegistration
とtwoFA.CreateProof
)で結果をトラバースできることに注意してください。このステップではAsync<Result<unit, ProofId>>
が生成されますが、すぐにAsyncResult.cata
にパイプされます。これによりResult
の2つの代替次元が単一のAsync<CompleteRegistrationResult>
値に縮小されます。
completeRegistrationWorkflow
関数はさらなる単純化の余地があります。
純粋な登録ワークフロー
すべての依存関係を削除したので、ドメインロジックを非同期にする必要はなくなりました。completeRegistrationWorkflow
では非同期処理が発生しないため、単純化できます。
let completeRegistrationWorkflow
(proof: bool option)
(registration: Registration)
: Result<Registration, Mobile> =
match proof with
| None -> Error registration.Mobile
| Some isValid ->
if isValid then Ok registration
else Error registration.Mobile
return
キーワードを含むasync
コンピュテーション式はなくなりました。これで純粋関数になりました。
構成を再調整する必要がありますが、わずかな変更です。
let sut pid r = async {
let! p = AsyncOption.traverse (twoFA.VerifyProof r.Mobile) pid
return!
completeRegistrationWorkflow p r
|> AsyncResult.traverseBoth db.CompleteRegistration twoFA.CreateProof
|> AsyncResult.cata (fun () -> RegistrationCompleted) ProofRequired
}
completeRegistrationWorkflow
の呼び出し結果はAsync
値ではなくなったため、let!
バインディングを使う必要はありません。代わりに、それを呼び出して出力を直接AsyncResult.traverseBoth
にパイプできます。
DRY
completeRegistrationWorkflow
をさらに簡単にできないか考えてみましょう。
この時点で、2つのブランチに重複するコードがあることは明らかです。DRY原則(Don’t Repeat Yourself)を適用して単純化できます。
let completeRegistrationWorkflow
(proof: bool option)
(registration: Registration)
: Result<Registration, Mobile> =
match proof with
| Some true -> Ok registration
| _ -> Error registration.Mobile
この種の単純な関数に対する型アノテーションは不要なので、削除してみましょう。
let completeRegistrationWorkflow proof registration =
match proof with
| Some true -> Ok registration
| _ -> Error registration.Mobile
これら2つのステップは純粋なリファクタリングです。completeRegistrationWorkflow
を実装するコードを再編成するだけなので、構成は変更されません。
本質的な複雑さ
ここまで読んで「これはズルだ!すべての複雑さを取り除いてしまった!何も残っていない!」と感じるかもしれません。多くの動作をテストできないコードに移してしまったと思うかもしれませんが、そうではありません。
念のため説明しておくと、AsyncOption.traverse
やAsyncResult.cata
のような関数は分岐の動作を含んでいますが、テスト可能です。実際、これらは純粋関数なので本質的にテスト可能なのです。
純粋関数とその不純な依存関係の合成は(ユニット)テストできないかもしれませんが、それはコンポジションルートで構成された依存関係の注入ベースのオブジェクトグラフにも当てはまります。
関数合成は一見簡単ではないように思えるかもしれませんが、ある程度まで型システムが支援してくれます。コンポジションがコンパイルされれば、不純/純粋/不純のサンドイッチを正しく構成できている可能性が高いのです。
私がすべての複雑さを取り除いたわけではありません。まだ少し残っています。この関数の循環的複雑度は現在2です。元の関数を見ると、重複が最初から存在していたことがわかります。偶発的な複雑さをすべて取り除くと、本質的な複雑さが明らかになるのです。関数型プログラミングの原則を適用するとこのようなことが頻繁に起こるため、私は関数型プログラミングを特効薬だと考えています。
パイプライン構成
これでほぼ完了です。問題は非常に単純になり、不純/純粋/不純のサンドイッチパターンを実現できました。
ただし、コードはまだ改善の余地があります。
現在の構成を見ると、変数p
の名前が最適ではないと感じるかもしれません。実際、私もこの変数の命名に苦労しました。場合によっては、変数名が邪魔になり、関数パイプラインを使って変数名を省略した方がコードが明確になることがあります。
それは常に試す価値があるので、やってみましょう。結果的には改善されなかったとしても、その過程自体が説明になるでしょう。
名前付き変数を削除したい場合、その変数を生成した関数の出力を次の関数に直接パイプすることで、多くの場合削除できます。ただし、これには関数引数が最も右側にあることが必要です。現状ではそうなっていません。registration
が最も右側にあり、proof
はその左側にあります。
引数をこの順序にする特別な理由はないので、引数を反転させてみましょう。
let completeRegistrationWorkflow registration proof =
match proof with
| Some true -> Ok registration
| _ -> Error registration.Mobile
これにより、構成全体を単一のパイプラインとして記述できます。
let sut pid r = async {
return!
AsyncOption.traverse (twoFA.VerifyProof r.Mobile) pid
|> Async.map (completeRegistrationWorkflow r)
|> Async.bind (
AsyncResult.traverseBoth db.CompleteRegistration twoFA.CreateProof
>> AsyncResult.cata (fun () -> RegistrationCompleted) ProofRequired)
}
ただし、これには2つの新しい汎用関数(Async.map
とAsync.bind
)が必要です。
// ('a -> 'b) -> Async<'a> -> Async<'b>
let map f x = async {
let! x' = x
return f x' }
// ('a -> Async<'b>) -> Async<'a> -> Async<'b>
let bind f x = async {
let! x' = x
return! f x' }
私の考えでは、これらの関数はF#のAsync
モジュールに含まれているべきですが、実際には含まれていません。理由は不明ですが、ご覧の通り追加は簡単です。
この変更により変数p
は削除されますが、構成全体が理解しやすくなるとは言い切れません。ただし、関数引数を入れ替えたことで別の単純化が可能になります。
イータ簡約
proof
がcompleteRegistrationWorkflow
の最後の関数引数になったので、イータ簡約を実行できます。
let completeRegistrationWorkflow registration = function
| Some true -> Ok registration
| _ -> Error registration.Mobile
ポイントフリースタイル(変数名を明示的に使わない書き方)が万人に好まれるわけではありませんが、私は好きです。(個人の感想です。)
サンドイッチ
completeRegistrationWorkflow
をポイントフリースタイルとポイントスタイルのどちらで記述するにしろ、構成を改善すべきだと思います。不純/純粋/不純のサンドイッチであることを明示的に示す必要があります。これにより変数を再導入することになりますが、思い切ってより良い名前を考案しましょう。
let sut pid r = async {
let! validityOfProof =
AsyncOption.traverse (twoFA.VerifyProof r.Mobile) pid
let decision = completeRegistrationWorkflow r validityOfProof
return!
decision
|> AsyncResult.traverseBoth db.CompleteRegistration twoFA.CreateProof
|> AsyncResult.cata (fun () -> RegistrationCompleted) ProofRequired
}
p
の代わりに、最初の値をvalidityOfProof
と呼ぶことにしました。これはサンドイッチの最初にある不純なアクション(パンの上部)の結果です。
validityOfProof
は不純なアクションの結果ですが、値自体は純粋で、completeRegistrationWorkflow
への入力として使用できます。これがサンドイッチの純粋な部分(具)です。ワークフローはその入力に基づいて決定を下し、その決定に基づいて呼び出し元がアクションを実行するため、出力をdecision
と名付けました。
completeRegistrationWorkflow
は純粋な関数なので、decision
がasync
ワークフロー内でlet
バインディング(let!
ではなく)でバインドされていることに注意してください。これはcompleteRegistrationWorkflow
がAsync
値を返さないためです。
第二の不純なアクション(パンの下部)は、前述の通りAsyncResult.traverseBoth
とAsyncResult.cata
のパイプラインを通じてdecision
に対して機能します。
このような構成にすると、不純/純粋/不純のサンドイッチパターンがより明確になります。これが私の最終的な編集です。今のところ、この構成に満足しています。
結論
コードを常に不純/純粋/不純のサンドイッチにリファクタリングできるとは主張しません。実際、そのようなアーキテクチャが不可能と思われるソフトウェアのカテゴリも容易に想像できます。
それでも、Webサービスやメッセージベースのアプリケーションの領域においては、サンドイッチパターンが不可能だった事例を思い出せないのは興味深いことです。もちろん、不可能なケースもあるはずです。それが私が例を求めた理由です。この記事はそのような例に対する回答でした。結果として、関数型アーキテクチャで動作を構成するための有用な手法をいくつか議論できたので有益だったと思います。ただし、反例にはなりませんでした。
読者の中には「それは印象的だが、実際にこのようなコードを実運用するソフトウェアとして書くのか?」と疑問に思う方もいるでしょう。
もし私に決定権があるなら、答えはイエスです。コードを純粋に保つことができれば、ユニットテストが容易になり、テストによる悪影響もありません。関数はオブジェクトでは難しい方法で合成できるため、関数型プログラミングには多くの利点があります。私なら、可能な場合は積極的に活用します。
いつものように、コンテキストが重要です。チームメンバーがこのスタイルのプログラミングを受け入れる環境にいたこともあれば、他のメンバーが理解できない環境もありました。後者の場合は、チームメンバーを疎外するのではなく、彼らが理解できるようにアプローチを調整します。
この記事の意図は何が可能かを示すことであり、何をすべきかを指示することではありません。それを決めるのはあなた自身です。
この記事は、2019年のF# Advent Calendar in Englishの12月2日のエントリです。