The best days are ahead of us.

条件付きサンドイッチの例

2025-04-09 00:00:25

原文: A conditional sandwich example by Mark Seemann

F#によるワークフローをImpureimサンドイッチパターンへリファクタリングする例

Impureimサンドイッチアーキテクチャ(別名 関数型コア、命令型シェル)に対する最も一般的な反応は懐疑的なものです。このコード構成パターンが、任意の複雑さを持つシステムにどのように適用できるのか、という疑問が生じるのは当然です。

端的に言えば、このパターンはすべての場合に適用できるわけではありません。十分に複雑なシステムでは、1. 不純なクエリでデータを収集し、2. 純粋な関数を呼び出し、3. 結果を不純なアクションで適用する、という単純な構成は難しくなります。問題は:Impureimサンドイッチを諦めなければならないほどの複雑さとは、どの程度なのでしょうか?

左側にサンドイッチパターン、右側にフリーモナド、その間に移行ゾーンがある軸のイメージ

おそらく、サンドイッチパターンがまだ適用可能だが、それが本当に有益かどうか疑わしくなる曖昧な移行ゾーンがあるでしょう。私の経験では、この移行点は多くの人が想定するよりもずっと右側(複雑さの許容範囲が広い側)にあるようです。

Impureimサンドイッチを適用できない場合、関数型プログラミングではフリーモナドを採用するかもしれませんし、オブジェクト指向プログラミングでは依存関係の注入を使うことになるでしょう。言語やパラダイムによっては、他の選択肢もあるでしょう。

私の経験はWeb系システムが中心ですが、このコンテキストでは驚くほど多くの問題がImpureimサンドイッチアーキテクチャに適合するように再構成できることがわかりました。実際、ほとんどの問題はこのパターンで対処できると考えています。

しかし、私は常に良い例を探しています。「依存関係の排除」という記事へのコメントで書いたように:

「不純/純粋/不純のサンドイッチが適用できない、シンプルながらも具体的な例があれば歓迎します」

残念ながら、そのような例は非常に稀です。実際の本番コードは無数の例の宝庫のように思えるかもしれませんが、本番コードには本質を分かりにくくする関係のない詳細が多く含まれています。さらに、本番コードは多くの場合機密情報を含んでいるため、共有できません。

2019年にChrister van der Meerenが親切にもリファクタリング可能な問題例を提供してくれましたが、それ以来、新たな例はほとんどありませんでした。今日まで。

最近、一見「関数型コア、命令型シェル」アーキテクチャには適さないように思える決定フローの良い例に遭遇しました。以下は実際の本番コードで、Criiptoの許可を得て再現したものです。

存在しないユーザーの作成

以前にも触れたように、私はCriiptoがFusebit APIと統合するのを手伝っています。このAPIにはユーザーモデルがあり、Fusebitサービスにユーザーを作成することができます。ユーザーを作成すると、クライアントはそのユーザーとしてログインし、利用可能なリソースにアクセスできるようになります。これらすべてを統制する基盤となるセキュリティモデルがあります。

Criiptoのユーザー全員がFusebit APIでプロビジョニングされているわけではありません。Fusebitシステムを使用する必要がある場合にのみ、適宜プロビジョニングしています。また、ユーザーがすでに存在する場合は、改めて作成する必要はありません。

しかし、さらに複雑な要件があります。ユーザーには関連する発行者(issuer)が必要です。これは、まだ存在しない場合にプロビジョニングしなければならないもう一つのFusebitリソースです。

必要なロジックは、フローチャートで視覚化するとわかりやすいかもしれません。

Fusebitユーザーをプロビジョニングする方法を図解したフローチャート

ユーザーには発行者が必要ですが、適切な発行者がすでに存在している可能性があります。存在しない場合は、ユーザーを作成する前に発行者を作成する必要があります。

一見すると、これはImpureimサンドイッチアーキテクチャに適合しないワークフローのように思えます。結局のところ、ユーザーが存在しないことが確認された場合にのみ、発行者の存在を確認すべきです。最初と2番目の不純なクエリの間に決定ポイントがあります。

この問題を解決し、Impureimサンドイッチとして機能を実装することは可能でしょうか?

投機的プリフェッチ

「関数型コア、命令型シェル」アーキテクチャを適用する方法を探る際には、一歩引いて全体像を見ることが有益です。言い換えれば、手続き的思考ではなく、宣言的思考が必要です。上記のようなフローチャートは本質的に手続き的であり、他の可能性を見逃す原因になりかねません。

私が関数型プログラミングを好む理由の一つは、より宣言的に考えることを促すからです。これにより、従来の思考では気づかなかったような優れた抽象化を見つけられることがあります。

