WebFlux.fnとClean Architectureで実装を試みる
動機
仕事で使うあてがあるわけではないのですが気になってWebFlux.fn(WebFluxのFunctional Endpoint)を少し触ったのを機に、もう少しそれっぽい実装をしてみようと思うに至りました。
せっかくなので、Clean Architecture(クリーンアーキテクチャ)で有名なあの円形の図に倣った形で、DDD(ドメイン駆動設計)っぽい実装にも挑戦してみることにしました。
DDDとかClean Architectureは特に実装に依るものではなく設計方法論とういことは十二分に承知したうえで、字面だけを見て理解した内容を実装に落としてみることにしました。
無論、初めてだらけなので結構頻繁に迷子になりました。本当は自分なりの答えに行き着ければよかったのですが今のところ気付きを得られた程度で、まだまだ熟考が必要と感じています。
自分の悩みが誰かの気付きになればと思い、一区切りついたので記事に起こしています。
絵をかいて視覚的に分かりやすいようにしたいと思う反面、それはそれで手間がかかるのでひとまず文字で表現しています。あしからず。
参考
当然先人の知恵をたくさんお借りして進めました。DDD本、IDDD本、CleanArchitecture本などは一通り目は通していますが、主に以下を参考にさせていただきました。
実践クリーンアーキテクチャ with Java │ nrslib
クリーンアーキテクチャ完全に理解した · GitHub
世界一わかりやすいClean Architecture - nuits.jp blog
BLOG.IK.AM
https://www.youtube.com/watch?v=Nqv0JeyaZmg
ソース
ソースコードは以下に置いています。いくつか免責事項(?)があります。
github.com
- この記事をあげた後に改編する可能性があり、記事の内容と齟齬があるかもしれませんがご容赦ください。
- しばらく時間が経って気まぐれでリポジトリを削除してしまいコードを見れなくなることがあるかもしれませんがご容赦ください。
悩み1:パッケージどう切るか問題
Javaパッケージ構成をどのようにするかですが、極力無駄な依存を生まないようにパッケージプライベートをうまく使える構成にしたいところです。
あとはClean ArchitectureやDDDとの関連が分かるようにある程度名前は合わせておきたいところです。
そんなこんなで以下のような構成にしてみました。
global // レイヤに跨るもの。Utilとか。 drivers // フレームワークの設定系。Configとか。 adapters // 外界とアプリケーションをつなぐ部分。アダプター層。 ┗ handlers // Function Endpointのハンドラ。外界から入ってくるほう。 ┗ XxxHandler // ハンドラクラス ┗ XxxPresenter // プレゼンタ ┗ XxxRequest // リクエスト(DTO) ┗ XxxResponse // レスポンス(DTO) ┗ gateways // 外界へ出ていくほう。 ┗ db // DBアクセスコード。RepositoryやQueryの実装。 ┗ XxxDatabaseClient // DBアクセスコードの実装。 ┗ XxxEntity // ORM用クラス(DTO) ┗ XxxReactiveRepository // ORM用クラス ┗ api // API呼出しコード。RepositoryやQueryの実装。 usecases // アプリケーション層。 ┗ XxxUseCase // ユースケースインタフェース ┗ XxxUseInteractor // インタラクタ(ユースケースの実装) ┗ XxxUseCaseInput // ユースケースの入力(DTO) ┗ XxxUseCaseOutput // ユースケースの出力(DTO) domain // ドメイン層。 ┗ model // ドメインモデル。 ┗ Xxx // エンティティクラスや値クラス ┗ XxxRepository // リポジトリ(インタフェース) queries // クエリサービス。 ┗ XxxQuery // クエリインタフェース ┗ XxxQueryRequest // クエリリクエスト(DTO) ┗ XxxQueryResponse // クエリレスポンス(DTO)
だいたいClean Architectureの名前に倣ったけれど、ドメイン層だけは「entity」を使わずに「domain.model」にしました。Entity(エンティティ)はERモデルのエンティティとか、JPAのエンティティとか、DDDのエンティティとか、他の用語と被るので分かりにくくなりそう、というのがその理由です。
アダプター層は外界のパラメータをユースケース(アプリケーション層)が理解できる形に変換することを想定しています。
ユースケース(アプリケーション層)は、外界の表現方法に依らない処理を実装することを想定しています。つまり、インタフェースがHTTPであっても、CSVファイルの読み込みであっても、アプリケーション層の処理は分からないことを想定しています。
あと、やっぱり感覚的に更新系と参照系では異なる方式が要求される気がするので、CQRSの考えに倣ってクエリサービスは別立てしています。一応、位置づけとしてはRepositoryと同じで、ユースケースからインタフェースを介して呼び出されることを想定しています。
悩み2:presenterどうするか問題
Clean Architectureの図に倣った実装を試みるときにみなさん悩まれることのようのです。Webアプリケーションフレームワークが生のHTTPハンドラを隠蔽しており、ControllerやHandlerクラスから何かしらを戻り値としてreturnしないといけないので、「ユースケースからプレゼンターのインタフェースを読んで描画して処理終了!」みたいな実装は難しいわけです。
今回はpresenterの責務としては「ユースケース(アプリケーション層)の返す値を外界に合わせた表現に変換する」こととしたときに、一応クラスとしては用意しつつもHandlerの中で呼び出してレスポンスクラスを生成する使い方にしました。
また、特に単票画面で登録や更新をするときのように、リクエストで飛んでくる入力パラメータとレスポンスで返す出力パラメータは似たようなものになることが往々にしてあります。そこで、presenterの責務に、先述のユースケース→外界のみならず「外界の表現をユースケース(アプリケーション層)の入力に変換する」も追加し、いわゆる変換クラス(ConverterとかTranslatorみたいなものと同義)と位置付けることにしました。
悩み3:ドメインモデルクラスどこまで定義するか問題
極論的には、プログラミング言語に含まれるネイティブな型(intとかStringとかDateとか)を使わずにすべてドメインモデルとして定義すべし、みたいなのがあるようです。そうすることでドメインモデルの扱いにおいて型安全になり、カプセル化によるドメイン知識の漏洩も防げるのかなと思います。
さて、いざやろうとするとどこまで厳密に従うべきか悩みます。今回はお勉強なのでひとまず全部クラスとして定義してみました。プロパティやメソッドの引数などに曖昧さがなくなって意味がより明確になったように思うので、このくらいのアプリであれば全面採用しても問題ないように思っています。が、一人で作っている分には全部把握しているので、生産性や品質面で顕著な効果を感じられていないのが悩みどころです。より複雑になったり、時間を経て保守しないいけないとなったときには効きそうに思います。
悩み4:ドメインモデルクラスどこまで露出するか問題
Clean Architectureに倣った実装において真に重要なことは「依存の方向が外から中へ向くこと」一点だそうです。としたときにドメインモデルを、アプリケーション層を飛ばしてアダプター層にまでドメインモデルのまま見せるかどうか。
アダプタ層は入力のI/Fによって変わる部分、アプリケーション層より中は変わらない部分、と位置付けた時に、アダプタ層がアプリケーション層とドメイン層の両方を意識して実装しないといけないのは思考の負荷が高いように思いました。あくまで、アプリケーション層との境界だけを意識するほうがシンプルなのかな、と。
というわけで、原則、層を飛び越えた露出はしない方針にしました。
悩み5:バリデーションどこでどうやるか問題
一番悩んで未だにモヤモヤしているのが、入力値のバリデーションをどこで行うか?エラーをどのように返すか?というあたりです。
アダプター層のパラメータとアプリケーション層のパラメータとドメイン層のパラメータと、それぞれでチェックが必要になるわけですが、先ほどの露出の問題と同じく、多層を意識した設計はしたくないのです。
なのでドメインモデルの整合性を保つためのチェックはドメイン層に実装したいわけです。
一方でユースケースごとに異なるバリデーションはアプリケーション層に実装したいです。
外界からは基本的には「文字列」として飛んでくるパラメータをアプリケーション層に渡せる型に変換するのはアダプター層の責務としたときに、正しい型に変換できることのチェックはアダプター層で実装する必要があります。
素直に実装すると、最初にアダプター層のチェック、次にユースケース層のチェック、最後にドメイン層のチェックになります。
この時、層ごとにエラーを返すと、クライアントは何度もエラーの訂正をしないといけなくなるので、パラメータごとに内層に渡せる形であれば内層に渡してチェックをし、全パラメータの一通りのチェックを通した結果をクライアントには返したいわけです。そうすればクライアントはまとめてエラー修正をすることができます。
「パラメータごとに」ってのがポイントで、ある項目はアダプター層のチェックで引っかかったとしたらそれ以上のチェックはしない(できない)、その他はアプリケーション層でチェックをする、という実装が必要になります。
あとは、クライアントにはどのパラメータがエラーだったかを返したいわけですが、パラメータ名を知っているのはアダプター層だけなので、内層に渡ったものはあくまでそれぞれの層での属性名でしか判別できなくなります。
というのでかなり悶々として試行錯誤を繰り返し、今の実装に落ち着きました。なお、属性名から外界のパラメータ名への変換は実装できていません。
上記のような経緯なので、バリデーションもBean Validationではうまくはまりそうにないと判断して自作しています。
悩み6:例外ハンドリングをどこでどうやるか問題
パラメータ名への変換が実装できていない理由として、例外ハンドリングをどこでどうやるか、というのでこれまた悶々としたためです。
WebFlux(Reactor)を使ってMonoやFluxで実装をした場合、基本は遅延実行になるわけですが、手続き型に慣れ親しんだ身からすると「書いてあるところに来たタイミングで実行されるわけではない」という振る舞いに相当違和感を感じました。本来なら全体を「try~catch」で囲ってあげれば例外ハンドリングができるわけですが、実際に実行されるのはそのtry節の外だったりするので、「onErrorReturn」や「onErrorResume」をうまく使って補足する必要があるようです。※この辺は理解が怪しいです。観測した感じ、そんな感じでした。
先ほどの入力チェックでエラーになった時には例外を投げるようにしたわけですが、これを補足してレスポンスコード「400(Bad Request)」で返すのをどこでやるのがよいのかでこれまた悶々と試行錯誤を繰り返しました。
最初、Handlerの関数のチェーンの中でやってみたところ、Monoを返すパターンはうまく実装できましたが、Fluxを返すパターンでうまいやり方が分かりませんでした。
// Monoを返すパターン // mapを使ってServerResponseに渡せる .map(res -> ServerResponse.ok().bodyValue(res)); // Fluxを返すパターン // mapを使って渡せない(渡し方が分からない) ServerResponse.ok().body(usecase.handle(in), ResponseType.class);
なので、アダプター層より外層のフレームワーク層に位置するところで「DefaultErrorWebExceptionHandler」を継承してカスタマイズした独自例外ハンドラを実装して対処しています。
アダプター層より外層まで伝播させてしまったが故に外界からのパラメータ名が何か分からず、属性名から外界のパラメータ名への変換が実装できてないのです。
悩み7:ORMどうするか問題
今回、experimentalではありますが「spring-boot-starter-data-r2dbc」を使っています。JPARepositoryのように、インタフェースを定義するとバイトコードエンハンスメント(?)で実装が生成されるようですが、マップするクラスをドメインクラスやクエリクラスをそのまま使わず、それ専用に用意してこれまた型変換するようにしました。極力フレームワークに引きずられる実装コードを少なくしたかった、というのがその理由です。
型変換するので、ドメイン層のRepositoyにそのままReactiveRepositoryを継承させることができなくなります。なのでDBアクセス専用のリポジトリを別に定義して、Repositoryの実装にコンポジット&インジェクションするようにしました。
リポジトリに渡されるドメインモデルをマッパークラスに変換するのはアダプター層においたRepositoryの実装でやることになります。ここは、ドメインモデルがアダプター層に露出していることになりました。
悩み8:変換多すぎ問題
結果として、色んなところで変換処理が必要になりました。しかも、単純なプロパティコピーではない構造の変換を含んでいますので、いちいち手組が必要になります。これは規模が大きくなると結構大変になる系のやつです。
総括
長々と悩みの吐露にお付き合いくださった方はどうもありがとうございました。
個人的な感想として、
- 関数型の実装を手続き型しか分からない人たちに理解してバグのない実装をしてもらうことは至難の業な気がする。
- WebFluxで得られるスケーラビリティが本当に必要になった時に本当に必要な個所に適用するだけなら何とかできそうな気がする。
- DDDやClean Architectureの考え方を実装に落とすにもまだまだ修行が必要。
- 変換とか多いし、単純なCRUDのくせにやたらクラス多いし(何も考えずシンプルにやりたいことだけ実装するならこんなにクラスはいらない)で、大規模化には苦労しそうではあるけど、実装をしていて意図が明確なので見通しは良くなったと感じる。生産性は下がるかもしれないが、保守性は上がったと信じたい。
といった感じです。
引き続き研鑽したいと思います。