RustでClean Architectureを実装してみる
はじめに
RustでWebアプリケーションのGraphQLバックエンドを実装してみました。その中で、できるだけClean Architectureに沿うように実装してみたので、得られた知見を公開してみたいと思います。
資料に基づきできるだけ正確な記述を目指していますが、誤りもあるかもしれません。また実装から少し時間を空けて執筆しているので、忘れている部分も多く不正確なことが書いてあるかもしれません。
Clean Architectureとは
以下のブログでRobert C. Martin(通称Uncle Bob)によって提唱されたアーキテクチャです。
その後本人により書籍も出版されました。日本語にも翻訳されています。
歴史について簡単に
多層アーキテクチャ (Multitier architecture) というものはかなり昔から考えられていたようです。初出についてはよくわからないのですが、Eric Evansの『Domain-Driven Design』には記述が存在するので、少なくとも出版年の2003年には知られていたことになります。どういうものかというと、ソースコードをPresentation, Application, Domain, Infrastructureのような複数の層に分割するアーキテクチャです。層の数には決まりはなく、増えたり減ったりすることもあります。
2005年にはAlistair CockburnによりHexagonal Architectureが提唱されます。(記事の公開日は2005年のようなのですが、こちらの記事によれば、2002年にはすでに考案されていたという話もあります。)このアーキテクチャの言おうとしているところは、以下のようなことです。ソフトウェアを一次元的な多層(Presentationが上でInfrastructureが下)に分割するのではなく、境界の内と外とに分けます。図の六角形が境界にあたりますが、六という数字に意味はありません。これによって、従来の多層アーキテクチャにおいては一番上と一番下の層に分断されていたUIとデータベースは、境界の外という同じ場所に配置されます。ビジネスロジックは境界の中に保護しておきます。UIとデータベースといった境界の外にあるものは実装の詳細であり変化しうるものとみなし、「ポート」を通じて差し替え可能にしておきます。
2008年にはJeffrey PalermoによるOnion Architectureが登場します。これは内と外の概念しかなかったHexagonal Architectureに対し、さらに層の概念を導入したものだと思っています。
そして2012年にClean Architectureが登場します。これは既に見てきたHexiagonal ArchitectureやOnion Architectureといったアーキテクチャに関する多数のアイデアに共通する概念を抜き出し、統一的に説明しようとするものだとみなしてよいでしょう。以下の記事も大変参考になります。
開発したアプリケーションについて
自分のための蔵書管理アプリケーションを開発しました。フロントエンドはReact、バックエンドはRustでGraphQLを実装しています。本稿で取り扱うのはこのバックエンドサーバーです。
フロントエンドのソースコードはこちらです。READMEからデモアプリケーションにアクセスできるので、触って雰囲気をつかんでもらえればと思います。
バックエンドのソースコードはこちらです。こちらが今回の話の題材です。
実装について
実装方針
調べてみると、JavaやGoによる実装例はいくつか出てきます。これらを参考にしました。
ではRustで実装するにはどのようにすればよいのでしょうか。結論から言えば、わりとJavaと似たような形で実装できます。Interfaceの代わりにTraitを使えばよいです。
具体的に見ていきましょう。
DI
それなりに面倒な問題です。JavaでSpring Bootを使って実装するのであればDIコンテナに任せられます。ですがRust本体にはそのような仕組みはありません。DIに関するライブラリもそれほど流行っているものはなさそうだったので、今回は手動で行うことにしました。src/dependency_injection.rs で手動で依存性の注入を行っています。注入を終えたインスタンスを返り値で返しています。
やってみると手動でもなんとかなるものです。Spring BootでよくあるError creating bean with name
のようなエラーに悩まされたり、Beanが複雑なルールで切り替わったりしないので、むしろシンプルでわかりやすいかもしれません。
モジュール構成
主に4つのモジュールが存在します。
- Infrastructure
- Domain
- Use Case
- Presentational
有名な同心円の図に当てはめると、中心にDomainがあり、中心から2番目の円にUse Caseがあり、一番外側の円にInfrastructureとPresentationalがあるというイメージです。
Domain
最重要ビジネスルールを記述するところです。Clean Architecture本だと20章「エンティティ」です。コンピューターで計算しても手で計算しても変わらないようなルールを記述します。
まずはドメインオブジェクトを実装します。
今回はgetter, setterを導入しました。publicにして内部に直接アクセス可能にしてしまうと、バリデーションルールを無視してフィールドを書き換えてしまう可能性があるからです。ただし手動で実装するのは面倒だったので、getsetクレートを使いました。
ただしRustとgetter, setterは相性が悪いと言うような話もあるようです。問題になる場合もあるようなので注意した方が良さそうです。
Result型で異常側を表すDomainError型も定義しておきます。
thiserrorとanyhowを使って実装の手間を削減しています。
また、リポジトリのTraitも用意しておきます。
auto_mockを使ってモックを自動生成できるようにしておきます。
またasyncを使っているので、async-traitが必要です。これは現時点でのRustには、Traitにasyncなメソッドが定義できないという制約があるためです。以下の記事に詳しく理由が書いてあります。
async-traitを使えば簡単にTraitに対してasyncなメソッドが書けます。しかし内部的にBoxを使って動的ディスパッチが行われるので、Traitを経由しない場合と比べてパフォーマンスは落ちてしまうようです。とはいえよほどの場合でもない限り気になるものではないでしょう。
このTraitを実際に実装するのはInfrastructureモジュールです。
値オブジェクトおよびNew Typeについて
今回はすべてのフィールドをすべてに対して、それぞれ値オブジェクト(Value Object)を定義してみました。具体例はBookId
とかBookTitle
とかです。RustにおいてはNew Typeパターンと呼ばれるものにも近いです。
全てに対して固有の型を定義するのはかなりのコストがかかります。振り返ってみると、この方式はやり過ぎだったかもしれません。Id系の型だけ定義しておけば他はプリミティブでも十分だった気がします。変更はsetter経由で行い、そこでバリデーションが行えるという前提のもとですが。
なお、近頃一部で話題になっていた値オブジェクト論争には立ち入らないのであしからず。
Use Case
アプリケーション固有のビジネスロジックを実装するところです。Clean Architecture本だと20章「ユースケース」あたりです。
UseCaseという名前でTraitを作成します。
InteractorがそのTraitを実装します。コンストラクタインジェクションのようなことをやって、Repositoryを注入していますね。Rustだと抽象的なTraitに依存する場合、必ずジェネリクスを使用する必要があります。このあたりはJavaと異なるところです。
Domainモジュールと疎結合にするため、入出力の型としてDTOを新たに定義します。
型変換が頻繁に発生するので、From<Book>
を実装しておきます。
またこの層のエラーを表す型を定義しておきます。
?演算子が使えるように、From
を実装しておきます。
PresenterやOutput Portについて
Clean Architectureを実装しようとして多くの人が悩むところに、PresenterやUse Case Output Port周りの話があると思います。この図の右下のやつです。
ここについてはいろいろな議論があるのですが、私はPresenterは使いませんでした。素直にUseCaseで値を返し、その値をControllerで使うようにしています。
先人が色々と書いているので、興味があれば読んでみると面白いと思います。
Facade
CreateやUpdateなどで細かめにUseCase Traitを定義しています。
そのため結果として、かなりUseCaseやInteractorの数が多くなってしまいます。
悩んだ末、Facadeとしてある程度まとまりのあるUseCaseを別途定義してみました。
これがあると、Presentationモジュールのジェネリクスの数が減って少しきれいになります。
結果として、以下でMessage Busとして紹介されている解決方法とほぼ同じになりました。
Infrastructure
ここはRepositoryのTraitを実装しているだけです。
このあたりで悩んだ話は以下にまとめてあるので、こちらも読んでみてください。
Presentatioin
HTTP APIとしてのインターフェースを実装したり、GraphQLの処理を実装するところです。
ControllerはGraphQLの実装に処理を任せているだけです。
GraphQLではQueryとMutationという2つの大きく2種類の処理があります。Queryが参照系で、Mutationが更新系です。実装方針はほぼ同じなのでQueryだけ見ます。
QueryはUseCaseに処理を任せているだけです。
あとはGraphQL用の型を定義する必要もあります。
Data Loader
実は、実装とClean Architectureの原則と折り合いがつかないところがありました。
N+1問題を解決するためのData Loaderという仕組みがあります。
今回は本と著者の関係が多対多になるので、必要になりました。ここの実装はこんな感じです。
これを使う側がこちらです。
少し分かりづらいのですが、このQIという型はこちらです。
Traitではなく、具体的な型に依存してしまっています。本当はPresentationはすべてUseCaseのTraitのみに依存させて、DIで具体的な型を注入したかったのですが、ここだけはうまく行きませんでした。async-graphql側の制約で、Genericにしてしまうと噛み合わせが悪くなってしまいます。ここは諦めました。
その他
Cloneの多用
関数の引数に所有権を渡すか参照を渡すか、設計を適当に行ったところ、全体的に不必要なcloneを多用するコードになってしまっています。ここは今後見直して行きたいと思っています。
実装してみた感想
Pros
- フレームワークと粗結合になる
- actix-teb, async-graphql, sqlxといったフレームワーク・ライブラリと疎結合になります。
- 今後actixからaxumに移行してみたいと思っていますが、そんなに苦労せず移行できそうな気がしています。
- それぞれのモジュールの責任範囲が明確になる
Cons
- 実装が冗長になる
- Traitを挟んだりDTOに詰め替えたりする分、実装量は増えます。
- 多少パフォーマンスが犠牲になる
- async-traitの動的ディスパッチや、DTOへの詰め直しにより、多少のオーバーヘッドが発生します。
終わりに
まだバグが残っているのも認識しており、他にも問題があるかもしれませんが、何かの参考になれば幸いです。
Discussion