The best days are ahead of us.

登録フローを関数型アーキテクチャにリファクタリングする

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メソッドはcompleteRegistrationWorkflowcreateProof関数引数と互換性のある関数シグネチャを持ちます。既存の携帯電話番号の証明を探し、同じ証明を何度も再利用できるようにします。携帯電話番号の証明がない場合は新しい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 optionRegistrationは実行時の値と考えられます。リファクタリングに関係なく、結果の関数はこれらの引数を受け取って望ましい結果を生成できる必要があります。

証明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を使用しています。

このテストではNoneProofIdと任意のRegistration rを使ってSUTファサードを呼び出します。FsCheckHedgehogのようなプロパティベースのテストフレームワークを使っていれば、Registration値自体を任意のテスト引数にできたでしょうが、この状況では過剰だと判断しました。

expectedProofIdを特定するために、テストはFake2FAクラスの動作に依存しています。CreateProofメソッドはべき等なので、同じ番号で何度呼び出しても同じ証明が返されます。このテストケースではSUTが既にそのメソッドを呼び出していると想定しているため、テストから再度呼び出せばSUTが受け取ったのと同じ値が返されるはずです。次に、テストはその証明IDをProofRequiredケースでラップし、Unquoteの=!(等しくなければならない)演算子を使ってexpectedactualと等しいことを検証します。

最後に、テストは登録データベースが空のままであることも確認します。

これは仕様化テストなので、既に合格していますが、信頼性はまだ確認できていませんトートロジー(恒真命題)的なアサーションを書いていないことをどう確認すればよいでしょうか?

仕様化テストを作成するときは、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の現在の実装を見ると、不純なアクションが意思決定コードと交互に配置されています。これらを分離するにはどうすればよいでしょうか?

最初の機会はverifyProofSomeケースでのみ呼び出している点です。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!バインディングによりpbool 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ケースだけで必要というわけではないため、maptraverseは使えません。ただし、どちらの場合も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!バインディングのおかげで、結果のresMobile 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にも同様の機能が必要です。ResultEitherとも呼ばれ、双関手であるだけでなく両方の軸をトラバースすることもできます。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行は前と同じですが、resResult<unit, Mobile>になりました。

まだtwoFA.CreateProofに「エラーパス」を処理させる必要がありますが、今回は「正常パス」も処理する必要があります。

Okの場合はunit値(())がありますが、traverseBothf関数と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
    }

これは小さな変更です。isValidtrueの場合、関数はcompleteRegistrationを呼び出さなくなりました。代わりにOk registrationを返します。これにより戻り値の型がAsync<Result<Registration, Mobile>>になり、completeRegistration関数引数を削除できるようになりました。

このバージョンを構成するには、新たな汎用関数が1つ必要です。汎用関数の氾濫にうんざりするかもしれませんが、これはF#言語の設計思想の表れです。F#の基本ライブラリには汎用関数がほとんど含まれていません。対照的に、GHCbaseライブラリにはこれらの関数がすべて組み込まれています。

新しい関数は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.CompleteRegistrationtwoFA.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.traverseAsyncResult.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.mapAsync.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は削除されますが、構成全体が理解しやすくなるとは言い切れません。ただし、関数引数を入れ替えたことで別の単純化が可能になります。

イータ簡約

proofcompleteRegistrationWorkflowの最後の関数引数になったので、イータ簡約を実行できます。

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は純粋な関数なので、decisionasyncワークフロー内でletバインディング(let!ではなく)でバインドされていることに注意してください。これはcompleteRegistrationWorkflowAsync値を返さないためです。

第二の不純なアクション(パンの下部)は、前述の通りAsyncResult.traverseBothAsyncResult.cataのパイプラインを通じてdecisionに対して機能します。

このような構成にすると、不純/純粋/不純のサンドイッチパターンがより明確になります。これが私の最終的な編集です。今のところ、この構成に満足しています。

結論

コードを常に不純/純粋/不純のサンドイッチにリファクタリングできるとは主張しません。実際、そのようなアーキテクチャが不可能と思われるソフトウェアのカテゴリも容易に想像できます

それでも、Webサービスやメッセージベースのアプリケーションの領域においては、サンドイッチパターンが不可能だった事例を思い出せないのは興味深いことです。もちろん、不可能なケースもあるはずです。それが私が例を求めた理由です。この記事はそのような例に対する回答でした。結果として、関数型アーキテクチャで動作を構成するための有用な手法をいくつか議論できたので有益だったと思います。ただし、反例にはなりませんでした。

読者の中には「それは印象的だが、実際にこのようなコードを実運用するソフトウェアとして書くのか?」と疑問に思う方もいるでしょう。

もし私に決定権があるなら、答えはイエスです。コードを純粋に保つことができれば、ユニットテストが容易になり、テストによる悪影響もありません。関数はオブジェクトでは難しい方法で合成できるため、関数型プログラミングには多くの利点があります。私なら、可能な場合は積極的に活用します。

いつものように、コンテキストが重要です。チームメンバーがこのスタイルのプログラミングを受け入れる環境にいたこともあれば、他のメンバーが理解できない環境もありました。後者の場合は、チームメンバーを疎外するのではなく、彼らが理解できるようにアプローチを調整します。

この記事の意図は何が可能かを示すことであり、何をすべきかを指示することではありません。それを決めるのはあなた自身です。

この記事は、2019年のF# Advent Calendar in Englishの12月2日のエントリです。


インデックスへ戻る