RustのDI備忘録
DI(DIって言い方好きじゃない)の本質的に便利なところはテストダブル的な用法よりも抽象に対して動的に実装が与えられるというところである。したがってどんな言語でもシンプルで堅牢なDI手法を確立することはソフトウェアを作るうえで重要である。
ということでRustの手堅いDI手法を2つ備忘録的に記録しておく。
Constructor Injection
素直にtraitを使って抽象化する方法。静的constructor injectionと呼ばれてるやつ。
code:domain/repository/user_repository.rs
pub trait UserRepository: Send + Sync + 'static {
async fn find_by(&self, user_id: UserId) -> Result<User, DomainError>;
}
code: usercase/user_usecase.rs
pub struct UserUseCase<Repo: UserRepository> {
repository: Arc<Repo>,
}
impl <R: UserRepository> UserUseCase<R> {
async fn get_user(&self, user_id: UserId) -> Result<User, DomainError> {
// 実装詳細
}
}
UserUseCaseはrepositoryを素直に参照で持ってもよいのだが、その場合メソッド内(上記のget_user)で tokio::spawn でspawningしたいときにselfが使えなくて困るので、保守性を考えたら基本Arcに包むことになると思われる。悲しいね。
わざわざ型引数Repoを使わないでdynamic traitを直接扱うことで、specializationを避けたり型引数の明示を避けることもできる。こちらは動的constructor injectionと言われている。Haskellだと存在型包んでやることで実現するやつ。
code: domain/repository/user_repository_dynamic.rs
pub struct UserUseCase {
repository: Arc<dyn UserRepository>,
}
Constructor Injectionはシンプルでmockallとの相性も良いが、複数の抽象に依存したい(e.g. UserRepository と GroupRepository に依存したユースケースを作りたい)とか言い始めると構造体を作る手間が増えて辛くなってくる。現実的な規模のアプリケーションを作る場合は結構つらい。また、依存が複雑化した場合にCake Patternと同様の問題が発生する。
(Minimal) Cake Pattern
ユースケースもtraitにし、抽象を実装した構造体を持っているという抽象をしたHas traitを導入することで直接的な構造体への依存を取り除く方法。Has traitが合成可能なので、複数の抽象に依存したい場合も簡単にできる。
code: domain/repository/user_repository.rs
pub trait UserRepository: Send + Sync + 'static {
async fn find_by(&self, user_id: UserId) -> Result<User, DomainError>;
}
pub trait HaveUserRepository {
type UserRepository: UserRepository + Send + Sync + 'static
fn user_repository(&self) -> &Self::UserRepository;
}
code: usecase/user_usecase.rs
pub trait UserUseCase: HaveUserRepository {
async fn get_user(&self, user_id: UserId) -> Result<User, DomainError> {
// 実装
};
}
impl <T: HaveUserRepository> UserUseCase for T {}
// デフォルト実装を使用する。書き換えも可能
一番シンプルな実装はこれ。これを便宜的に簡易Cake Patternと呼ぶことにする。ただこの場合、複雑な依存の場合DIがめんどくさくなる。例えば、UserUseCaseがFetcherというユースケース層で定義されたhelper traitに依存しており、FetcherがUserRepositoryに依存している場合、Fetcher の実装を差し替える(DIする)のに UserRepository の実装を持つことを強制される。この問題はテスト目的でDIする場合はmockallがなんとかしてくれるが、そうでない場合は依存を定義するtraitと依存を実装するtrait(Uses)に分けることで解決できる。
code: domain/repository/user_repository.rs
pub trait UsesUserRepository: Send + Sync + 'static {
async fn find_by(&self, user_id: UserId) -> Result<User, DomainError>;
}
// Have trait
pub trait ProvedesUserRepository {
type UserRepository: UsesUserRepository + Send + Sync + 'static
fn user_repository(&self) -> &Self::UserRepository;
}
code: usecase/user_usecase.rs
pub trait UsesUserUseCase: Send + Sync + 'static {
async fn get_user(&self, user_id: UserId) -> Result<User, DomainError>;
}
// 依存関係の定義
pub trait UserUseCase: ProvidesUserRepository {}
impl<R: UserUseCase> UsesUserUseCase for R {
async fn get_user(&self, user_id: UserId) -> Result<User, DomainError> {
// 実装
};
}
pub trait ProvidesUserUseCase {
type UserUseCase: UsesUserUseCase;
fn user_usecase(&self) -> &Self::UserUseCase;
}
code: infra/repository/user_repository_impl.rs
// DB実装。普通は別ファイルに分離するが説明のためにここに書いとく。
pub trait UsesDatabase: Send + Sync + 'static {
async fn find_user(&self, user_id: UserId) -> Result<User, DbError>;
}
pub trait Database: Send + Sync + 'static;
impl<Db: Database> UsesDatabase for Db {
async fn find_user(&self, user_id: UserId) -> Result<User, DbError> {
// DBを触る実装。
}
}
pub trait ProvidesDatabase {
type Database: UsesDatabase;
fn database(&self) -> &Self::Database;
}
// Repositoryの実装
// Repository実装の依存関係の定義
pub trait UserRepositoryWithDatabase: ProvidesDatabase {}
impl<T: UserRepositoryWithDatabase> UsesUserRepository for T {
async fn find_by(&self, user_id: UserId) -> Result<User, DomainError> {
// DBを利用したUserRepositoryの実装
}
}
こうすることで、うまく依存するtraitが依存するtraitの面倒を見ないで済むようになる。
こういった手法はScalaでよく使われている(いた)Cake Patternと呼ばれるDI手法の亜種で、Minimal Cake Patternとか呼ばれていたりする。ここではこの実装を単にCake Patternと呼ぶことにする。イメージとしては抽象はUsesHoge というtraitで定義し、抽象に対する依存はProvidesHoge で表現し、UsesHogeの実装のときに必要な抽象を導入するのに Hoge traitで ProvidesHogeを使って依存を表現する。最終的にどの実装を使うかの選択はプレゼンテーション層などで作った構造体にどのHoge trait(e.g. UserUseCase, UserRepositoryWithDatabase), を実装するかによって静的に決まる。
逆に言うと、最終的に抽象の分だけ実装を選択する手間が有るため、不必要なCake Patternは実装コストに見合わないだろう。
code: presentation/handlers.rs
pub struct App;
// 実装の選択
impl Database for App {};
impl UserRepositoryWithDatabase for App {};
impl UserUseCase for App {};
impl ProvidesDatabase for App {
type Database = Self;
fn database(&self) -> &Self::Database {
self
}
}
impl ProvidesUserRepository for App {
type UserRepository = Self;
fn user_repository(&self) -> &Self::UserRepository {
self
}
}
impl ProvidesUserUseCase for App {
type UserUseCase = Self;
fn user_usecase(&self) -> &Self::UserUseCase {
self
}
}
動的に実装を切り替える場合は、プレゼンテーション層でusecaseを呼ぶときに使う構造体を複数用意し、条件分岐で切り替えれば良い。
見てもらったように、簡易Cake Pattrernやconstructor injectionに比べCake Patternはボイラープレートが多い。ボイラープレートが多いということは保守性が下がるということを意味し、この手法には賛否両論が有る。
所感
RustのDI手法について主なものを2つ紹介した。極めてシンプルな依存関係であればConstructor injectionが一番見通しが良いが、実際にソフトウェアの依存関係がそこまでシンプルなことは珍しく、実用した際にはそれなり以上に面倒である。一方でCake Patternはボイラープレートが多すぎる。実際のところ多くの場合ではテスト目的以外で実装を差し替えたくなるのはRepositoryくらいのものである(要出典)ことを考えると、テスト目的のDIはmockallに全て任せ、簡易Cake Patternを行うというのが手堅い手法に思える。
書きたいけどまだ書いてないこと
Cake Patternのmockallでのmock戦略。試しきれてない。
最後に一言
Effect Systemくれ
参考文献
κeenさんの記事。必要最低限なDIの話が載ってる。Minimal Cake Pattern。
依存が複雑化した場合のMinimal Cake Patternの方法。
yukiさんの記事。各DIのPros/Consが載ってる。Reader Monadを使ったDI手法はあんまりうれしくないというのはHaskellでもそうなんだよな。