🙆♀️
『関数型ドメインモデリング(Domain Modeling Made Functional)』を読んだ 02
01からの続き
型を使ったワークフローの各ステップのモデリング
- 型を使ったワークフローの各ステップのモデリング
- 検証のステップ
-
「注文の検証」サブステップ
substep "ValidateOrder" = input: UnvalidatedOrder output:ValidatedOrder OR ValidationError dependencies: CheckProductCodeExists, CheckAddressExists
- 依存関係をどのようにモデル化するか
-
依存関係のモデル化
type CheckProductCodeExists = ProductCode -> bool type CheckedAddress = CheckedAddress of UnvalidatedAddress type AddressValidationError = AddressValidationError of string type CheckAddressExists = UnvalidatedAddress -> Result<CheckedAddress, AddressValidationError> type ValidateOrder = CheckProductCodeExists // 依存関係 -> CheckAddressExists // 依存関係 -> UnvalidatedOrder // 入力
- 部分適用を容易にするために、依存関係を最初に置いている
-
-
- 価格設定のステップ
-
「価格設定」サブステップ
substep "PriceOrder" = input: ValidatedOrder output: PricedOrder dependencies: GetProductPrice type GetProductPrice = ProductCode -> Price type PriceOrder = GetProductPrice // 依存関係 -> ValidatedOrder // 入力 -> PriceOrder // 出力
-
- 注文確認ステップ
-
「注文確認」サブステップ
type HtmlString = HtmlString of string type OrderAcknowledgment = { EmailAddress : EmailAddress Letter : HtmlString } type CreateOrderAcknowledgmentLetter = PricedOrder -> HtmlString type SendResult = Sent | NotSent type SendOrderAcknowledgment = OrderAcknowledgment -> SendResult // bool値ではなく、より意味のある型を返すようにする type OrderAcknowledgmentSent = { OrderId : OrderId EmailAddress : EmailAddress } type AcknowledgeOrder = CreateOrderAcknowledgmentLetter // 依存関係 -> SendOrderAcknowledgment // 依存関係 -> PricedOrder // 入力 -> OrderAcknowledgmentSent option // 出力
-
- 返すイベントを作る
- 発送用のOrderPlaced(注文が確定した)イベントと、請求書用のBillableOrderPlaced(請求可能な注文が確定した)イベントを作成する必要がある
- OrderPlacedイベントはPricedOrderのエイリアスで、BillableOrderPlacedはPricedOrderのサブセットとなる
-
返すイベントを作る
type OrderPlaced = PricedOrder type BillableOrderPlaced = { OrderId : OrderId BillingAddress : Address AmountToBill : BillingAmount } // ❌BAD: 専用の型を作成すると、イベントの追加などの変更が難しくなる type PlaceOrderResult = { OrderPlaced : OrderPlaced BillableOrderPlaced : BillableOrderPlaced OrderAcknowledgmentSent : OrderAcknowledgmentSent option } // ✅GOOD: イベントのリストを返すことで、新しいイベントを追加する場合に選択肢に追加できるようになる type PlaceOrderEvent = | OrderPlaced of OrderPlaced | BillableOrderPlaced of BillableOrderPlaced | AcknowledgmentSent of OrderAcknowledgmentSent type CreateEvents = PricedOrder -> PlaceOrderEvent list
- 同じイベントがドメイン内の複数のワークフローに現れることがわかった場合、より一般的なOrderTakingDomainEvent(受注ドメインイベント)として、ドメイン内のすべてのイベントの選択型として作ることもできる
- 発送用のOrderPlaced(注文が確定した)イベントと、請求書用のBillableOrderPlaced(請求可能な注文が確定した)イベントを作成する必要がある
- 検証のステップ
エフェクト(副作用)の文書化
- エフェクト(副作用)の文書化
- エラーを返すかやI/O処理をするかなどすべての依存関係を再検討して、エフェクトを明示する必要があるかを考える
- 検証ステップでのエフェクト
- CheckProductCodeExists(製品コードの存在チェック)とCheckAddressExists(アドレスの存在チェック)がある
-
AsyncResultで表現する例
// 製品カタログのローカルなキャッシュコピーがある場合 type CheckProductCodeExists = ProductCode -> bool // リモートのサービスを呼び出している場合 type AsyncResult<'success, 'failure> = Async<Result<'success, 'failure>> type CheckAddressExists = UnvalidatedAddress -> AsyncResult<CheckedAddress, AddressValidationError> type ValidateOrder = CheckProductCodeExists // 依存関係 -> CheckAddressExists // AsyncResultを返す依存関係に変更された -> UnvalidatedOrder // 入力 -> AsyncResult<ValidatedOrder, ValidationError> // 出力もAsyncResultに変更される
- 価格設定ステップでのエフェクト
-
ステップ自体がエラーを返す場合に、Resultで表現する例
type PricingError = PricingError of string // PriceOrder(注文の価格計算)ステップ自体がエラーを返す場合 type PriceOrder = GetProductPrice // 依存関係 -> ValidatedOrder // 入力 -> Result<PricedOrder, PricingError> // 出力
-
- 確認ステップでのエフェクト
-
I/O処理を行うが、エラーがあっても無視して成功パスを進みたい場合に、Asyncのみとする例
// I/O処理を行うが、エラーがあっても無視して成功パスを進みたい場合は、Asyncのみとする type SendOrderAcknowledgment = OrderAcknowledgment -> Async<SendResult> type AcknowledgeOrder = CreateOrderAcknowledgmentLetter // 依存関係 -> SendOrderAcknowledgment // Asyncを返す依存関係に変更された -> PriceOrder // 入力 -> Async<OrderAcknowledgmentSent option> // 出力もAsyncに変更される
-
ステップからワークフローを合成する
- ステップからワークフローを合成する
- 入力と出力の型の調整が必要となる
-
入力と出力だけリストアップしたもの
type ValidateOrder = UnvalidatedOrder // 入力 -> AsyncResult<ValidatedOrder, ValidationError list> // 出力 type PriceOrder = ValidatedOrder // 入力 -> Result<PricedOrder, PricingError> // 出力 type AcknowledgeOrder = PricedOrder // 入力 -> Async<OrderAcknowledgmentSent option> // 出力 type CreateEvents = PricedOrder // 入力 -> PlaceOrderEvent list // 出力
- PriceOrderにはValidatedOrderが必要だが、AsyncResult<ValidatedOrder,...>となっている
依存関係は設計の一部ですか?
- 依存関係は設計の一部ですか?
-
依存関係を明確にした例と、隠蔽した例
// 依存関係を明確にした例 type ValidateOrder = CheckProductCodeExists // 明示的な依存関係 -> CheckAddressExists // 明示的な依存関係 -> UnvalidatedOrder // 入力 -> AsyncResult<ValidatedOrder, ValidationError list> // 出力 // プロセスがどう仕事を遂行するか(どのシステムと連携するか)を隠蔽した例 type ValidateOrder = UnvalidatedOrder // 入力 -> AsyncResult<ValidatedOrder, ValidationError list> // 出力
- ガイドラインに沿って考えてみる
- パブリックAPIで公開されている関数については、依存関係の情報を呼び出し元から隠す
- 内部で使用される関数については、依存関係を明示する
- どうするか
- トップレベルのPlaceOrder(注文確定)ワークフロー関数の依存関係は、呼び出し側が知る必要がないため、公開すべきではない
- しかし、ワークフロー内部の各ステップは依存関係を明確にすべき
- 各ステップが実際に必要とするものを文書化できる
- ステップの依存関係が変化したときは、関数定義を変更できる
- しかし、ワークフロー内部の各ステップは依存関係を明確にすべき
-
type PlaceOrderWorkflow = PlaceOrder // 入力 -> AsyncResult<PlaceOrderEvent list, PlaceOrderError> // 出力
- トップレベルのPlaceOrder(注文確定)ワークフロー関数の依存関係は、呼び出し側が知る必要がないため、公開すべきではない
-
パイプラインの完成形
- パイプラインの完成形
- パブリックAPI
-
パブリックAPI
// --------------------- // 入力データ // --------------------- type UnvalidatedOrder = { OrderId : string CustomerInfo : UnvalidatedCustomer ShippingAddress : UnvalidatedAddress } and UnvalidatedCustomer = { Name : string Email : string } and UnvalidatedAddress = ... // --------------------- // 入力コマンド // --------------------- type Command<'data> = { Data : 'data Timestamp : DateTime UserId : string // etc } type PlaceOrderCommand = Command<UnvalidatedOrder> // --------------------- // パブリックAPI // --------------------- /// 受注確定ワークフローの成功出力 type OrderPlaced = ... type BillableOrderPlaced = ... type OrderAcknowledgmentSent = ... type PlaceOrderEvent = | OrderPlaced of OrderPlaced | BillableOrderPlaced of BillableOrderPlaced | AcknowledgmentSent of OrderAcknowledgmentSent /// 受注確定ワークフローの失敗出力 type PlaceOrderError = ... type PlaceOrderWorkflow = PlaceOrderCommand -> AsyncResult<PlaceOrderEvent list, PlaceOrderError>
-
- 内部ステップ
-
内部ステップ
// --------------------- // 内部ステップの定義 // --------------------- // ----- 注文の検証 ----- // 注文の検証が使用するサービス type CheckProductCodeExists = ProductCode -> bool type AddressValidationError = ... type CheckedAddress = ... type CheckAddressExists = UnvalidatedAddress -> AsyncResult<CheckedAddress, AddressValidationError> type ValidateOrder = CheckProductCodeExists // 依存関係 -> CheckAddressExists // 依存関係 -> UnvalidatedOrder // 入力 -> AsyncResult<ValidatedOrder, ValidationError list> // 出力 and ValidationError = ... // ----- 注文の価格計算 ----- // 注文の価格計算が使用するサービス type GetProductPrice = ProductCode -> Price type PricingError = ... type PriceOrder = GetProductPrice // 依存関係 -> ValidatedOrder // 入力 -> Result<PricedOrder, PricingError> // 出力 // etc
-
- パブリックAPI
長時間稼働するワークフロー
- 長時間稼働するワークフロー
- パイプラインは数秒程度の短時間で稼働することを想定していた
- 例: もし、一日中かかるとしたら
- リモートサービスを呼び出す前に、状態をストレージに保存する必要がある
- サービスが終了したことを知らせるメッセージを待って、次のステップに進める必要がある
- 人間の手作業が含まれるなど、長時間稼働するワークフローをサーガと呼ぶことがある
- プロセスマネージャーを作成する必要がある場合もある
- 受信したメッセージを処理して、現在の状態からどのようなアクションを取るべきかを判断し、適切なワークフローを起動させる役割を担う
- プロセスマネージャーを作成する必要がある場合もある
まとめ
- 本章では型だけを使ってワークフローをモデル化する方法を学んだ
- 流れ
- コマンドをどのようにモデル化するか検討し、ワークフローへの入力を文書化する
- ステートマシンを使ってライフサイクルをもつエンティティをモデル化する
- 各ステップの依存関係や副作用の文書化をする
- ドメインを伝達できるコードとしての、実行可能なドキュメントを作成している
- 多くの型が必要となる
- 型がなければ、検証済みの注文と価格設定された注文の違いや、装置コートと通常の文字列の違いを別に文書化する必要が出てきてしまう
- 流れ
次にやること
- 実際の実装に着手する
- ウォーターフォールを意図しているわけではない
- 実際には、顧客やドメインエキスパートへのフィードバックをできるだけ早く得るために必要なことは何でも行うべき
- 継続的に要件収集、モデリング、プロトタイピングを混ぜ合わせる
- 型を使ったモデリングの要点は、ドメインエキスパートがモデルを直接読むことができるため、要件とモデリングがシームレスに繋げることができる
第三部: モデルの実装
- 第三部の内容
- 第二部でモデル化したワークフローを実装する
- 一般的な関数型プログラミングのテクニックを使用する
8. 関数の理解
- 関数型プログラミングの基本を理解する
関数、関数、どこにでも
- 関数、関数、どこにでも
- 関数型プログラミングとオブジェクト指向プログラミングの違い
- 関数型プログラミングとは、関数が非常に重要であるかのようにプログラミングすること
- 関数があらゆる場所、あらゆるものに使われる
- パーツを作るとき
- OOP: クラスやオブジェクトとなる
- FP: 関数となる
- プログラムの一部をパラメーター化したり、コンポーネント間の結合を軽減する場合
- OOP: インターフェースや依存関係の注入を使用する
- FP: 関数を使ってパラメーター化する
- DRY原則
- OOP: 継承やDecoratlrパターンを使用する
- FP: 関数合成を使う
- 関数型プログラミングとは、関数が非常に重要であるかのようにプログラミングすること
- 関数型プログラミングは異なるスタイルの違いではなく、プログラミングに対する考え方がまったく違っていると理解することが重要
- 既存実装からの疑問が浮かんだ場合、その疑問が本当に解決したかった課題をどう解決するか考えた方がよい結果につながる
- コレクションをループするのはどうするのか -> コレクションの各要素に対してアクションを実行するにはどうしたらいいか
- 既存実装からの疑問が浮かんだ場合、その疑問が本当に解決したかった課題をどう解決するか考えた方がよい結果につながる
- 関数型プログラミングとオブジェクト指向プログラミングの違い
「もの」としての関数
- 「もの」としての関数
- 関数型プログラミングでは、関数自体が「もの」なので、他の関数へ入力として渡すことができる
- 出力としても返すことができる
- F#で関数を「もの」として扱う
-
F#で関数を「もの」として扱う例
let plus3 x = x + 3 let times2 x = x * 2 let square = (fun x -> x * x) let addThree = plus3 // listOfFunctions : (int -> int) list let listOfFunctions = [addThree; times2; square] for f in listOfFunctions do let result = fn 100 printfn "If 100 is the input, the output is %i" result // myString : string let myString = "hello" // square : x:int -> int let square = (fun x -> x * x)
-
- 入力としての関数
-
入力としての関数の例
let evalWith5ThenAdd2 fn = fn(5) + 2 let add1 x = x + 1 evalWith5ThenAdd2 add1 // fn(5) + 2 は add1(5) + 2 となる // よって、5 + 1 + 2 となる let square x = x * X evalWith5ThenAdd2 square // fn(5) + 2 は square(5) + 2 となる // よって、5 * 5 + 2 となる
-
- 出力としての関数
-
出力としての関数の例
let add1 x = x + 1 let add2 x = x + 2 let add3 x = x + 3 let adderGenerator numberToAdd = fun x -> numberToAdd + x // val addGenerator : int -> (int -> int) let adderGenerator numberToAdd = let innerFn x = numberToAdd + X innerFn let add1 = adderGenerator 1 add1 // 結果 => 3 let add100 = adderGenerator 100 add100 2 // 結果 => 102
-
- カリー化
- 「関数を返す」という技を使えば、どんなにパラメーターが多い関数でも、1パラメーターが連なったものに変換できる
- これをカリー化という
- F#では明示的に関数を返さなくてもすべての関数はカリー化されるようになっている
-
カリー化の例
// int -> int -> int let add x y = x + y // 明示的に関数を返す: int -> (int -> int) let adderGenerator x = fun y -> x + y
- 「関数を返す」という技を使えば、どんなにパラメーターが多い関数でも、1パラメーターが連なったものに変換できる
- 部分適用
- すべての関数がカリー化されていると、複数のパラメーターを持つ関数に1つだけ引数を渡すと部分適用ができる
-
部分適用の例
// sayGreeting: string -> string -> unit let sayGreeting greeting name = printfn "%s %s" greeting name // sayHello: string -> unit let sayHello = sayGreeting "Hello" // sayGoodbye: string -> unit let sayGoodbye = sayGreeting "GoodBye" sayHello "Alex" // 出力: "Hello Alex" sayGoodbye "Alex" // 出力: "Goodbye Alex"
- 関数型プログラミングでは、関数自体が「もの」なので、他の関数へ入力として渡すことができる
全域関数
- 全域関数
- 数学の世界では、関数とは取りうる各入力に対して、それぞれ1つの出力に結びついているものを指す
- 関数型プログラミングでも、取りうる入力全てについて、対応する出力が1つずつ決まるように関数を設計する
- これを全域関数と呼ぶ
- なぜそんなことをやるのか?
- すべての副作用を型シグネチャで文書化して、明示的にしたいから
-
くだらないtwelveDividedByという関数の例
// twelveDividedBy: int -> int となってしまう(例外がある) let twelveDividedBy n = match n with | 6 -> 2 | 5 -> 2 | 4 -> 3 | 3 -> 4 | 2 -> 6 | 1 -> 12 | 0 -> failwith "Can't divide by zero" /// 制限された入力値を使う場合 type NonZeroInteger = // ゼロではない整数に制約されるように定義する // スマートコンストラクタを追加するなど private NonZeroInteger of int // twelveDividedBy: NonZeroInteger -> int let twelveDividedBy (NonZeroInteger n) = match n with | 6 -> 2 ... /// 拡張された出力値を使う場合 // twelveDividedBy: int -> int option let twelveDividedBy n = match n with | 6 -> Some 2 // 有効 | 5 -> Some 2 // 有効 | 4 -> Some 3 // 有効 ... | 0 -> None // 未定義
- 数学の世界では、関数とは取りうる各入力に対して、それぞれ1つの出力に結びついているものを指す
関数合成
- 関数合成
- 関数合成とは
- 1つ目の関数の出力を2つ目の関数の入力につなげて、関数を組み合わせること
-
りんご -> バナナ
関数とバナナ -> さくらんぼ
関数を合成して、りんご -> さくらんぼ
関数を作ること - 合成の重要な位置側面として、情報の隠蔽がある
-
バナナ
はどこへいった
-
-
- 1つ目の関数の出力を2つ目の関数の入力につなげて、関数を組み合わせること
- F#における関数合成
- F#では最初の関数の出力型と2番目の関数の入力型が同じであれば、2つの関数をくっつけることができる
- パイピングと呼ばれる方法で行われる
- Unixでのパイピングに似ている
-
F#でのパイピング
let add1 x = x + 1 // int -> int 型の関数 let square x = x * x // int -> int 型の関数 let add1ThenSquare x = x |> add1 |> square // テスト add1ThenSquare 5 // 結果は36 let isEven x = (x % 2) = 0 // int -> bool 型の関数 let printBool = sprintf "value is %b" x // bool -> string 型の関数 let isEvenThenPrint x = x |> isEven |> printBool // テスト isEvenThenPrint 2 // 結果は"value is true"
- F#では最初の関数の出力型と2番目の関数の入力型が同じであれば、2つの関数をくっつけることができる
- 関数からアプリケーション全体を構築する
-
低レベル処理
をいくつかまとめてサービス
にして、それをいくつかまとめてワークフロー
とする - できた
ワークフロー
を並列に合成して、入力に応じて特定のワークフローを呼び出すコントローラ/ディスパッチャ
を作成することで、ワークフローからアプリケーション
を構築できる - これが関数型アプリケーションを構築する方法
- 各レイヤーは入力と出力をもつ関数で構成され、すべての層が関数で構成されている
-
- 関数を合成する上での課題
- 一方の関数の出力がもう一方の関数の入力と一致しない場合にどうするか
- よくあるのは基礎となる型は合うが、関数の形式が異なる場合
- ある関数がOption<int>を出力するが、2つ目の関数はプレーンなintを必要とする場合など
- 一般的なアプローチは、両サイドの最小公倍数に変換すること
- intとOption<int>なら、Someを用いて関数Aの出力をOptionに変換する
-
両サイドの最小公倍数に変換する
// intを出力とする関数 let add1 x = x + 1 // Option<int>を入力とする関数 let printOption x = match x with | Some i -> printfn "The int is %i" i | None -> printfn "No value" // Someコンストラクタを使ってadd1の出力をOptionに変換する 5 |> add1 |> Some |> printOption
- 一方の関数の出力がもう一方の関数の入力と一致しない場合にどうするか
- 関数合成とは
まとめ
- 本章ではF#における関数型プログラミングの基本的な概念を学んだ
- あらゆる場所で関数を構成要素として使用し、それらを合成可能に設計すること
9. 実装:パイプラインの合成
- まだ何も実装はしていない
- ワークフローは一連のドキュメント変換、すなわちパイプラインと考えられ、パイプラインの各ステップは「パイプ」として設計されている
- パイプラインの段階
- UnvalidatedOrder(未検証の注文)をValidatedOrder(検証済みの注文)に変換し、失敗した場合はエラーを返す
- ValidatedOrderをPricedOrderに変換する
- 価格設定ステップの出力を取得し、確認書を作成して送信する
- 一連のイベントを作成して返す
-
パイプラインの見た目
let placeOrder unvalidatedOrder = unvalidatedOrder |> ValidateOrder |> PriceOrder |> AcknowledgeOrder |> createEvents
- 元の要件を維持したまま、技術的な詳細に引きずられることなくコード化したい
- 個々のステップを作成して、ワークフローを実装する
- 各ステップをステートレスで副作用のない独立した関数として実装する
- これらの小さな関数を1つの関数に合成する
- 実際にやってみるときの注意点
- 各ステップの入力と出力を操作して合成できるようにする必要がある
- 一部の関数には、データパイプラインの一部ではないものの実装には必要となる、追加のパラメーターがある
- これらは依存関係と呼ばれる
- エラー処理などのエフェクトへの対応となるResult型を、プレーンなデータを入力とする関数に結びつけられるようにする
- 個々のステップを作成して、ワークフローを実装する
- 本章では「依存性の注入」と同等のことを関数型プログラミングで実現する方法を見ていく
- いったんResultやAsyncなどのエフェクトの扱いはおいておく
単純型を扱う
- 単純型を扱う
- ワークフロー自体のステップを実装する前に、まずOrderId(注文ID)やProductCode(製品コード)などの単純型を実装する
- ほとんどの型が制約を受けているので、制約付きの型を実装する
- 例:
OrderId.create
はstringから制約付きのOrderId
を作成する -
module Domain = type OrderId = private OrderId of string module OrderId = /// 注文IDのスマートコンストラクタを定義する /// string -> string let create str = if String.IsNullOrEmpty(str) then // ひとまずResultではなく例外を使う failwith "OrderId must not be null or empty" elif str.Length > 50 then failwith "OrderId must not be more than 50 chars" else OrderId str /// 注文IDの内部値を取り出す /// OrderId -> string // パターンマッチと内部値の抽出をワンステップでおこなっている let value (OrderId str) = // パラメーターのところですでにアンラップしている str // 内部値を返す
- ワークフロー自体のステップを実装する前に、まずOrderId(注文ID)やProductCode(製品コード)などの単純型を実装する
関数の型から実装を導く
- 関数の型から実装を導く
- コードが型に準拠していることを確実にするには
- 通常の方法で関数を定義して、後で使用するときに型エラーが出ると信じる方法(型推論を使用する)
- 関数を実装した後で組み合わせる際にエラーが発生する
-
let ValidateOrder checkProductCodeExists // 依存関係 checkAddressExists // 依存関係 unvalidatedOrder = // 入力 ...
- 関数シグネチャを定義して特定の型を実装していることを明確にする方法
- 関数を組み合わせたときにエラーになるのではなく、関数定義の中でローカルにエラーが発生する
-
// 関数シグネチャを定義する type MuFunctionSignature = Param1 -> Param2 -> Result // シグネチャを実装する関数を定義する let myFunc: MyFunctionSignature = fun param2 param2 // validateOrderの例 let validateOrder : ValidateOrder = fun checkProductCodeExists checkAddressExists unvalidatedOrder -> ...
- 通常の方法で関数を定義して、後で使用するときに型エラーが出ると信じる方法(型推論を使用する)
- コードが型に準拠していることを確実にするには
検証ステップの実装
- 検証ステップの実装
- プリミティブなフィールドを持つ未検証の注文を、検証済みの適切なドメインオブジェクトに変換する
-
ドメインオブジェクトに変換する
let validateOrder: ValidateOrder = fun checkProductCodeExists checkAddressExists unvalidatedOrder -> let orderId = unvalidatedOrder.OrderId |> OrderId.create let customerInfo = unvalidatedOrder.CustomerInfo |> toCustomerInfo // ヘルパー関数 let shippingAddress = unvalidatedOrder.ShippingAddress |> toAddress // ヘルパー関数 // unvalidatedOrderの各プロパティに対して同様に行う // すべてのフィールドの準備ができたら、それらを使って // 新しい「検証済みの注文」レコードを作成して返す { OrderId = orderId CustomerInfo = customerInfo ShippingAddress = shippingAddress BillingAddress = ... Lines = ... } let toCustomerInfo (customer:UnvalidatedCustomerInfo) : CustomerInfo = // 顧客情報の各種プロパティを作成すr // 無効な場合は例外をスローする let firstName = customer.FirstName |> String50.create let lastName = customer.LastName |> String50.create let emailAddress = customer.EmailAddress |> EmailAddress.create // 個人名を作成すr let name : PersonalName = { FirstName = firstName LastName = lastName } // 顧客情報を作成する let customer Info : CustomerInfo = { Name = name EmailAddress = emailAddress } // そしてそれを返す customerInfo
-
- 検証されたチェック済みの住所の作成
- 生のプリミティブ型をドメインオブジェクトに変換し、住所が存在するかどうかもチェックする必要がある
-
toAddress関数の実装
let toAddress (checkAddressExists unvalidatedAddress) = // リモートサービスを呼び出す let checkedAddress = checkAddressExists unvalidatedAddress // パターンマッチを使用して内部値を抽出する let (CheckedAddress checkedAddress) = checkedAddress let addressLine1 = checkedAddress.AddressLine1 |> String50.create let addressLine2 = checkedAddress.AddressLine2 |> String50.createOption // createOptionでnullや空文字列を許容する let addressLine3 = checkedAddress.AddressLine3 |> String50.createOption let addressLine4 = checkedAddress.AddressLine4 |> String50.createOption let city = checkedAddress.City |> String50.create let zipCode = checkedAddress.ZipCode |> ZipCode.create // 住所を作成すr let address : Address = { AddressLine1 = addressLine1 AddressLine2 = addressLine2 AddressLine3 = addressLine3 AddressLine4 = addressLine4 City = city ZipCode = zipCode } // 住所を返す address let validateOrder : ValidateOrder = fun checkProductCodeExists checkAddressExists unvalidatedOrder -> let orderId = ... let customerInfo = ... let shippingAddress = unvalidatedOrder.ShippingAddress |> toAddress checkAddressExists // 新しいパラメーター
- 明細行の作成
- toOrderQuantityでは、生の10進数をケースごとに異なる検証を行って選択型として返す
-
明細行の作成
let toValidatedOrderLine checkProductCodeExists (unvalidatedOrderLine:UnvalidatedOrderLine) = let orderLineId = unvalidatedOrderLine.OrderLineId |> OrderLineId.create let productCode = unvalidatedOrderLine.ProductCode |> toProductCode checkProductCodeExists // ヘルパー関数 let quantity = unvalidatedOrderLine.quantity |> toOrderQuantity productCode // ヘルパー関数 let validatedOrderLine = { OrderLineID = orderLineId ProductCode = productCode Quantity = quantity } validatedOrderLine let validateOrder : ValidateOrder = fun checkProductCodeExists checkAddressExists unvalidatedOrder -> let orderId = ... let customerInfo = ... let shippingAddress = ... let orderLines = unvalidatedOrder.OrderLines // `toValidatedOrderLine`を用いて各行を変換する |> List.map (toValidatedOrderLine checkProductCodeExists) let toOrderQuantity productCode quantity = match productCode with | Widget _ -> quantity |> int // decimalをintに変換 |> UnitQuantity.create // ユニット数に変換 |> OrderQuantity.Unit // OrderQuantity型に持ち上げる | Gizmo _ -> quantity |> KilogramQuantity.create // キログラム量に変換 |> OrderQuantity.Kilogram // OrderQuantity型に持ち上げる
- 関数アダプターの作成
- checkProductCodeExistsがbool値を返すため、ProductCodeを返すことができないのをどうするか?
- check関数を引数として渡して判定する場合に、判定後の戻り値がパイプラインで想定していないbool値になってしまう問題
-
bool値を返すため、ProductCodeを返すことができない
let toProductCode (checkProductCodeExists:CheckProductCodeExists) productCode = productCode |> ProductCode.create |> checkProductCodeExists // boolを返すことになる
- 仕様を変更するのではなくアダプター関数を作成する
- 判定を抜けられれば入力値をそのまま返す(パススルー)アダプター関数
- パススルー関数を使うのは、関数型プログラミングではよくある方法
-
let toProductCode (checkProductCodeExists:CheckProductCodeExists) productCode = let checkProduct productCode = let errorMsg = sprintf "Invalid: %A" productCode predicateToPassthru errorMsg checkProductCodeExists productCode productCode |> ProductCode.create |> checkProduct // パススルー関数 let convertToPassthru checkProductCodeExists productCode = if checkProductCodeExists productCode then productCode else failwith "Invalid product Code" // 任意の述語関数をパススルー関数に変換する汎用アダプター let predicateToPassthru errorMsg f x = if f x then x else failwith errorMsg
- checkProductCodeExistsがbool値を返すため、ProductCodeを返すことができないのをどうするか?
- プリミティブなフィールドを持つ未検証の注文を、検証済みの適切なドメインオブジェクトに変換する
残りのステップの実装
- 残りのステップの実装
- priceOrderの実装
-
priceOrderの実装
// 元の設計からエフェクト(副作用)を取り除いたもの type GetProductPrice = ProductCode -> Price type PriceOrder = GetProductPrice // 依存関係 -> ValidatedOrder // 入力 -> PricedOrder // 出力 let priceOrder : PriceOrder = fun getProductPrice validatedOrder -> let lines = validatedOrder.Lines |> List.map(toPricedOrderLine getProductPrice) let amountToBill = lines |> List.map(fun line -> line.LinePrice) |> BillingAmount.sumPrices let pricedOrder : PricedOrder = { OrderId = validatedOrder.OrderId CustomerInfo = validatedOrder.CustomerInfo ShippingAddress = validatedOrder.ShippingAddress BillingAddress = validatedOrder.BillingAddress Lines = lines AmountToBill = amountToBill } pricedOrder /// 価格のリストを合計して請求総額にする /// 合計が範囲外の場合は例外を発生させる let sumPrices prices = let total = prices |> List.map Price.value |> List.sumBy create total /// 検証済みの注文明細行を価格計算済みの注文明細行に変換する let toPricedOrderLine getProductPrice (line:ValidatedOrderLine) : PricedOrderLine = let qty = line.Quantity |> OrderQuantity.ValidationError let price = line.ProductCode |> getProductPrice let linePrice = price |> Price.multiply qty { OrderLIneId = line.OrderLineID ProductCode = line.ProductCode Quantity = line.Quantity LinePrice = linePrice } /// 価格に十進数の数量をかける /// 新しい価格が範囲外の場合、例外を発生させる let multiply qty (Price p) = create (qty * p)
-
- まだ実装したくない・わからない場合は、「実装されていない」メッセージを出して失敗させることもできる
- 実装のスケッチをするときに、プロジェクト全体がコンパイルが可能になるので便利
- ダミーの関数を作成して組み合わせておくことができる
-
まだ実装したくない場合
let priceOrder : PriceOrder = fun getProductPrice validatedOrder -> failwith "not implemented"
- 確認ステップの実装
- sendAcknowledgmentはまだ実装を決めなくてもいい
- 依存関係をパラメーター化する利点の1つ
-
// エフェクトを排除した「確認ステップ」の設計 type HtmlString = HtmlString of string type CreateOrderAcknowledgmentLetter = PricedOrder -> HtmlString type OrderAcknowledgment = { EmailAddress : EmailAddress Letter : HtmlString } type SendResult = Sent | NotSent type SendOrderAcknowledgment = OrderAcknowledgment -> SendResult type AcknowledgeOrder = CreateOrderAcknowledgmentLetter // 依存関係 -> SendOrderAcknowledgment // 依存関係 -> PricedOrder // 入力 -> OrderAcknowledgmentSent option // 出力 // 実装 let acknowledgeOrder : AcknowledgeOrder = fun createOrderAcknowledgmentLetter sendAcknowledgment pricedOrder -> let letter createAcknowledgmentLetter pricedOrder let acknowledgment = { EmailAddress = pricedOrder.CustomerInfo.EmailAddress Letter = letter } // 確認が正常に送信された場合、対応するイベントを返す // そうでなければNoneを返す match sendAcknowledgment acknowledgment with | Sent -> let event = { OrderId = pricedOrder.OrderId EmailAddress = pricedOrder.CustomerInfo.EmailAddress } Some event | NotSent -> None
- sendAcknowledgmentはまだ実装を決めなくてもいい
- イベントの作成
- ワークフローから返されるイベントを作成する
- 互換性のないものを持ち上げて共通の型に変換するというアプローチをとる
-
持ち上げてイベントを作成する
/// 配送コンテキストに送信するイベント type OrderPlaced = PricedOrder /// 請求コンテキストに送信するイベント /// 請求総額が0ではない場合にのみ作成される type BillableOrderPlaced = { OrderId : OrderId BillingAddress : Address AmountToBill : BillingAmount } type PlaceOrderEvent = | OrderPlaced of OrderPlaced | BillableOrderPlaced of BillableOrderPlaced | AcknowledgmentSent of OrderAcknowledgmentSent type CreateEvents = PricedOrder // 入力 -> OrderAcknowledgmentSent option // 入力(前のステップのイベント) -> PlaceOrderEvent list // 出力 // PricedOrder -> BillableOrderPlaced option let createBillingEvent (placedOrder:PricedOrder) : BillableOrderPlaced option = let billingAmount = placedOrder.AmountToBill |> BillingAmount.value if billingAmount = 0M then let order = { OrderId = placedOrder.OrderId BillingAddress = placedOrder.BillingAddress AmountToBill = placedOrder.AmountToBill } Some order else None let createEvents : CreateEvents = fun pricedOrder acknowledgmentEventOpt -> let event1 = pricedOrder // 共通の選択型に変換する |> PlaceOrderEvent.OrderPlaced // リストに変換する |> List.singleton let event2Opt = acknowledgmentEventOpt // 共通の選択型に変換すr |> Option.map PlaceOrderEvent.AcknowledgmentSent // リストに変換する |> listOfOption let event3Opt = pricedOrder |> createBillingEvent // 共通の選択型に変換する |> Option.map PlaceOrderEvent.BillableOrderPlaced // リストに変換する |> listOfOption // 全てのイベントを返す [ yield! event1 yield! event2 yield! event3 ] // Option型をList型に変換する let listOfOption opt = match opt with | Some x -> [x] | None -> []
- priceOrderの実装
パイプラインのステップを1つに合成する
- パイプラインのステップを1つに合成する
- そのままだと入力が一致しないときは、部分適用して変形させた関数を作成する(シャドーイング)
-
シャドーイングの例
// ワークフロー let placeOrder : PlaceOrderWorkflow = fun unvalidatedOrder -> unvalidatedOrder |> validateOrder |> priceOrder |> acknowledgeOrder |> createEvents // validateOrderには依存関係が含まれているため、unvalidatedOrderだけでは呼び出せない // 部分適用して型を合わせる // F#では同じ名前(validateOrder)を局所的な下らしい関数にも使用できる(シャドーイング) let validateOrder = validateOrder checkProductCodeExists checkAddressExists // アポストロフィをつけて元の関数の変形だと明示することもできる let validateOrder' = validateOrder checkProductCodeExists checkAddressExists let placeOrder : PlaceOrderWorkflow = // 部分適用を使って依存関係を組み込んだ、 // パイプラインステージの局所的なパージョンを作成する let validateOrder = validateOrder checkProductCodeExists checkAddressExists let priceOrder = priceOrder getProductPrice let acknowledgeOrder = acknowledgeOrder createOrderAcknowledgmentLetter sendOrderAcknowledgment // ワークフロー関数を返す fun unvalidatedOrder -> // 新しい1引数関数でパイプラインを合成する unvalidatedOrder |> validateOrder |> priceOrder |> acknowledgeOrder |> createEvents // acknowledgeOrderの出力が単なるイベントなので、 // createEventsの入力とは一致しない // そのため、単純にもっと命令型のコードに切り替えることもできる let placeOrder : PlaceOrderWorkflow = fun unvalidatedOrder -> let validatedOrder = unvalidatedOrder |> validateOrder checkProductCodeExists checkAddressExists let pricedOrder = validatedOrder |> priceOrder getProductPrice let acknowledgmentOption = pricedOrder |> acknowledgeOrder createAcknowledgmentLetter sendAcknowledgment let events = createEvents pricedOrder acknowledgmentOption events
- そのままだと入力が一致しないときは、部分適用して変形させた関数を作成する(シャドーイング)
依存関係の注入
- 依存関係の注入
- 依存関係をどうやって渡すか
-
checkProductCodeExists
やcheckAddressExists
の依存関係をどうするか- グローバルには定義したくない
- トップレベルから依存関係を必要とする関数まで、どうやって受け渡していくか
- OOPでは依存関係の注入やIoCコンテナを使う
- 関数型プログラミングでは依存関係が暗黙的になるのは望ましくない
- リーダーモナドやフリーモナドなどのさまざまな手法がある
- シンプルにするには、ドリリングのようにトップレベルからバケツリレーしていく方法
- 実装時はトップレベルの関数に到達するまで連鎖を繰り返す
-
依存関係のバケツリレーの例
// 低レベルのヘルパー関数 let toAddress checkAddressExists unvalidatedAddress = ... let toProductCode checkProductCodeExists productCode = ... // ヘルパー関数 let toValidatedOrderLine checkProductExists unvalidatedOrderLine = // 明細行のコンポーネントを作成する let orderLineId = ... let productCode = unvalidatedOrderLine.ProductCode |> toProductCode checkProductCodeExists // サービスを利用する
- ワークフロー関数としてのplaceOrderをコンポジションルートにすべきか?(トップレベルの関数はOOPではコンポジションルートと呼ばれる)
- すべきではない
- placeOrderワークフロー自体も、必要なサービスをパラメーターとして受け取る方がよい
- そうすれば、すべての依存関係を差し替えられるため、ワークフロー全体を簡単にテストできる
-
Suaveフレームワークを使ったWebサービスのコンポジションルートの例
let app : WebPart = // ワークフローで使用するサービスの設定 let checkProductExists = ... let checkAddressExists = ... let getProductPrice = ... let createOrderAcknowledgmentLetter = ... let sendOrderAcknowledgment = ... let toHttpResponse = ... // サービスの部分適用によるplaceOrderワークフローの設定 let placeOrder = placeOrder checkProductExists checkAddressExists getProductPrice createOrderAcknowledgmentLetter sendOrderAcknowledgment // 他のワークフローの設定 let changeOrder = ... let cancelOrder = ... choose [ POST >=> choose [ path "/placeOrder" >=> deserializeOrder // JSONを未検証の注文に変換する >=> placeOrder // ワークフローを実行する >=> postEvents // イベントをキューに投入する >=> toHttpResponse // 出力に基づき200/400/etcを返す path "/changeOrder" >=> ... path "/cancelOrder" >=> ... ] ]
-
- 依存関係が多すぎるとき
- 低レベルの関数をトップレベル関数の外側で設定し、すべての依存関係を組み込んだ上で構築済みの子関数を渡す方法がある
-
構築済みの子関数を渡す例
let placeOrder : PlaceOrderWorkflow = // (構成情報を元にするなどして)情報を初期化する let endPoint = ... let credentials = ... // 認証情報を組み込んだ // checkAddressExistsの新しいバージョンを作成する let checkAddressExists = checkAddressExists endPoint credentials // etc // ワークフローのステップを設定する let validateOrder = validateOrder checkProductCodeExists checkAddressExists // 新しいcheckAddressExistsは1引数の関数として使用される // ワークフロー関数を返す fun unvalidatedOrder -> // ステップからパイプラインを合成する ...
- 依存関係をどうやって渡すか
依存関係のテスト
- 依存関係のテスト
- 依存関係を渡す利点はコア機能のテストが簡単になること
- スタブの依存関係を渡すだけでよいので、モックを使う必要がない
-
スタブした依存関係のテストの例
open NUnit.Framework [<Test>] let ``製品が存在する場合は、検証に成功する``() = // 準備: サービス依存関係のスタブを設定する let checkAddressExists address = CheckedAddress address // 成功 let checkProductCodeExists productCode = true // 成功 // 準備: 入力の設定 let unvalidatedOrder = ... // 実行: validateOrderを呼び出す let result = validateOrder checkProductCodeExists checkAddressExists ... // 検証: 結果がエラーではなく、検証済みの注文であることを確認する ... [<Test>] let ``製品が存在しない場合は、検証に失敗する``() = // 準備: サービスの依存関係のスタブを設定する let checkAddressExists address = ... let checkProductCodeExists productCode = false // 失敗 // 準備: 入力の設定 let unvalidatedOrder = ... // 実行: validateOrderを呼び出す let result = validateOrder checkProductCodeExists checkAddressExists ... // 検証: 結果が失敗であることを確認する ...
- 関数型プログラミングのテストのメリット
- validateOrder関数はステートレスなため、同じ入力で呼び出すと同じ出力が得られるためテストが簡単になる
- すべての依存関係が明示的に渡されるため、動作の理解が容易になる
- すべての副作用はパラメーターにカプセル化されているため、制御が簡単になる
- 依存関係を渡す利点はコア機能のテストが簡単になること
組み立てられたパイプライン
- 組み立てられたパイプライン
- 完全なパイプラインをどう組み立てるか
- パイプラインの完全な実装
-
パイプラインの例
module PlaceOrderWorkflow = // 共通の単純型(String50や製品コードなど)を利用できるようにする open SimpleType // 呼び出し元に公開されるパプリック型を利用できるようにする open API // =============================== // パート1: 設計 // =============================== // ワークフローのパプリックな部分(API)は、 // `PlaceOrderWorkflow`関数やその入力である`UnvalidatedOrder`のように // 別の場所で定義されている // 以下の型はワークフロー実装に対しプライベートである // ----- 注文の検証 ----- type CheckProductCodeExists = ProductCode -> bool type CheckedAddress = CheckedAddress of UnvalidatedAddress type CheckAddressExists = UnvalidatedAddress -> CheckedAddress type ValidateOrder = CheckProductCodeExists // 依存関係 -> CheckAddressExists // 依存関係 -> UnvalidatedOrder // 入力 -> ValidatedOrder // 出力 // ----- 注文の価格決定 ----- type GetProductPrice = ... type PriceOrder = ... // etc // =============================== // パート2: 実装 // =============================== // -------------------------------- // 注文の検証: 実装 // -------------------------------- let toCustomerInfo (unvalidatedCustomerInfo: UnvalidatedCustomerInfo) = ... let toAddress (checkAddressExists: CheckAddressExists) unvalidatedAddress = ... let predicateToPassthru = ... let toProductCode (checkProductCodeExists: CheckProductCodeExists) productCode = ... let toOrderQuantity productCode quantity = ... let toValidatedOrderLine checkProductExists (unvalidatedOrderLine: UnvalidatedOrderLine) = ... /// 注文の検証ステップの実装 let validateOrder : ValidateOrder = fun checkProductCodeExists checkAddressExists unvalidatedOrder -> let orderId = unvalidatedOrder.OrderId |> OrderId.create let customerInfo = ... let shippingAddress = ... let billingAddress = ... let lines = unvalidatedOrder .Lines |> List.map (toValidatedOrderLine checkProductCodeExists) let validatedOrder : ValidateOrder = { OrderId = orderId CustomerInfo = customerInfo ShippingAddress = shippingAddress BillingAddress = billingAddress Lines = lines } validatedOrder // -------------------------------- // ワークフローの全体像 // -------------------------------- let placeOrder checkProductExists // 依存関係 checkAddressExists // 依存関係 getProductPrice // 依存関係 createOrderAcknowledgmentLetter // 依存関係 sendOrderAcknowledgment // 依存関係 : PlaceOrderWorkflow = // 関数の定義 fun unvalidatedOrder -> let validatedOrder = unvalidatedOrder |> validateOrder checkProductExists checkAddressExists let pricedOrder = validatedOrder |> priceOrder getProductPrice let acknowledgmentOption = pricedOrder |> acknowledgeOrder createOrderAcknowledgmentLetter sendOrderAcknowledgment let events = createEvents pricedOrder acknowledgmentOption events
- 完全なパイプラインをどう組み立てるか
まとめ
- 本章ではパイプラインの各ステップの実装と依存関係の処理に焦点を当てた
- 各ステップについて
- 各ステップの実装は、1つのインクリメンタルな変換に絞っている
- 単独で理解しやすく、テストしやすい
- ステップの合成の際に型が一致しない場合のテクニック
- アダプター関数: アダプター関数を使用して、
bool
からProductCode
などに変換する - 共通型に持ち上げる: イベントを共通の
PlaceOrderEvent
型に変換する - 部分適用を使用して依存関係を型に組み込む: 関数合成をより簡単にしつつ、実装の詳細を呼び出し側から隠す
- アダプター関数: アダプター関数を使用して、
- 各ステップについて
- 何が残っているか
- エフェクト処理を避け、エラー処理に例外を使用した
- 関数合成には便利だが、ドキュメントの観点では最悪
- ごまかしのある関数シグネチャになってしまっている
10. 実装: エラーの扱い
- エラー処理に対する関数型のアプローチを探る
- 煩雑な条件分岐やtry/catch地獄でコードを汚染しない
- ある種のエラーをドメインエラーとして扱う
Result型を使ってエラーを明示する
- Result型を使ってエラーを明示する
- 関数型プログラミングの手法は、できるだけものごとを明示的にすることを重視する
- 残念だがコード内でエラーが2級市民のように扱われてしまうことがよくある
- 本番稼働できる強固なシステムにするにはエラーを1級市民として扱うべき
- 特にドメインの一部であるエラーについてはなおさら
- エラーを含めて、起こりうる結果がすべて型シグネチャによって明示的に文書化されている必要がある
-
エラーを明示的に文書化する
// エラーが明示的ではない type CheckAddressExists = UnvalidatedAddress -> CheckedAddress // エラーが明示的なバージョン type CheckAddressExists = UnvalidatedAddress -> Result<CheckedAddress, AddressValidationError> and AddressValidationError = | InvalidFormat of string | AddressNotFound of string
- シグネチャを見るだけで多くのことを理解できる
- 明示的になっているドキュメント内容
- 入力は未検証の住所である
- 検証が成功すると出力はチェック済みの住所となる
- 検証が成功しなかった場合の理由は、フォーマットが無効だったか、アドレスが見つからなかったかのどちらかである
-
- 関数型プログラミングの手法は、できるだけものごとを明示的にすることを重視する
ドメインエラー扱う
- ドメインエラーを扱う
- エラーをグループ化する
- エラーの種類
- ドメインエラー: ビジネスプロセスの一部として予想されるエラーで、ドメイン設計に含まれているもの
- 例: 請求の段階で却下された注文、無効な製品コードを含む注文など
- パニック: 処理不可能なシステムエラー(メモリ不足など)やプログラマーの見落としによるエラー(null参照やゼロによる除算など)、システムを不明な状態にするエラー
- インフラエラー: アーキテクチャの一部のエラーであり、ビジネスプロセスやドメインにも含まれない、ネットワークタイムアウトや認証失敗など
- ドメインエラー: ビジネスプロセスの一部として予想されるエラーで、ドメイン設計に含まれているもの
- それぞれのエラーの対処方法
- ドメインエラー
- ドメインエラーかどうかわからないときは、ドメインエキスパートに聞いてみる
- 「ねえオリー、ロードバランサーへの接続が中断されたとき、何か気になることはある?」オリー「???」
- ドメインエラーはドメインモデリングに組み込んで、可能であれば型システムで文書化する
- ドメインエラーかどうかわからないときは、ドメインエキスパートに聞いてみる
- パニック
- パニックは、ワークフローを放棄し例外を発生させ、適切かつもっとも高いレベル(アプリケーションのmain関数など)で補足することが最善の対処方法
-
最も高いレベルで例外を補足する例
/// 悪い入力を受け取るとパニックするワークフロー let workflowPart2 input = if input = 0 then raise (DivideByZeroException()) ... /// ワークフローからすべての例外をトラップする /// アプリケーションのトップレベル関数 let main() = // すべてのワークフローをtry/withブロックで囲む try let result1 = workflowPart1() let result2 = workflowPart2 result1 printfn "the result is %A" result2 // トップレベルの例外処理 with | :? OutOfMemoryException -> printfn "exited with OutOfMemoryException" | :? DivideByZeroException - printfn "exited with DivideByZerorException" | ex -> printfn "exited with %s" ex.Message
- インフラエラー
- コードが多くマイクロサービスで構成されていれば例外処理がすっきりする
- モノリシックなアプリケーションなら、より明確にドメインエラーと同様に扱ってもよい
- これもドメインエキスパートに尋ねるとわかりやすい
- リモートアドレスの検証サービスが利用できない場合、ビジネスプロセスはどう変更すべきか、顧客にはどう伝えるべきか
- 例: 今、ご存知のように国のアドレス検証サービスがダウンしているので、もう少々お待ちいただけますか -> ドメインエラーにした方がよさそう
- これもドメインエキスパートに尋ねるとわかりやすい
- ドメインエラー
- エラーの種類
- 型によるドメインエラーのモデリング
- ドメインを文字列などのプリミティブ型を使わずにドメイン語彙で表現したように、エラーも同じように扱う
- エラーを選択型としてモデル化する
- うまくいかない可能性のあるすべての事柄について、コード内で明示的にドキュメント化できる
- さらに選択型を拡張できる
- 個別のエラーへの対応を追加することも容易になる
- RemoteServiceErrorならリトライするなど
-
エラーを選択型としてモデル化する例
type PlaceOrderError = | ValidationError of string | ProductOutOfStock of ProductCode | RemoteServiceError of RemoteServiceError ...
- うまくいかない可能性のあるすべての事柄について、コード内で明示的にドキュメント化できる
- エラー処理はコードの見た目を悪くする
- 例外の利点は成功パスのコードを整然と保てるところ
- ただ、各ステップでエラーを返すとコードはさらに煩雑になっていってしまう
- コードの大部分がエラー処理に費やされてしまうこともある
-
例外でハンドリングする例
let validateOrder unvalidatedOrder = // 潜在的なエラーを条件分岐で処理する例 let orderResult = ... create order id (or return Error) if orderIdResult is Error then return let customerInfoResult = ... create name (or return Error) if customerInfoResult is Error then return // 潜在的な例外をtry/withで補足する例 try let shippingAddressResult = ... create valid address (or return Error) if shippingAddress is Error then return // ... with | ?: TimeoutException -> Error "service timed out" | ?: AuthenticationException -> Error "bad credentials" // etc
- ただ、各ステップでエラーを返すとコードはさらに煩雑になっていってしまう
- 例外の利点は成功パスのコードを整然と保てるところ
- エラーをグループ化する
Resultを生成する関数の連鎖
- Resultを生成する関数の連鎖
- Resultを出力する関数は2つの分岐を作る
- 鉄道にちなんでスイッチ関数と呼ぶ(モナディック関数)
- 2つのスイッチ関数があれば組み合わせて両方の失敗トラックをパイパスして接続する
- パイプラインのすべてのステップをつなぐと、成功パスと失敗パスの2トラックの「鉄道指向プログラミング」となる
- 次の関数の入力と型が合わない問題がある
- 入力が1つで出力が2つのスイッチ関数を、2トラックの関数に変換する必要がある
- アダプターブロックを作成することで解決する
- 鉄道にちなんでスイッチ関数と呼ぶ(モナディック関数)
- アダプターブロックの実装
- スイッチ関数を2トラック関数に変換するアダプターは、関数型プログラミングのツールキットの中でも非常に重要
- bindやflatMapと呼ばれている
- どう実装するか
- 入力はスイッチ関数で、出力は2トラックのみの関数で、2トラックの入力と2トラックの出力をもつラムダとなる
- 2トラックの入力が成功した場合、その入力をスイッチ関数に渡す
- 2トラックの入力が失敗の場合は、スイッチ関数をバイパスして失敗を返す
-
// カリー化されたbind関数 let bind switchFn = fun twoTrackInput -> match twoTrackInput with | Ok success -> switchFn success | Error failure -> Error failure // カリー化される前のbind関数 let bind switchFn twoTrackInput = match twoTrackInput with | Ok success -> switchFn success | Error failure -> Error failure // Resultを受け取るmap関数 let map f aResult = match aResult with | Ok success -> Ok (f success) | Error failure -> Error failure
- スイッチ関数を2トラック関数に変換するアダプターは、関数型プログラミングのツールキットの中でも非常に重要
- 合成と型チェック
- 成功パスでは、ステップの出力型が次のステップの入力型と一致する限り、トラックに沿って変更できる
- FunctionAとFunctionCは型が異なるためbindを使っても直接合成はできない
-
Result型の合成と型チェック
type FunctionA = Apple -> Result<Bananas, ...> type FunctionB = Bananas -> Result<Cherries, ...> type FunctionC = Cherries -> Result<Lemon, ...> let functionA : FunctionA = ... let functionB : FunctionB = ... let functionC : FunctionC = ... let functionABC input = input |> functionA |> Result.bind functionB |> Result.bind functionC
- 成功パスでは、ステップの出力型が次のステップの入力型と一致する限り、トラックに沿って変更できる
- 共通のエラー型に変換する
- 成功パスと違って、エラートラックではトラックに沿ってずっと同じエラー型を持つ
- 互換性のある共通の型を作成して、mapErrorで変換して合成する
-
mapErrorで合成する例
let mapError f aResult = match aResult with | Ok success -> Ok success | Error failure -> Error (f failure) type FunctionA = Apple -> Result<Bananas, AppleError> type FunctionB = Bananas -> Result<Cherries, BananasError> type FruitError = | AppleErrorCase of AppleError | BananaErrorCase of BananaError // functionAのResult型をFruitErrorに変換する let functionA : FunctionA = ... let functionAWithFruitError input = input |> functionA |> Result.mapError (fun appleError -> AppleErrorCase appleError) // さらに簡略化した例 let functionAWithFruitError input = input |> functionA |> Result.mapError AppleErrorCase // 関数Aの型 Apple -> Result<Bananas, AppleError> // functionAWithFruitErrorの型 Apple -> Result<Bananas, FruitError> // "FruitError"を使用するようにfunctionBを変換する let functionBWithFruitError input = input |> functionB |> Result.mapError BananaErrorCase // 新しい関数は"bind"で合成できる let functionAB input = input |> functionAWithFruitError |> Result.bind functionBWithFruitError // 組み合わせたfunctionABのシグネチャ val functionAB : Apple -> Result<Cherries, FruitError>
- 成功パスと違って、エラートラックではトラックに沿ってずっと同じエラー型を持つ
- Resultを出力する関数は2つの分岐を作る
パイプラインでのbindとmapの使用
- パイプラインでのbindとmapの使用
- Resultに注目して、Asyncエフェクトやサービスの依存関係はいったん無視して再実装してみる
- 新バージョンのワークフローパイプラインの利点
- パイプラインの各関数はどれもエラーを生成する可能性があるが、エラーはシグネチャから推測できる
- 関数は個別にテストできるため予期しない動作が発生しないと確信できる
- 2トラックモデルを使用しているので、どこかのステップでエラーが発生すると残りの関数がスキップされる
- 特別なif分岐やtry/catchブロックは存在しない
-
Resultに注目して再実装した例
type ValidateOrder = UnvalidatedOrder // 入力 -> Result<ValidatedOrder, ValidationError> // 出力 type PriceOrder = ValidatedOrder // 入力 -> Result<PricedOrder, PricingError> // 出力 type AcknowledgeOrder = PricedOrder // 入力 -> OrderAcknowledgmentSent option // 出力 type CreateEvents = PricedOrder // 入力 -> OrderAcknowledgmentSent option // 入力(前ステップのイベント) -> PlaceOrderEvent list // 出力 // ValidateOrderとPriceOrderを合成する // まず共通のエラー型を作成する type PlaceOrderError = | Validation of ValidationError | Pricing of PricingError // mapErrorを使用してエラー型を変換する let validateOrderAdapted input = input |> validateOrder // 元の関数 |> Result.mapError PlaceOrderError.Validation let priceOrderAdapted input = input |> priceOrder // 元の関数 |> Result.mapError PlaceOrderError.Pricing // 最終的にbindを使って合成する let placeOrder unvalidatedOrder = unvalidatedOrder |> validateOrderAdapted // 変換後の関数 |> Result.bind priceOrderAdapted // 変換後の関数 // 1トラックの関数はResult.mapを使用して合成する let placeOrder unvalidatedOrder = unvalidatedOrder |> validateOrderAdapted |> Result.bind priceOrderAdapted |> Result.map acknowledgeOrder // mapを使って2トラックに変換する |> Result.map createEvents // 同じく2トラックに変換する // placeOrder関数のシグネチャ UnvalidatedOrder -> Result<PlaceOrderEvent list, PlaceOrderError>
- まだ実際にはコンパイルできない
- acknowledgeOrderの出力がcreateEventsの入力と一致していないから
- まだ実際にはコンパイルできない
他の種類の関数を2トラックモデルに適合させる
- 他の種類の関数を2トラックモデルに適合させる
- 2つの関数に着目する
- 例外を投げる関数
- 何も返さない行き止まりの関数
- 例外を投げる関数の処理
- 基本的には多くの例外はドメイン設計の一部ではなく、トップレベル以外では補足する必要はない
- では、もし例外をドメインの一部として扱うとしたら?
- 例: リモートサービスからのタイムアウトをトラップしてRemoteServiceErrorに変換したい
- 例外を発生させる関数をResultを返す関数に変換するアダプターブロックを作成する
-
// エラーを起こしたサービスを追跡するためのServiceInfoを作成する type ServiceInfo = { Name : string Endpoint : Url } // エラー型を定義する type RemoteServiceError = { Service : ServiceInfo Exception : System.Exception } // 例外を投げるサービスをResultを返すサービスに変換する // アダプターブロックを作成する(ドメインに関連する例外のみキャッチする) let serviceExceptionAdapter serviceInfo serviceFn x = try Ok (serviceFn x) with | :? TimeoutException as ex -> Error { Service = serviceInfo; Exception = ex } | :? AuthorizationException as ex -> Error { Service = serviceInfo; Exception = ex } // 実際のサービスの例 let serviceInfo = { Name = "AddressCheckingService" Endpoint = ... } // 例外を発生させるサービス let checkAddressExists address = ... // Resultを返すサービス(Resultを返すため"R"をつけている) let checkAddressExistsR address = // サービスを適合させる let adaptedService = serviceExceptionAdapter serviceInfo checkAddressExists // サービスを呼び出す address |> adaptedService |> Result.mapError RemoteService // PlaceOrderError型 // 新しいサービスのシグチャ checkAddressExistsR : UnvalidatedAddress -> Result<CheckedAddress, RemoteServiceError> // パイプラインで使用するためにケースを追加する type PlaceOrderError = | Validation of ValidationError | Pricing of PricingError | RemoteServiceError of RemoteServiceError // 追加
- 行き止まりの関数の処理
- 行き止まりまたは打ちっぱなしの関数と呼ばれる
- 入力を受け取っても出力を返さない関数
- 何らかの方法でI/Oに書き込んでいる
- DBへの書き込み、キューへの投稿など
- 2トラックパイプラインで動作させるためには、さらに別のアダプターブロックが必要
- パススルー関数が必要
- 受け取った入力で行き止まりの関数を呼び出してから、元の入力を返す仕組み
-
行き止まりの関数とパススルー関数の例
// ('a -> unit) -> ('a -> 'a) let tee f x = f X x // ('a -> unit) -> (Result<'a, 'error> -> Result<'a, 'error>) let adaptDeadEnd f = Result.map (tee f)
- パススルー関数が必要
- 行き止まりまたは打ちっぱなしの関数と呼ばれる
- コンピュテーション式で暮らしを楽にする
- 条件分岐やループと組み合わせたり、深くネストしているResult関数を扱ったりする場合にどうするか
- コンピュテーション式はbindのわずらわしい部分を裏に隠す
-
let!
で結果をアンラップして内部の値を取得している- 通常の値として渡すことができる
- mapErrorによって共通の型に持ち上げる
- まるでResultを使っていないかのようなコードにすることができる
-
-
コンピュテーション式の使用例
// コンピュテーション式を使用しない場合 let placeOrder unvalidatedOrder = unvalidatedOrder |> validateOrderAdapted |> Result.bind priceOrderAdapted |> Result.map acknowledgeOrder |> Result.map createEvents // コンピュテーション式を使用する場合 let placeOrder unvalidatedOrder = result { let! validatedOrder = validateOrder unvalidatedOrder |> Result.mapError PlaceOrderError.Validation let! pricedOrder = priceOrder validatedOrder |> Result.mapError PlaceOrderError.Pricing let acknowledgmentOption = acknowledgeOrder pricedOrder let events = createEvents pricedOrder acknowledgmentOption return events }
- コンピュテーション式の合成
- コンピュテーション式の魅力の1つは合成可能であること
-
コンピュテーション式の合成
let validateOrder input = result { let! validatedOrder = ... return validatedOrder } let priceOrder input = result { let! pricedOrder = ... return pricedOrder } // より大きなresult式で使用できる let placeOrder unvalidatedOrder = result { let! validatedOrder = validateOrder unvalidatedOrder let! pricedOrder = priceOrder validatedOrder ... return ... }
- Resultで注文を検証する
-
ヘルパー関数がResultを返すようになった場合
// すべてのヘルパー関数がResultを返すようになっても、 // コンピュテーション式であればプレーンな値として扱うことができる let validateOrder : ValidateOrder = fun checkProductCodeExists checkAddressExists unvalidatedOrder -> result { let! orderId = unvalidatedOrder.OrderId |> OrderId.create |> Result.mapError ValidationError let! customerInfo = unvalidatedOrder.CustomerInfo |> toCustomerInfo let! shippingAddress = ... let! billingAddress = ... let! lines = ... let validatedOrder : ValidatedOrder = { OrderId = orderId CustomerInfo = customerInfo ShippingAddress = shippingAddress BillingAddress = billingAddress Lines = lines } return validatedOrder }
-
- Resultのリストを扱う
- mapではResultのリストが返ってきてしまう
- Resultのリストをループして、1つでも失敗があれば結果をエラーとするヘルパー関数を作成する
-
Resultのリストを取り扱う例
/// Result<item>をResult<list>>に前置する let prepend firstR restR = match firstR, restR with | Ok first, Ok rest -> Ok (first :: rest) | Error err1, Ok _ -> Error err1 | Ok _, Error err2 -> Error err2 | Error err1, Error _ -> Error err1 let sequence aListOfResults = let initialValue = Ok [] // Result内の空のリスト // リストを逆順でループしながら、 // 要素を初期値に前置していく List.foldBack prepend aListOfResults initialValue // 試しに使ってみる例 type IntOrError = Result<int, string> let listOfSuccesses : IntOrError list = [Ok 1; Ok 2] let successResult = Result.sequence listOfSuccesses // Ok [1, 2] let listOfErrors : IntOrError list = [ Error "bad"; Error "terrible"] let errorResult = Result.sequence listOfErrors // Error "bad" // validatedOrderを再構築する let validateOrder : ValidateOrder = fun checkProductCodeExists checkAddressExists unvalidatedOrder -> result { let! orderId = ... let! customerInfo = ... let! shippingAddress = ... let! billingAddress = ... let! lines = unvalidatedOrder.Lines |> List.map (toValidatedOrderLine checkProductCodeExists) |> Result.sequence // Resultのリストを単一のResultに変換する(mapとsequenceはtraverseでまとめられる) let validatedOrder : ValidatedOrder = { OrderId = orderId CustomerInfo = customerInfo ShippingAddress = shippingAddress BillingAddress = billingAddress Lines = lines } return validatedOrder } // ワークフローを再構築する let placeOrder : PlaceOrder = fun unvalidatedOrder -> result { let! validatedOrder = validateOrder checkProductCodeExists checkAddressExists unvalidatedOrder |> Result.mapError PlaceOrderError.Validation let! pricedOrder = priceOrder getProductPrice validatedOrder |> Result.mapError PlaceOrderError.Pricing let acknowledgmentOption = ... let events = ... return events }
- 2つの関数に着目する
モナドなど
- モナドなど
- モナドとは
- プログラミングパターンの1種で、モナディックな関数を直列につなげることができるもの
- モナディックとは
- 通常の値を受け取り、ある拡張がされた値を返す関数
- Result型でラップされたものを返すスイッチ関数など
- 技術的なモナドの定義
- データ構造
- 関連するいくつかの関数
- return(pure): 通常の値をモナディック型に変える関数。ResultだとOkコンストラクタにあたる
- bind(flatMap): モナディック関数を連鎖させられる関数
- 関数の動作ルール
- 実装が正しく、おかしなことがないか保証するためのシンプルな指針
- アプリカティブを使って並列に合成する
- モナディックな値を並列に組み合わせることができる
- 検証の場合、最初のエラーだけでなくすべてのエラーを結合するために使う
- モナディックな値を並列に組み合わせることができる
- 専門用語
- エラー処理では、bind関数はResultを生成する関数を2トラックの関数に変換して、直列に繋げることを可能とする
- map関数は1トラック関数を2トラック関数に変換する
- モナディックな合成アプローチとは、bindを用いて関数を直列に連結すること
- アプリカティブな合成アプローチとは、結果を並列に組み合わせること
- モナドとは
非同期エフェクトの追加
- 非同期エフェクトの追加
- AsyncResult型に合わせて、asyncResultコンピュテーション式を定義する
-
asyncResultコンピュテーション式を使用した例
let validateOrder : ValidateOrder = fun checkProductCodeExists checkAddressExists unvalidatedOrder -> asyncResult { let! orderId = unvalidatedOrder.OrderId |> OrderId.create |> Result.mapError ValidationError |> AsyncResult.ofResult // ResultをAsyncResultに持ち上げる let! customerInfo = unvalidatedOrder.CustomerInfo |> toCustomerInfo |> AsyncResult.ofResult let! checkedShippingAddress = unvalidatedOrder.ShippingAddress |> toCheckedAddress checkAddressExists let! shippingAddress = checkedShippingAddress |> toAddress |> AsyncResult.ofResult let! billingAddress = ... let! lines = unvalidatedOrder.Lines |> List.map (toValidatedOrderLine checkProductCodeExists) |> Result.sequence // Resultのリストを単一のResultに変換する |> AsyncResult.ofResult let validatedOrder : ValidatedOrder = { OrderId = orderId CustomerInfo = customerInfo ShippingAddress = shippingAddress BillingAddress = billingAddress Lines = lines } return validatedOrder } type CheckAddressExists = UnvalidatedAddress -> AsyncResult<CheckedAddress, AddressValidationError> /// checkAddressExistsを呼び出し、エラーをValidationErrorに変換する let toCheckedAddress (checkAddress: CheckAddressExists) address = address |> checkAddress |> AsyncResult.mapError (fun addrError -> match addrError with | AddressNotFound -> ValidationError "Address not found" | InvalidFormat -> ValidationError "Address has bad format" ) let placeOrder : PlaceOrder = fun unvalidatedOrder -> asyncResult { let! validatedOrder = validateOrder checkProductExists checkAddressExists unvalidatedOrder |> AsyncResult.mapError PlaceOrderError.Validation let! pricedOrder = priceOrder getProductPrice validatedOrder |> AsyncResult.ofResult |> AsyncResult.mapError PlaceOrderError.Pricing let acknowledgmentOption = ... let events = ... return events }
まとめ
- まとめ
- 型変換は面倒だが、これによってすべてのパイプラインコンポーネントが問題なく連携して動作するという信頼感が得られる
03へ続く
Discussion