🛠️

[Rust] Rustでアプリケーションを書くときのアーキテクチャなどについて考える-その1(アーキテクチャ、Domain、Usecase編

yagince2023/01/25に公開

ここ数年、何個かのRustアプリケーションを作ってきて
すこしずつこんな構成になっているとやりやすいかも?ってのが見えてきたので、改めてまとめつつ、考えてみます。
フレームワークやライブラリなども考えていく予定です。

環境

  • Rust: 1.66

サンプル実装

yagince/rust-app-example

全体アーキテクチャ

全体としては、クリーンアーキテクチャやレイヤードアーキテクチャっぽい構成になってます。
こんな感じが程よく作りやすいかなと。
※クリーンアーキテクチャを参考にはしているけど、概念や用語には則っていないので注意

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を受け取る関数を定義する際などに u64String などの型だけだと何の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

↓こんな感じになります
image

パラメタライズテストしたい時は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

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