上記のフローチャートは、私がよく耳にする最も一般的な反論を表しています:最初のクエリの結果に基づいて二次クエリの実行を決定する必要がある場合、Impureimサンドイッチは機能しないというものです。今回の問題もまさにそうです。ユーザー存在クエリの結果によって、発行者についての問い合わせが必要かどうかが決まります。

ユーザーには発行者が必要なので、ユーザーが存在する場合、発行者も存在するという前提があります。

しかし、最初からFusebit APIに発行者について問い合わせることはそれほど問題でしょうか?

このような提案に対して違和感を覚えるかもしれません。結局のところ、無駄に思えますね。結果が必要ない場合にもWebサービスに問い合わせるのはなぜでしょうか?パフォーマンスへの影響は?

これが無駄かどうかは、何を基準に測定するかによります。ネットワーク上で送信されるデータ量を測定するなら、確かに増加するでしょう。

しかし、考えているほど悪影響はないかもしれません。実行しようとしているHTTP GETリクエストの結果はキャッシュ可能かもしれませんし、結果がすでにプロキシサーバーのメモリに待機している可能性もあります。

ただし、FusebitのHTTP APIのユーザーリソースも発行者リソースも、キャッシュヘッダーを持っていないため、この点は今回のケースでは当てはまりません。それでも考慮に値する視点なので、ここで触れておきました。

もう一つの一般的なパフォーマンス上の懸念は、このような冗長な可能性のあるトラフィックがシステム全体のパフォーマンスを低下させるというものです。おそらくその可能性はあります。いつものように、それが本当に問題になるかどうかは、測定してみるべきです。

ユーザーの存在確認と発行者の存在確認は互いに独立しています。つまり、2つのクエリを並行して実行できます。システムの負荷状況によっては、1つのHTTPリクエストと2つの同時リクエストの違いは無視できるほど小さいかもしれません(ただし、システムがすでに容量限界近くで動作している場合、この戦略が全体的なパフォーマンスに影響する可能性はあります。しかし多くの場合、実際には問題にはなりません)。

第三の考慮点は、システムを通る処理パスの統計的分布です。上記のフローチャートを見ると、循環的複雑度3 です。つまり、3つの異なる処理パスが存在します。

しかし、95%のケースでユーザーが存在 しない ことが判明するなら、いずれにせよ2番目のクエリ(発行者用)を実行する必要があるため、プリフェッチと条件付きクエリの違いは最小限です。

これを「ズルい」と考える人もいるかもしれませんが、Impureimサンドイッチアーキテクチャを目指すなら、これらはすべて検討すべき重要な観点です。純粋な関数に渡す前にすべてのデータをフェッチできることが多いものです。場合によっては「無駄な」リソースを使うことになるかもしれませんが、それでも価値があることが多いのです。

もう一つ考慮すべき「無駄」があります。それは、必要以上に複雑で保守が難しいコードを書くことによる開発者の時間の浪費です。最近「ユニットテスト用の実際の依存関係を優先する」という記事で述べたように、関数型プログラミングを活用すればするほど、テストの保守コストは低くなります。

このシナリオがサーバー間通信であることを忘れないでください。少しの余分な帯域幅のコストと、開発者の無駄になる時間を比較すると、どちらが高くつくでしょうか?

ハードウェアやネットワークインフラの数ドルのコストでコードを大幅に簡素化できるなら、それは往々にして良いトレードオフです。

参照整合性

上記のフローチャートには、実は成り立たない微妙な前提が含まれています。その前提とは、システム内のすべてのユーザーが同じ方法で作成され、すべてのユーザーが発行者に関連付けられているというものです。したがって、この前提によれば、ユーザーが存在する場合、発行者も存在するはずです。

しかし、これは誤った前提であることがわかりました。Fusebit HTTP APIは参照整合性を強制していません。存在しない発行者IDを指定してユーザーを作成することも可能です。ユーザー作成時には発行者ID(文字列)のみを提供しますが、APIはその IDが実際に存在するかどうかをチェックしないのです。

したがって、ユーザーが存在するからといって、関連する発行者が存在するとは限りません。確実に知るには、実際に確認する必要があります。

これは結局、2つのクエリを実行しなければならないことを意味します。前のセクションで懸念していた点は実は無関係だったのです。フローチャートそのものが不正確でした。

実際には、2つの独立した、しかも並列化可能なプロセスがあるのです:

Fusebitユーザープロビジョニングの実際の並列性を示すフローチャート

