[Rust] Rustでアプリケーションを書くときのアーキテクチャなどについて考える-その1(アーキテクチャ、Domain、Usecase編
ここ数年、何個かのRustアプリケーションを作ってきて
すこしずつこんな構成になっているとやりやすいかも?ってのが見えてきたので、改めてまとめつつ、考えてみます。
フレームワークやライブラリなども考えていく予定です。
環境
- Rust: 1.66
サンプル実装
全体アーキテクチャ
全体としては、クリーンアーキテクチャやレイヤードアーキテクチャっぽい構成になってます。
こんな感じが程よく作りやすいかなと。
※クリーンアーキテクチャを参考にはしているけど、概念や用語には則っていないので注意
Layers
-
bin
- 実行用バイナリ
- バッチもAPIもmainはここに置く
-
cli
- CLIから実行する処理の実体を記述する場所
- cli用のオプション定義などはここに記述する
-
web
- webappの実体を記述する場所
-
infrastructure
- インフラ層
- ドメイン層で定義されたinterfaceを実装する
- 外部のAPIやサービスへアクセスする実際の処理
- DB、Cache、暗号化などはこのレイヤーにある
-
interface
- 受け取ったデータのHandlerを実装するようなレイヤー
- WebでいうとControllerなど
-
usecase
- 業務ロジックを書くレイヤー
- なんかいろいろデータをごにょごにょする
-
domain
- ドメイン層
- repositoryのinterface定義はこのレイヤーでやる
- 実装は別のレイヤー
Layer Dependencies
- レイヤーは下位レイヤーにのみ依存する
- 同一レイヤーはOK
- 下位レイヤーであれば飛び越えてもOK (e.g. web -> interface)
- 下位レイヤーは上位レイヤーに依存してはいけない
- 依存性はinterfaceで宣言し、上位レイヤーがそれを実装する(依存性逆転の原則)
- 上位レイヤーから下位レイヤーに依存性を注入することで切り替えを用意にしたり、モックを渡すことでテスタビリティを上げる
bin
↓
cli or web
↓
infrastructure
↓
interface
↓
usecase
↓
domain
Domain
ドメインに関するEntityやValueObjectなど。
Entity実装例
Userを例に考えてみます。
use serde::{Deserialize, Serialize};
use validator::Validate;
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct UserId(pub i64);
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Validate)]
pub struct User {
pub id: UserId,
#[validate(length(min = 1))]
pub name: String,
pub age: u32,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Validate)]
pub struct NewUser {
#[validate(length(min = 1))]
pub name: String,
pub age: u32,
}
IDはEntity毎に型を定義する
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct UserId(pub i64);
idを受け取る関数を定義する際などに u64
や String
などの型だけだと何のEntityのIDを期待しているのかわからなくなるので UserId
という型を定義しておくと後々理解しやすいと思います。
Create時など、まだIDが無い状態の時の為にNewUserという型を定義する
struct User {
pub id: Option<UserId>
...
}
とすることもできるがこれをやるとUserのIDが必ずOptionになるので、至る所でmapが出てきてNoneの場合を考慮しなくてはいけなくなるので、後々面倒になるんですよねぇ。
Idが無い場合はデータの作成時くらいでなので、その時用に別の型を用意しておくほうが便利。
Validationはここで実装する
#[derive(Debug, Clone, PartialEq, Deserialize, Validate)]
pub struct NewUser {
#[validate(length(min = 1))]
pub name: String,
pub age: u32,
}
※これはRailsに慣れているからというのも大きい気がするけど、そこは一旦目をつぶっておきます...
Entityの仕様をValidationで表現するとわかりやすいというのが大きな理由です。
ただし、NewUser
User
両方に同じvalidationを定義しなければいけないのが難点なんですよね。
TypeScriptのOmit<T, Keys>のようなものがあると便利なのかもしれないですねぇ...
※Userの型から id
を抜いた型を自動生成するマクロを書けばいい気がしている(proc macroでderiveできるようにしてあげると便利かもしれない)
Repository実装例
このレイヤーではTraitでインターフェースのみ定義します。
Repositoryのインターフェースをどこに置くかは色々ご意見あるかと思いますが
ちらばると面倒なので、Domain層に統一しておきます。
use crate::domain::user::{NewUser, User, UserId};
use async_trait::async_trait;
#[cfg_attr(test, mockall::automock)]
#[async_trait]
pub trait UserRepository: Send + Sync {
async fn get_users(&self) -> anyhow::Result<Vec<User>>;
async fn get_user(&self, id: &UserId) -> anyhow::Result<Option<User>>;
async fn create_user(&self, user: NewUser) -> anyhow::Result<User>;
}
async_traitでasync fnを使えるようにする
...
#[async_trait]
pub trait UserRepository: Send + Sync {
...
RepositoryはI/Oが発生する事が多いので非同期にしたい為、asyncをtraitで使えるように
async-traitを使っておきます。
後でimplする時に1つの構造体に複数のTraitをimplできるようにメソッド名はかぶらないようにしておく
1 Trait -> 1 Struct で実装するのであれば構わないんですが
それをやると、大量のRepositoryを受け渡ししなければいけなくて辛くなることがあるので
struct Repository {}
impl UserRepository for Repository {}
impl FooRepository for Repository {}
のような感じに出来るように
pub trait UserRepository {
async fn get_users(&self) -> ...
}
pub trait FooRepository {
async fn get_foo(&self) -> ...
}
となるようにしています。
Traitはテストでモック・スタブにできるようにmockall::automockしておく
テスト時にRepositoryを渡す時は結果を任意で変えたいので
mockallを使っています。
Usecase
ビジネスロジックのような処理を書くレイヤー
ユーザー作成の例
use validator::Validate;
use crate::domain::{
repository::user_repository::UserRepository,
user::{NewUser, User, UserId},
};
pub struct CreateUser<'a, R: UserRepository> {
repo: &'a R,
}
impl<'a, R: UserRepository> CreateUser<'a, R> {
pub fn new(repo: &'a R) -> Self {
Self { repo }
}
pub async fn run(&self, user: NewUser) -> anyhow::Result<User> {
user.validate()?;
self.repo.create_user(user).await
}
}
- validationする
- repositoryでデータを永続化する
っていうシンプルな例です
Interactorっていう単語は使ってない
使ってもいいんですが、Interactorって何?っていうのを全員が理解しないといけないのは面倒ですし
特にそういう用語を使わなくても、名前から何をしたいのかがわかれば今の所十分なので
あえてInteractorみたいな用語は使ってません。
※慣れの問題なだけかもしれない。
Repositoryは借用(参照で受け取る)する
これは実装上の都合ですが
RDBを使うRepositoryを実装する時にInterface側でトランザクション境界を決めたい場合
RepositoryにTransactionを渡す必要が出てくるのですが
TransactionのスコープをInterface側で決める関係でRepositoryにはTransactionを参照で渡す必要がでてきます。
なので、RepositoryをUsecaseにわたす時も参照で渡すようにしています。
Test
ここまでDomain,Usecaseを考えたのでテストについても一緒に考えていきます。
テスト大好きです。
Domain(Entity)
Validationのテストの例です
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Validate)]
pub struct User {
pub id: UserId,
#[validate(length(min = 1))]
pub name: String,
pub age: u32,
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use rstest::rstest;
use validator::ValidationErrors;
use super::*;
#[rstest]
#[case("", true)]
#[case("a", false)]
fn test_validate_name(#[case] name: &str, #[case] has_error: bool) {
let user = User {
name: name.into(),
..Default::default()
};
let res = user.validate();
assert_eq!(ValidationErrors::has_error(&res, "name"), has_error);
}
}
assertは差分が見やすくなるpretty_assertionsを使う
assertは差分が見やすくなるのでpretty_assertionsを使っています
thread 'domain::user::tests::test_deserialize_from_json' panicked at 'assertion failed: `(left == right)`
left: `User { id: UserId(1234567890), name: "Name Name", age: 100 }`,
right: `User { id: UserId(123456789), name: "Name Name", age: 100 }`', src/domain/user.rs:52:9
↓こんな感じになります
パラメタライズテストしたい時はrstest
#[rstest]
#[case("", true)]
#[case("a", false)]
fn test_validate_name(#[case] name: &str, #[case] has_error: bool) {
let user = User {
name: name.into(),
..Default::default()
};
let res = user.validate();
assert_eq!(ValidationErrors::has_error(&res, "name"), has_error);
}
Validationのテストはパラメタライズテストしたいのでrstestを使っています
Usecase
ユーザー作成ロジックのテストの例です
use validator::Validate;
use crate::domain::{
repository::user_repository::UserRepository,
user::{NewUser, User, UserId},
};
pub struct CreateUser<'a, R: UserRepository> {
repo: &'a R,
}
impl<'a, R: UserRepository> CreateUser<'a, R> {
pub fn new(repo: &'a R) -> Self {
Self { repo }
}
pub async fn run(&self, user: NewUser) -> anyhow::Result<User> {
user.validate()?;
self.repo.create_user(user).await
}
}
#[cfg(test)]
mod tests {
use assert_matches::assert_matches;
use mockall::predicate::eq;
use validator::ValidationErrors;
use crate::domain::{repository::user_repository::MockUserRepository, user::UserId};
use super::*;
#[tokio::test]
async fn test_create_user() -> anyhow::Result<()> {
let new_user = NewUser {
name: "TestName".into(),
age: 99,
};
let mut repo = MockUserRepository::new();
repo.expect_create_user()
.with(eq(new_user.clone()))
.returning(|x| {
Ok(User {
id: UserId(100),
name: x.name,
age: x.age,
})
});
let usecase = CreateUser::new(&repo);
let user = usecase.run(new_user).await?;
assert_matches!(user, User { id, ..} => {
assert_eq!(id, UserId(100));
});
Ok(())
}
#[tokio::test]
async fn test_create_user_if_validation_error() -> anyhow::Result<()> {
let new_user = NewUser {
name: "".into(),
age: 99,
};
let mut repo = MockUserRepository::new();
repo.expect_create_user()
.with(eq(new_user.clone()))
.returning(|x| {
Ok(User {
id: UserId(100),
name: x.name,
age: x.age,
})
});
let usecase = CreateUser::new(&repo);
let res = usecase.run(new_user).await;
assert_matches!(res, Err(e) => {
match e.downcast::<ValidationErrors>() {
Ok(e) => assert!(ValidationErrors::has_error(&Err(e), "name")),
Err(e) => panic!("Not ValidationErrors: {:?}", e),
}
});
Ok(())
}
}
RepositoryのMock
let mut repo = MockUserRepository::new();
repo.expect_create_user()
.with(eq(new_user.clone()))
.returning(|x| {
Ok(User {
id: UserId(100),
name: x.name,
age: x.age,
})
});
let usecase = CreateUser::new(&repo);
Repositoryはmockallを使っているので MockUserRepository
を使ってモックにしています
こうしておくことでRDBなどを使っていても依存せずに任意の状態を作れることができます
部分的なassert
let user = usecase.run(new_user).await?;
assert_matches!(user, User { id, ..} => {
assert_eq!(id, UserId(100));
});
部分的なassertをしたい場合は、assert_matchesを使ってassertしたいフィールドのみを取り出してassertしています。
全てチェックするのが面倒な時に便利です。
Errはanyhow::Resultで雑に逃げる
Resultが返ってくるケースで全てをassertするのは面倒なので、テストケースの戻り値をanyhow::Resultにすることで
?
でreturnしてErrであればテストがfailするようにしています。
次回
長くなりそうなので、今回はここまでにします。
- 次回はInfrastructure層について考えていきたいと思います。
- Interface層、CLI/Webについてもそのうち書きたい。
Discussion