会社にRustを導入しようとしている話② 〜 Rustでのレイヤードアーキテクチャについて
こんにちは、株式会社POLでエンジニアをしている高橋です。
今回は、前回で紹介したRustの導入についての続きとして、POLのRust開発でのコード構成について紹介します。
POLではレイヤードアーキテクチャを踏襲したコード構成を採用しており、サンプルコードを交えてPOLのRust開発のコードの雰囲気が伝わるように簡単に紹介してみようと思っています。
Rustのコード構成
レイヤードアーキテクチャを踏襲しているということもあり、基本的には以下のようなコード構成でRustによるWeb APIを実装しています。
├── domain: ドメイン層
│ ├── repository: traitによる処理の抽象化
│ ├── [domain].rs: ドメイン単位でtraitを定義
├── infrastructure: インフラ層
│ ├── data_source:
│ └── [data_source].rs: データの永続化先毎にtraitを定義し実装
│ └── repository:
│ └── [domain].rs: ドメインリポジトリの実装
├── application: アプリケーション層
│ └── [domain]: ドメイン単位でハンドラを定義
│ ├── handler.rs: リポジトリの処理を実行するハンドラ
│ └── request.rs: ハンドラへのリクエストを定義する
├── presentation: プレゼンテーション層
│ └── [endpoint]: APIのエンドポイント毎等に処理を定義
├── domain.rs
├── infrastructure.rs
├── application.rs
├── presentation.rs
├── main.rs
└── lib.rs
ドメイン層
repository配下でtraitを定義することで、アプリケーション層からは具体的な実装が隠蔽された形で利用できるようにしています。traitの関数は原則Result型を返却するようにしています。
#[async_trait::async_trait]
pub trait LaboratoryRepository {
async fn search_laboratories(
&self,
condition: &String,
lang: &LanguageCode,
page: &u64,
per_page: &u64,
) -> Result<LaboratorySearchResultModel>;
}
また、ドメイン層のモデルについては別クレートに切り出しし、将来的にはビジネスロジックを凝縮させていく予定で作成しています。現状では
ビルダー関数のみでドメインモデルの生成が行える
ドメインモデルの生成はResult型で返却する
といった程度のルールを設けることで、将来的な拡張性を担保しようとしています。
インフラ層
ドメイン層のtraitを実装をインフラ層で行っており、データベースやAlgoliaといった外部リソースへのアクセスを担っています。複数の外部リソースを組み合わせてドメイン層のtraitを実装することもあるため、外部リソースへのアクセスを行う処理についてもdata_source配下でtraitで定義し抽象化及び実装することで、テストがしやすい構造になるようにしています。
// data_soruce配下の実装例
// data_source/[data_source]/client.rs
// 外部リソース内の適当な粒度(ドメインに相当)でtraitを定義します
#[cfg_attr(test, mockall::automock)]
#[async_trait::async_trait]
pub trait LaboratoryIndexClient {
async fn search(
&self,
condition: &str,
page: u64,
hit_per_page: u64,
) -> Result<LaboratorySearchResult>;
}
// ファクトリメソッドによってtraitの実装を返却します
pub fn laboratory_index_client_factory() -> Result<Box<dyn LaboratoryIndexClient + Sync + Send>> {
Ok(Box::new(self::laboratory::AlgoliaLaboratoryIndexClient {}))
}
// data_source/[data_source].rs
// 外部リソースへのクライアントをstructで定義します
#[derive(Getters)]
#[getset(get = "pub")]
pub struct FulltextSearchClient {
arc_laboratory_client: Arc<Box<dyn self::client::LaboratoryIndexClient + Sync + Send>>,
}
// 下位のファクトリメソッドを利用して、外部リソースへのクライアントのファクトリメソッドを定義します
pub fn fulltext_search_client_factory() -> Result<FulltextSearchClient> {
let laboratory_client = self::client::laboratory_index_client_factory()?;
Ok(FulltextSearchClient {
arc_laboratory_client: Arc::new(laboratory_client),
})
}
インフラ層のリポジトリの実装では上記の外部リソースへのクライアントを利用してデータアクセスし、ドメインモデルを生成しています。テストでは外部リソースへのクライアントがモック化され、ビルダーによるドメインモデルの生成が成功するかどうかを主眼においたテストが行われます。
// repository配下の実装例
#[derive(new)]
pub struct InfraLaboratoryRepository {
// traitの実装を保持させることで、モック化を可能にしています
laboratory_search_client: Arc<Box<dyn LaboratoryIndexClient + Sync + Send>>,
}
// traitの実装
#[async_trait::async_trait]
impl LaboratoryRepository for InfraLaboratoryRepository {
async fn search_laboratories(
&self,
condition: &String,
lang: &LanguageCode,
page: &u64,
per_page: &u64,
) -> Result<LaboratorySearchResultModel> {
// 外部リソースへのクライアントを駆使して、ドメインモデルのビルダーに必要な値を取得します
let search_result = self
.laboratory_search_client
.search(condition, *page, *per_page)
.await?;
// 処理が続く
}
}
また、インフラ層で実装された各ドメインリポジトリについても、全リポジトリを生成するファクトリメソッドをinfrastructure.rsで定義することで、lib.rsでファクトリメソッドを呼び出すだけでリポジトリが生成され依存関係が流出しないような作りにしています。
// infrastructure.rs
// すべてのドメインリポジトリの実装を保持するstruct
#[derive(new, Getters)]
#[getset(get = "pub")]
pub struct Repositories {
laboratory_repository: Arc<dyn LaboratoryRepository + Sync + Send>,
hoge_repository: Arc<dyn HogeRepository + Sync + Send>,
fuga_repository: Arc<dyn FugaRepository + Sync + Send>,
}
// 下位のファクトリメソッドを呼び出してRepositoriesを生成する
pub fn repository_factory() -> Repositories {
let fulltext_search_clients = fulltext_search_client_factory()?;
let arc_laboratory_search_client = fulltext_search_clients.arc_laboratory_client();
let laboratory_repository: Arc<dyn LaboratoryRepository + Sync + Send> =
Arc::new(InfraLaboratoryRepository::new(
Arc::clone(arc_laboratory_search_client),
));
let hoge_repository = …
let fuga_repository = …
Repositories {
laboratory_repository: Arc::new(laboratory_repository),
hoge_repository: Arc::new(hoge_repository),
fuga_repository: Arc::new(fuga_repository),
}
}
// lib.rs
pub fn create_schema_with_context(
) -> Result<Schema<RootQuery, RootMutation, EmptySubscription>, Box<dyn Error>> {
// リポジトリを生成し利用する
let repository = repository_factory()?;
// 処理が続く…
}
アプリケーション層
アプリケーション層ではドメインリポジトリを呼び出しして得られた結果を返却するハンドラを定義しています。
pub struct GetLaboratoryListUseCaseHandler<'a> {
pub laboratory_repository: &'a Arc<dyn LaboratoryRepository + Sync + Send>,
}
impl<'a> GetLaboratoryListUseCaseHandler<'a> {
pub async fn execute(
&self,
request: GetLaboratoryListUseCaseRequest,
) -> Result<LaboratorySearchResultModel> {
self.laboratory_repository
.search_laboratories(
request.condition(),
request.lang(),
request.page(),
request.per_page(),
)
.await
}
}
※レイヤーとして薄いためこのレイヤーの責務についてどうするか悩み中です。
プレゼンテーション層
プレゼンテーション層では利用しているWebフレームワークのリクエストに対応するレスポンスを、アプリケーション層のハンドラを呼び出すことで生成しています。GraphQLであればリクエストに対応するオブジェクト、gRPCであればgRPCに対するレスポンスを実装しています。
以上が各レイヤーと簡単なコードサンプルの紹介となります。
おまけ、レイヤーのクレート化について
今回紹介したコードは各レイヤーを1つのクレートとしてまとめたコードになっていますが、他のプロジェクトではレイヤー毎にクレートとして切り出している場合もあります。
レイヤーをクレートとして切り出すか議論の余地がありそうですが、上記の構成で開発しているプロジェクトのビルドの実行時間がそれなりにかかっており、開発する上であまり嬉しくない体験になっています。
クレートに切り出すことで切り出す最大のメリットとしてはビルド時間の短縮が見込めるため、個人的にはレイヤー毎、あるいはレイヤーについてもドメイン単位で切り出したほうが良いと感じています。まだまだRust開発については個人的に未熟な点が多いので、ビルド時間の短縮については次回以降で改善があれば紹介したいと思います。
最後に
以上、POLでRustの導入についての紹介その2でした。次回はRustを開発しているチームの取り組みについて紹介したいと思います。
Rustを書きたいエンジニアを募集中ですので、興味のある方は気軽にエントリください。
この記事が気に入ったらサポートをしてみませんか?