Impureimサンドイッチパターンに要件を適合させる際に、常にこれほどうまくいくわけではありませんが、これは非常に有望な状況に見えてきました。これらの2つのプロセスはそれぞれほぼ自明な構造を持っています。

べき等性

実際に必要なのは、べき等性を持つ作成アクションです。「RESTful Web Services Cookbook」が説明しているように、適切に設計されたAPIでは、POST除く すべてのHTTP動詞はべき等であるべきです。残念ながら、ユーザーと発行者の作成は(当然)POSTリクエストで行われるため、これらの操作は本質的にべき等ではありません。

関数型プログラミングでは、決定と効果を分離することがよくあります。そのために、次のような判別共用体を作成しました:

type Idempotent<'a> = UpToDate | Update of 'a

この型はoption型と同型ですが、今回の特定の目的のために別の型を導入する価値があると考えました。通常、クエリが UserData option を返す場合、Some のケースはユーザーが存在することを示し、None のケースはユーザーが存在しないことを示すと解釈されます。

ここでは、「中身がある」場合が Update アクションの必要性を示すようにしたかったのです。もし option 型を使っていたら、ユーザーが存在しない場合を Some 値に、ユーザーが存在する場合を None 値にマッピングする必要があったでしょう。これは型の一般的な慣用法に反するため、他の開発者にとって混乱を招く恐れがあります。

そのため、独自の型を作成することにしました。

UpToDate のケースは、値が存在し最新であることを示します。一方、Update のケースは、値(型 'a の)を更新すべきであることを示す命令的な表現になっています。

確立

この作業全体の目的は、ユーザー(および発行者)が存在することを 確立 することです。ユーザーがすでに存在していれば問題ありませんが、存在しない場合は作成する必要があります。

用語について熟考した結果、「establish(確立する)」という動詞が適切だと感じました。Twitterでは様々な反応がありましたが。

CreateUserIfNotExistsは冗長な名前です 🤢

代わりにEstablishUserはどうでしょう?

“Establish"は「確固たる基盤を設定する」と「事実を確認することで(何かが)真実または確実であることを示す」の両方を意味します。これはより簡潔に同じことを表現しているように思えます 👌

私のTwitterより

コメントを読むだけで、この小さなアイデアがいかに意見を分けるかがわかります。それでも、ブール値と 'a 値を Idempotent<'a> 値に変換する「establish」という関数を定義することにしました。

この作業全体の目的を忘れないでください。Impureimサンドイッチアーキテクチャの主な利点は、サンドイッチの不純な部分からロジックを分離できることです。純粋な関数は本質的にテスト可能なので、決定やアルゴリズムを純粋な関数として定義すればするほど、コードのテスト容易性は向上します。

テスト可能な関数を汎用的にすればさらに良いでしょう。再利用可能な関数は認知的負荷を軽減する可能性があるからです。読み手が抽象化を理解すれば、それはもはや大きな認知的負担にはなりません。

Idempotent<'a> 値を作成・操作する関数は、自動テストでカバーされるべきです。しかし、その動作はかなり自明なので、プロパティベースのテストを書くことにしました。対象のコードベースでは既にFsCheckを使用していたため、それを活用しました:

[<Property(QuietOnSuccess = true)>]
let ``Idempotent.establish returns UpToDate`` (x : int) =
    let actual = Idempotent.establish x true
    UpToDate =! actual
 
[<Property(QuietOnSuccess = true)>]
let ``Idempotent.establish returns Update`` (x : string) =
    let actual = Idempotent.establish x false
    Update x =! actual

これらのプロパティテストでは、Unquoteも使用しています。=! 演算子は「等しいはず」を意味するので、UpToDate =! actual は「UpToDateはactualと等しいはず」と読めます。

これで establish 関数の動作全体を検証できており、実装は次のようになります:

// 'a -> bool -> Idempotent<'a>
let establish x isUpToDate = if isUpToDate then UpToDate else Update x

至ってシンプルです。驚きのないコードは良いことです。

Fold

establish 関数は Idempotent 値を作成する方法を提供します。コンテナから値を取り出す関数も有用でしょう。Idempotent値に対してパターンマッチングを直接行うこともできますが、それを行うコードに決定ロジックが混入してしまいます。

目標は、可能な限り多くの決定ロジックをテストでカバーし、全体的なImpureimサンドイッチを宣言的な構成として残すことです—いわゆる質素なオブジェクトパターンとして。その役割を果たせる再利用可能で、テストされた関数を導入するのが適切です。

Idempotent<'a> に対するケース分析(カタモーフィズムとも呼ばれる)が必要です。Idempotent<'a>optionMaybe とも)と同型なので、そのカタモーフィズムもMaybeカタモーフィズムと同型です。特に驚きはありませんが、自動テストで機能を確認することができます:

