「ミクロレベルでは関数型プログラミングを理解しているし、おもちゃのプログラムも書いたことがあるけれど、実際のデータや本格的なエラー処理などを含む完全なアプリケーションをどのように書けばいいのでしょうか?」
これはよくある質問です。そこで、このシリーズの記事では、設計、検証、エラー処理、永続化、依存関係の管理、コードの構成などをカバーして、まさにこの課題に取り組むためのレシピを説明したいと思います。
まず、いくつかのコメントと注意点を挙げます。
- アプリケーション全体ではなく、単一のユースケースに焦点を当てます。必要に応じてコードを拡張する方法は明らかになるはずです。
- これは、特別なトリックや高度な技法を使わない、非常にシンプルなデータフロー指向のレシピとなるでしょう。しかし、初心者にとっては、予測可能な結果を得るための簡単な手順があると役立つでしょう。正解は一つだけだと言うつもりはありません。異なるシナリオでは異なるレシピが必要になりますし、経験を積むにつれ、このレシピは単純すぎて限界があると感じることもあるでしょう。
- オブジェクト指向設計からの移行を容易にするために、「パターン」「サービス」「依存性注入」などの馴染みのある概念を使い、それらが関数型の概念にどう対応するかを説明します。
- このレシピは、意図的にやや命令型です。つまり、明示的なステップバイステップのワークフローを使用します。このアプローチでオブジェクト指向から関数型プログラミングへの移行が楽になることを期待しています。
- ものごとをシンプルに保つため(そして簡単なF#スクリプトから使えるようにするため)、インフラ全体をモック化し、UIには直接触れません。
概要
この連載で取り上げる予定の内容は次のとおりです。
- ユースケースを関数に変換する。この最初の記事では、シンプルなユースケースを検討し、関数型アプローチでどのように実装できるかを見ていきます。
- 小さな関数を組み合わせる。次の記事では、小さな関数をより大きな関数に組み合わせるためのシンプルな比喩について説明します。
- 型駆動設計とエラー型。3番目の記事では、ユースケースに必要な型を構築し、失敗パスのための特別なエラー型の使用について説明します。
- 設定と依存関係の管理。この記事では、すべての関数をどのように接続するかについて説明します。
- バリデーション。この記事では、バリデーションを実装するさまざまな方法と、安全でない外部の世界から型安全の暖かくファジーな世界に変換する方法について説明します。
- インフラストラクチャ。この記事では、ログ記録、外部コードとの連携など、さまざまなインフラストラクチャコンポーネントについて説明します。
- ドメイン層。この記事では、関数型環境でドメイン駆動設計がどのように機能するかを説明します。
- プレゼンテーション層。この記事では、結果とエラーをUIに伝える方法について説明します。
- 変化する要件への対応。この記事では、変化する要件にどのように対処し、それがコードにどのような影響を与えるかについて説明します。
はじめに
非常にシンプルなユースケースを選びましょう。具体的には、Webサービスを通じて顧客情報を更新するケースです。
基本的な要件は次のとおりです。
- ユーザーがデータ(ユーザーID、名前、メールアドレス)を送信します。
- 名前とメールアドレスが有効かどうかを確認します。
- データベース内の適切なユーザーレコードを新しい名前とメールアドレスで更新します。
- メールアドレスが変更された場合、そのアドレスに確認メールを送信します。
- 操作の結果をユーザーに表示します。
これは典型的なデータ中心のユースケースです。ユースケースを開始する要求があり、その要求データがシステムを「流れ」ながら、各ステップで順番に処理されます。 このようなシナリオは企業向けソフトウェアでよく見られるため、例として使用しています。
以下は、さまざまなコンポーネントを示す図です。
しかし、これは「ハッピーパス」のみを示しています。現実はそれほど単純ではありません!データベースにユーザーIDが見つからない場合や、メールアドレスが無効な場合、データベースでエラーが発生した場合はどうなるでしょうか?
問題が起こり得る箇所をすべて示すように図を更新してみましょう。
ユースケースの各ステップで、図に示すようなさまざまなエラーが発生する可能性があります。このシリーズの目標の1つは、これらのエラーをエレガントに処理する方法を説明することです。
関数型思考
ユースケースのステップを理解したところで、関数型アプローチを使ってどのように解決策を設計すればよいでしょうか?
まず、元のユースケースと関数型思考の間のミスマッチに対処する必要があります。
ユースケースでは、通常、リクエスト/レスポンスモデルを考えます。リクエストが送信され、レスポンスが返されます。何か問題が発生した場合、フローは短絡され、レスポンスが「早期に」返されます。
以下は、ユースケースを簡略化したバージョンに基づいて、私が意味することを示す図です。
しかし、関数型モデルでは、関数は入力と出力を持つブラックボックスです。このようになります。
このモデルにユースケースを適合させるにはどうすればよいでしょうか?
前進するフローのみ
まず、関数型のデータフローが前進のみであることを認識する必要があります。Uターンしたり早期に戻ったりすることはできません。
この場合、すべてのエラーをハッピーパスの代替パスとして最後まで前方に送る必要があります。
これを行えば、フロー全体を次のような単一の「ブラックボックス」関数に変換できます。
もちろん、大きな関数の内部を見れば、各ステップに対応する小さな関数が(関数型の言葉で言えば)「合成されて」パイプラインで結合されています。
エラー処理
最後の図では、成功の出力が1つと、エラーの出力が3つあります。これは問題です。関数は1つの出力しか持てず、4つは持てないからです。
これをどう扱えばよいでしょうか?
答えは、共用体型を使うことです。相異なる出力に対応するケースを1つずつ作り、関数全体としては1つの出力しか持たないようにします。
出力として考えられる型定義の例を示します。
type UseCaseResult =
| Success
| ValidationError
| UpdateError
| SmtpError
そして、4つの異なるケースが1つの出力に埋め込まれているのを示すように書き直した図がこちらです。
エラー処理の簡素化
これで問題は解決しますが、フローの各ステップに1つずつエラーケースを設けるのは脆弱で再利用性が低いです。もっと良い方法はないでしょうか?
もちろん、あります。実際に必要なのは2つのケースだけです。ハッピーパス用に1つ、他のすべてのエラーパス用に1つです。次のようになります。
type UseCaseResult =
| Success
| Failure
この型は非常に汎用的で、どんなワークフローにも使えます!実際、この型を使った便利な関数のライブラリを作成できることと、それをあらゆるシナリオで再利用できることをこの後に示します。
とはいえ、一つ注意点があります。現状では結果にデータがまったく含まれておらず、成功/失敗のステータスだけです。実際の成功オブジェクトや失敗オブジェクトを含められるように少し調整する必要があります。ジェネリクス(別名:型パラメータ)を使って、成功型とエラー型を指定しましょう。
これが最終的な、完全に汎用的で再利用可能なバージョンです。
type Result<'TSuccess,'TFailure> =
| Success of 'TSuccess
| Failure of 'TFailure
F#ライブラリには実際に、これとほぼ同じ型が定義されています。Choiceという名前です*。ただし、わかりやすさのため、この記事と次の記事では上で定義したResult
型を引き続き使います。より本格的なコーディングに入る際に再検討しましょう。
* 訳注:現在のライブラリには、Result型も含まれています。Result
は構造体として実装されています。
さて、個々のステップを再確認してみると、各ステップのエラーを単一の「失敗」パスに結合する必要があることがわかります。
これをどのように行うかは、次の記事のトピックになります。
まとめとガイドライン
以上より、次のガイドラインが得られました。
ガイドライン
- 各ユースケースは単一の関数に相当します。
- ユースケース関数は
Success
とFailure
の2つのケースを持つ共用体型を返します。 - ユースケース関数は、データフローの1ステップを表す一連の小さな関数から構築されます。
- 各ステップのエラーは単一のエラーパスに結合されます。