🦀

RustでClean Architectureを実装してみる

2022/09/11に公開

はじめに

RustでWebアプリケーションのGraphQLバックエンドを実装してみました。その中で、できるだけClean Architectureに沿うように実装してみたので、得られた知見を公開してみたいと思います。

資料に基づきできるだけ正確な記述を目指していますが、誤りもあるかもしれません。また実装から少し時間を空けて執筆しているので、忘れている部分も多く不正確なことが書いてあるかもしれません。

Clean Architectureとは

以下のブログでRobert C. Martin(通称Uncle Bob)によって提唱されたアーキテクチャです。

https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html

その後本人により書籍も出版されました。日本語にも翻訳されています。

https://www.kadokawa.co.jp/product/301806000678/

歴史について簡単に

多層アーキテクチャ (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といったアーキテクチャに関する多数のアイデアに共通する概念を抜き出し、統一的に説明しようとするものだとみなしてよいでしょう。以下の記事も大変参考になります。

https://www.nuits.jp/entry/easiest-clean-architecture-2019-09

開発したアプリケーションについて

自分のための蔵書管理アプリケーションを開発しました。フロントエンドはReact、バックエンドはRustでGraphQLを実装しています。本稿で取り扱うのはこのバックエンドサーバーです。

フロントエンドのソースコードはこちらです。READMEからデモアプリケーションにアクセスできるので、触って雰囲気をつかんでもらえればと思います。

https://github.com/hiterm/bookshelf

バックエンドのソースコードはこちらです。こちらが今回の話の題材です。

https://github.com/hiterm/bookshelf-api

実装について

実装方針

調べてみると、JavaやGoによる実装例はいくつか出てきます。これらを参考にしました。

https://nrslib.com/clean-architecture-with-java/
https://tech-blog.optim.co.jp/entry/2019/01/29/173000
https://www.m3tech.blog/entry/2020/02/07/110000

ではRustで実装するにはどのようにすればよいのでしょうか。結論から言えば、わりとJavaと似たような形で実装できます。Interfaceの代わりにTraitを使えばよいです。

具体的に見ていきましょう。

DI

それなりに面倒な問題です。JavaでSpring Bootを使って実装するのであればDIコンテナに任せられます。ですがRust本体にはそのような仕組みはありません。DIに関するライブラリもそれほど流行っているものはなさそうだったので、今回は手動で行うことにしました。src/dependency_injection.rs で手動で依存性の注入を行っています。注入を終えたインスタンスを返り値で返しています。

https://github.com/hiterm/bookshelf-api/blob/cb673ef17d6e81b205dc7b9721212d6b78f40972/src/dependency_injection.rs#L28-L59

やってみると手動でもなんとかなるものです。Spring BootでよくあるError creating bean with nameのようなエラーに悩まされたり、Beanが複雑なルールで切り替わったりしないので、むしろシンプルでわかりやすいかもしれません。

モジュール構成

主に4つのモジュールが存在します。

  • Infrastructure
  • Domain
  • Use Case
  • Presentational

有名な同心円の図に当てはめると、中心にDomainがあり、中心から2番目の円にUse Caseがあり、一番外側の円にInfrastructureとPresentationalがあるというイメージです。

Domain

最重要ビジネスルールを記述するところです。Clean Architecture本だと20章「エンティティ」です。コンピューターで計算しても手で計算しても変わらないようなルールを記述します。

まずはドメインオブジェクトを実装します。

https://github.com/hiterm/bookshelf-api/blob/cb673ef17d6e81b205dc7b9721212d6b78f40972/src/domain/entity/book.rs#L118-L142

今回はgetter, setterを導入しました。publicにして内部に直接アクセス可能にしてしまうと、バリデーションルールを無視してフィールドを書き換えてしまう可能性があるからです。ただし手動で実装するのは面倒だったので、getsetクレートを使いました。

ただしRustとgetter, setterは相性が悪いと言うような話もあるようです。問題になる場合もあるようなので注意した方が良さそうです。

https://stackoverflow.com/questions/35390615/writing-getter-setter-properties-in-rust

https://qiita.com/quasardtm/items/d5eae9294fb0e8374aff

Result型で異常側を表すDomainError型も定義しておきます。

https://github.com/hiterm/bookshelf-api/blob/1263a04c4587d5e19f7c0d82e48092d9c234b9d9/src/domain/error.rs#L7-L20

thiserrorとanyhowを使って実装の手間を削減しています。

また、リポジトリのTraitも用意しておきます。

https://github.com/hiterm/bookshelf-api/blob/cb673ef17d6e81b205dc7b9721212d6b78f40972/src/domain/repository/book_repository.rs#L12-L24

auto_mockを使ってモックを自動生成できるようにしておきます。

またasyncを使っているので、async-traitが必要です。これは現時点でのRustには、Traitにasyncなメソッドが定義できないという制約があるためです。以下の記事に詳しく理由が書いてあります。

https://smallcultfollowing.com/babysteps/blog/2019/10/26/async-fn-in-traits-are-hard/

async-traitを使えば簡単にTraitに対してasyncなメソッドが書けます。しかし内部的にBoxを使って動的ディスパッチが行われるので、Traitを経由しない場合と比べてパフォーマンスは落ちてしまうようです。とはいえよほどの場合でもない限り気になるものではないでしょう。

このTraitを実際に実装するのはInfrastructureモジュールです。

値オブジェクトおよびNew Typeについて

今回はすべてのフィールドをすべてに対して、それぞれ値オブジェクト(Value Object)を定義してみました。具体例はBookIdとかBookTitleとかです。RustにおいてはNew Typeパターンと呼ばれるものにも近いです。

https://doc.rust-jp.rs/rust-by-example-ja/generics/new_types.html

https://rust-unofficial.github.io/patterns/patterns/behavioural/newtype.html

全てに対して固有の型を定義するのはかなりのコストがかかります。振り返ってみると、この方式はやり過ぎだったかもしれません。Id系の型だけ定義しておけば他はプリミティブでも十分だった気がします。変更はsetter経由で行い、そこでバリデーションが行えるという前提のもとですが。

なお、近頃一部で話題になっていた値オブジェクト論争には立ち入らないのであしからず。

Use Case

アプリケーション固有のビジネスロジックを実装するところです。Clean Architecture本だと20章「ユースケース」あたりです。

UseCaseという名前でTraitを作成します。

https://github.com/hiterm/bookshelf-api/blob/1263a04c4587d5e19f7c0d82e48092d9c234b9d9/src/use_case/traits/book.rs#L11-L17

InteractorがそのTraitを実装します。コンストラクタインジェクションのようなことをやって、Repositoryを注入していますね。Rustだと抽象的なTraitに依存する場合、必ずジェネリクスを使用する必要があります。このあたりはJavaと異なるところです。

https://github.com/hiterm/bookshelf-api/blob/1263a04c4587d5e19f7c0d82e48092d9c234b9d9/src/use_case/interactor/book.rs#L22-L51

Domainモジュールと疎結合にするため、入出力の型としてDTOを新たに定義します。

https://github.com/hiterm/bookshelf-api/blob/1263a04c4587d5e19f7c0d82e48092d9c234b9d9/src/use_case/dto/book.rs#L16-L29

型変換が頻繁に発生するので、From<Book>を実装しておきます。

https://github.com/hiterm/bookshelf-api/blob/1263a04c4587d5e19f7c0d82e48092d9c234b9d9/src/use_case/dto/book.rs#L31-L64

またこの層のエラーを表す型を定義しておきます。

https://github.com/hiterm/bookshelf-api/blob/1263a04c4587d5e19f7c0d82e48092d9c234b9d9/src/use_case/error.rs#L5-L19

?演算子が使えるように、Fromを実装しておきます。

https://github.com/hiterm/bookshelf-api/blob/1263a04c4587d5e19f7c0d82e48092d9c234b9d9/src/use_case/error.rs#L21-L38

PresenterやOutput Portについて

Clean Architectureを実装しようとして多くの人が悩むところに、PresenterやUse Case Output Port周りの話があると思います。この図の右下のやつです。

https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html

ここについてはいろいろな議論があるのですが、私はPresenterは使いませんでした。素直にUseCaseで値を返し、その値をControllerで使うようにしています。

先人が色々と書いているので、興味があれば読んでみると面白いと思います。

https://izumisy.work/entry/2019/12/12/000521

https://softwareengineering.stackexchange.com/questions/357052/clean-architecture-use-case-containing-the-presenter-or-returning-data

https://nrslib.com/clean-flow-of-control/

https://qiita.com/os1ma/items/c02af5b7783b58165c8d

https://lukemorton.tech/articles/nuances-in-clean-architecture

Facade

CreateやUpdateなどで細かめにUseCase Traitを定義しています。

https://github.com/hiterm/bookshelf-api/blob/1263a04c4587d5e19f7c0d82e48092d9c234b9d9/src/use_case/traits/book.rs#L9-L33

そのため結果として、かなりUseCaseやInteractorの数が多くなってしまいます。

悩んだ末、Facadeとしてある程度まとまりのあるUseCaseを別途定義してみました。

https://github.com/hiterm/bookshelf-api/blob/1263a04c4587d5e19f7c0d82e48092d9c234b9d9/src/use_case/traits/query.rs#L11-L32

これがあると、Presentationモジュールのジェネリクスの数が減って少しきれいになります。

結果として、以下でMessage Busとして紹介されている解決方法とほぼ同じになりました。

https://logmi.jp/tech/articles/323559

Infrastructure

ここはRepositoryのTraitを実装しているだけです。

https://github.com/hiterm/bookshelf-api/blob/1263a04c4587d5e19f7c0d82e48092d9c234b9d9/src/infrastructure/book_repository.rs#L35-L54

このあたりで悩んだ話は以下にまとめてあるので、こちらも読んでみてください。

https://zenn.dev/htlsne/articles/rust-sqlx-test

Presentatioin

HTTP APIとしてのインターフェースを実装したり、GraphQLの処理を実装するところです。

ControllerはGraphQLの実装に処理を任せているだけです。

https://github.com/hiterm/bookshelf-api/blob/1263a04c4587d5e19f7c0d82e48092d9c234b9d9/src/presentation/controller/graphql_controller.rs#L15-L35

GraphQLではQueryとMutationという2つの大きく2種類の処理があります。Queryが参照系で、Mutationが更新系です。実装方針はほぼ同じなのでQueryだけ見ます。

QueryはUseCaseに処理を任せているだけです。

https://github.com/hiterm/bookshelf-api/blob/1263a04c4587d5e19f7c0d82e48092d9c234b9d9/src/presentation/graphql/query.rs#L12-L41

あとはGraphQL用の型を定義する必要もあります。

https://github.com/hiterm/bookshelf-api/blob/1263a04c4587d5e19f7c0d82e48092d9c234b9d9/src/presentation/graphql/object.rs#L75-L89

Data Loader

実は、実装とClean Architectureの原則と折り合いがつかないところがありました。

N+1問題を解決するためのData Loaderという仕組みがあります。

https://async-graphql.github.io/async-graphql/en/dataloader.html

今回は本と著者の関係が多対多になるので、必要になりました。ここの実装はこんな感じです。

https://github.com/hiterm/bookshelf-api/blob/1263a04c4587d5e19f7c0d82e48092d9c234b9d9/src/presentation/graphql/loader.rs#L27-L47

これを使う側がこちらです。

https://github.com/hiterm/bookshelf-api/blob/1263a04c4587d5e19f7c0d82e48092d9c234b9d9/src/presentation/graphql/object.rs#L122-L135

少し分かりづらいのですが、このQIという型はこちらです。

https://github.com/hiterm/bookshelf-api/blob/1263a04c4587d5e19f7c0d82e48092d9c234b9d9/src/presentation/graphql/object.rs#L6

https://github.com/hiterm/bookshelf-api/blob/1263a04c4587d5e19f7c0d82e48092d9c234b9d9/src/dependency_injection.rs#L19

Traitではなく、具体的な型に依存してしまっています。本当はPresentationはすべてUseCaseのTraitのみに依存させて、DIで具体的な型を注入したかったのですが、ここだけはうまく行きませんでした。async-graphql側の制約で、Genericにしてしまうと噛み合わせが悪くなってしまいます。ここは諦めました。

https://async-graphql.github.io/async-graphql/en/define_simple_object.html?highlight=generic#generic-simpleobjects

その他

Cloneの多用

関数の引数に所有権を渡すか参照を渡すか、設計を適当に行ったところ、全体的に不必要なcloneを多用するコードになってしまっています。ここは今後見直して行きたいと思っています。

実装してみた感想

Pros

  • フレームワークと粗結合になる
    • actix-teb, async-graphql, sqlxといったフレームワーク・ライブラリと疎結合になります。
    • 今後actixからaxumに移行してみたいと思っていますが、そんなに苦労せず移行できそうな気がしています。
  • それぞれのモジュールの責任範囲が明確になる

Cons

  • 実装が冗長になる
    • Traitを挟んだりDTOに詰め替えたりする分、実装量は増えます。
  • 多少パフォーマンスが犠牲になる
    • async-traitの動的ディスパッチや、DTOへの詰め直しにより、多少のオーバーヘッドが発生します。

終わりに

まだバグが残っているのも認識しており、他にも問題があるかもしれませんが、何かの参考になれば幸いです。

Discussion

ログインするとコメントできます