[<Property(QuietOnSuccess = true)>]
let ``Idempotent.fold when up-to-date`` (expected : DateTimeOffset) =
    let actual =
        Idempotent.fold (fun _ -> DateTimeOffset.MinValue) expected UpToDate
    expected =! actual
 
[<Property(QuietOnSuccess = true)>]
let ``Idempotent.fold when update required`` (x : TimeSpan) =
    let f (ts : TimeSpan) = ts.TotalHours + float ts.Minutes
    let actual = Update x |> Idempotent.fold f 1.1
    f x =! actual

最も一般的なカタモーフィズムはF#では慣用的に fold と呼ばれるので、私もその名前を採用しました。

最初のプロパティテストは、Idempotent 値が UpToDate の場合、fold は単に「フォールバック値」(ここでは expected)を返し、関数は実行されないことを確認しています。

IdempotentUpdate 値の場合、関数 f はその内部の値 x に対して実行されます。

実装は非常にシンプルです:

// ('a -> 'b) -> 'b -> Idempotent<'a> -> 'b
let fold f onUpToDate = function
    | UpToDate -> onUpToDate
    | Update x -> f x

establishfold はどちらも汎用的な関数です。Fusebitユーザーが存在しない場合に作成するワークフローを構成する前に、もう1つの特殊な関数が必要でした。

発行者の存在確認

前述したように、私はすでにFusebit APIとやりとりするための一連のモジュールを開発していました。そのうちの1つは発行者を読み取る関数でした。この Issuer.get アクションは Task<Result<IssuerData, HttpResponseMessage>> を返します。

Result 値は発行者が存在する場合にのみ Ok になりますが、Error 値がリソースの不在を意味すると断定することはできません。Error は純粋なHTTPエラーを示している可能性もあります。

HttpResponseMessage を調べて Result<IssuerData, HttpResponseMessage>Result<bool, HttpResponseMessage> に変換する関数は、(循環的複雑性が3で)ユニットテストで確認する価値があるほど複雑です。ここではFsCheckプロパティではなく、パラメータ化されたテストを使いました。

最初のテストは、結果が Ok の場合に Ok true に変換されることを確認しています:

[<Theory>]
[<InlineData ("https://example.com", "DN", "https://example.net")>]
[<InlineData ("https://example.org/id", "lga", "https://example.gov/jwks")>]
[<InlineData ("https://example.com/id", null, "https://example.org/.jwks")>]
let ``Issuer exists`` iid dn jwks =
    let issuer =
        {
            Id = Uri iid
            DisplayName = dn |> Option.ofObj
            PKA = JwksEndpoint (Uri jwks)
        }
    let result = Ok issuer
 
    let actual = Fusebit.issuerExists result
 
    Ok true =! actual

これらのテストはすべてAAAパターン(Arrange-Act-Assert)に従っています。この特定のテストは、テストすべきロジックが本当にあるのか疑問に思えるほど自明かもしれません。次のテストがその疑問に答えるでしょう:

[<Fact>]
let ``Issuer doesn't exist`` () =
    use resp = new HttpResponseMessage (HttpStatusCode.NotFound)
    let result = Error resp
 
    let actual = Fusebit.issuerExists result
    
    Ok false =! actual

要求された発行者が存在しないことをどう判断するのでしょうか?単なる Error 結果ではなく、特定の 404 Not Found 結果がそれを示します。この特定の Error 結果が Ok false という形の Ok 結果に変換されることに注目してください。

一方、他のすべての種類の Error 結果は Error 値のままであるべきです:

[<Theory>]
[<InlineData (HttpStatusCode.BadRequest)>]
[<InlineData (HttpStatusCode.Unauthorized)>]
[<InlineData (HttpStatusCode.Forbidden)>]
[<InlineData (HttpStatusCode.InternalServerError)>]
let ``Issuer error`` statusCode =
    use resp = new HttpResponseMessage (statusCode)
    let expected = Error resp
 
    let actual = Fusebit.issuerExists expected
 
    expected =! actual

これらのテストを総合すると、次のような実装が導かれます:

// Result<'a, HttpResponseMessage> -> Result<bool, HttpResponseMessage>
let issuerExists = function
    | Ok _ -> Ok true
    | Error (resp : HttpResponseMessage) ->
        if resp.StatusCode = HttpStatusCode.NotFound
        then Ok false
        else Error resp

ここでも、関数の名前が示唆するよりも汎用的な関数を書くことができました。これは私によくあることです。

このコンテキストでより重要なのは、これが別の純粋関数であるという点です—ユニットテストが非常に簡単だったのもこのためです。

全体の構成

すべての複雑な部分を純粋でテスト可能な関数に抽出できました。残るのはそれらを組み合わせることだけです。

まず、2つのプライベートヘルパー関数を定義します:

// Task<Result<'a, 'b>> -> Task<Result<unit, 'b>>
let ignoreOk x = TaskResult.map (fun _ -> ()) x
 
// ('a -> Task<Result<'b, 'c>>) -> Idempotent<'a> -> Task<Result<unit, 'c>>
let whenMissing f = Idempotent.fold (f >> ignoreOk) (task { return Ok () })

これらは後続の構成をより読みやすくするためだけに存在します。どちらも循環的複雑性は 1 なので、ユニットテストは省略しても問題ないと判断しました。

最終的な構成についても同様です:

let! comp = taskResult {
    let (issuer, identity, user) = gatherData dto
 
    let! issuerExists =
        Issuer.get client issuer.Id |> Task.map Fusebit.issuerExists
    let! userExists =
        User.find client (IdentityCriterion identity)
        |> TaskResult.map (not << List.isEmpty)
 
    do! Idempotent.establish issuer issuerExists
        |> whenMissing (Issuer.create client)
    do! Idempotent.establish user userExists
        |> whenMissing (User.create client) }

comp 構成は、入力 dto からデータを収集することから始まります。このコードスニペットは、ここでは示していない大きなコントローラーアクションの一部です。周囲のメソッドは入力データ転送オブジェクトの変換と、comp から IHttpActionResult への変換を扱うだけなので、現在の例とは関係ありません。

純粋な準備の後、サンドイッチの最初の不純な部分が登場します:Fusebit APIから issuerExistsuserExists 値を取得します。その後、サンドイッチの構造が少し崩れます。スモーブロー(北欧のオープンサンドイッチ)のような感じかもしれません…

より明示的なサンドイッチ構造にするなら、まず Issuer.getUser.find を排他的に呼び出すことから始めることもできました。それがサンドイッチの最初の不純なレイヤーになります。

純粋な中心部として、Fusebit.issuerExistsnot << List.isEmptyIdempotent.establish からなる純粋な関数を構成できたでしょう。

最後に、whenMissing を呼び出す2番目の不純なレイヤーでサンドイッチを完成させることができたはずです。

実際には、コードをそのように構成しなかったことを認めます。Task.mapTaskResult.map による継続として、純粋な関数(Fusebit.issuerExistsnot << List.isEmpty)の一部を初期クエリと混在させました。同様に、Idempotent.establish の結果をすぐに whenMissing にパイプすることにしました。私の動機は、これによってコードがより読みやすくなり、2つのアクションの対称性が強調されると考えたからです。サンドイッチ構造を厳密に維持するよりも、コードの読みやすさを優先しました。

この選択が絶対的に正しいと主張するつもりはありません。おそらく状況によっては正しかったかもしれませんし、そうでなかったかもしれません。ここでは単に私の判断の背景を説明しているだけです。

このコードはさらに改善できるでしょうか?もちろんです。ただ、この時点では実用的で読みやすく、コードレビューに提出するのに十分な品質だと感じました。実際、審査も無事通過しました。

考えられる一つの改善点としては、2つのアクションを並列化して同時に実行することが挙げられます。ただ、それが(おそらく小さな)労力に見合うかどうかは別問題です。

結論

私は常にImpureimサンドイッチアーキテクチャの概念に挑戦する例に関心を持っています。多くの場合、何をすべきかについて全体的な視点で考えることで、ほとんどの問題をこのパターンに適合させることができることがわかります。

最も一般的な反論は、後続の不純なクエリが先行する決定に依存する可能性があるというものです。したがって、前もってすべての不純なデータを収集することはできないと主張されます。

そのような状況が実際に存在することは確かですが、多くの人が考えるよりも稀だと思います。私が経験したほとんどのケースでは、最初はそのような状況に直面したと思っても、少し考え直すことで、「関数型コア、命令型シェル」アーキテクチャに適合するようにコードを再構成できることがわかりました。さらに嬉しいことに、その過程でコードが簡潔になることも多いのです。

これは、今回の例でも起こりました。最初は、Fusebitユーザーが存在することを確認するプロセスが、最初のフローチャートのような条件分岐を含むと考えていました。しかし、再考した結果、シンプルな判別共用体を定義することで問題が簡素化され、コードのテスト容易性が向上することに気づいたのです。

このような発見の過程を共有することは価値があると思いました。


インデックスへ戻る