🛠️

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

2023/02/07に公開

前回はアーキテクチャ、Domain層、Usecase層について考えてみました。
今回はInfrastructure層について考えてみようと思います。

環境

  • Rust: 1.67
  • PostgreSQL: 15

前回のおさらい

https://zenn.dev/lecto/articles/b113dbe3d0662f

Layers

  • bin
    • 実行用バイナリ
    • バッチもAPIもmainはここに置く
  • cli
    • CLIから実行する処理の実体を記述する場所
    • cli用のオプション定義などはここに記述する
  • web
    • webappの実体を記述する場所
  • infrastructure ❗今回はここ❗
    • インフラ層
    • ドメイン層で定義されたinterfaceを実装する
      • 外部のAPIやサービスへアクセスする実際の処理
    • DB、Cache、暗号化などはこのレイヤーにある
  • interface
    • 受け取ったデータのHandlerを実装するようなレイヤー
    • WebでいうとControllerなど
  • usecase
    • 業務ロジックを書くレイヤー
    • なんかいろいろデータをごにょごにょする
  • domain
    • ドメイン層
    • repositoryのinterface定義はこのレイヤーでやる
      • 実装は別のレイヤー

Infrastructure

DBなどに永続化するRepositoryを実装したりします。
まずはDBに永続化するリポジトリについて考えてみます。

ORマッパー(SeaORM)

ずいぶん長いことDieselを使ってきたのですが、やはりasyncが欲しくなりsqlxを検討しました。
sqlxでもいいのですが、やはりORマッパーに慣れているのでORマッパーがあるものを使ってみたいなと思っていたところ
SeaORMに出会ったので、使ってみることにしました。

DBマイグレーション

SeaORMにもマイグレーション機能があります
Setting Up Migration | SeaORM 🐚 An async & dynamic ORM for Rust

Railsと似たような都度マイグレーションファイルを書いていく方式です。
私はridgepoleが好きなので
Ruby以外のアプリケーションではsqldefにお世話になっています。
SQLで書けるのも好きなポイントです。

まずは適当なテーブルとして毎度おなじみUserテーブルを用意します。

schema.sql
CREATE TABLE users (
  id   BIGSERIAL    NOT NULL PRIMARY KEY,
  name VARCHAR(255) NOT NULL,
  age  INTEGER
);

マイグレーションは cargo-make を使って実行しています。
※今回はPostgreSQLを使っているので psqldef です。

Makefile.toml
[tasks.migration]
command = "psqldef"
args = [
    "-U",
    "${POSTGRES_USER}",
    "-W",
    "${POSTGRES_PASSWORD}",
    "-p",
    "${POSTGRES_PORT}",
    "-h",
    "${POSTGRES_HOST}",
    "-f",
    "schema.sql",
    "${POSTGRES_DB}",
]

[tasks.migration-test]
command = "psqldef"
args = [
    "-U",
    "${POSTGRES_USER}",
    "-W",
    "${POSTGRES_PASSWORD}",
    "-p",
    "${POSTGRES_PORT}",
    "-h",
    "${POSTGRES_HOST}",
    "-f",
    "schema.sql",
    "${POSTGRES_DB_TEST}",
]

環境変数はdocker-compose.ymlで設定しています。

docker-compose.yml
version: '3.7'

x-db-envs: &db-envs
  POSTGRES_DB: example
  POSTGRES_DB_TEST: example_test
  POSTGRES_USER: postgres
  POSTGRES_PASSWORD: postgres

services:
  postgres:
    container_name: postgres
    image: postgres:15
    restart: always
    environment: *db-envs
    ports:
      - "5435:5432"
    volumes:
      - ./postgres/docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d
      - pgdata:/var/lib/postgresql/data

  app:
    container_name: app
    build:
      context: .
    command: cargo run
    volumes:
      - .:/app
    environment:
      << : *db-envs
      POSTGRES_HOST: postgres
      POSTGRES_PORT: 5432
      DATABASE_URL: postgres://postgres:postgres@postgres:5432/example_test
    depends_on:
      - postgres

volumes:
  pgdata:
    driver: local

※postgresの起動時にTest用のデータベースも作成しています。

./postgres/docker-entrypoint-initdb.d/create-database.sh
#!/bin/bash
set -e

psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
    CREATE DATABASE "${POSTGRES_DB_TEST}";
    GRANT ALL PRIVILEGES ON DATABASE "${POSTGRES_DB_TEST}" TO "${POSTGRES_USER}";
EOSQL

マイグレーション実行

> cargo make migration
> cargo make migration-test

SeaORMのEntity生成

sea-orm-cliを使うと実際のテーブルからEntityを自動生成してくれます。
SeaORMのドキュメントではentityをworkspaceとして分割しているみたいです。
Setting Up Migration | SeaORM 🐚 An async & dynamic ORM for Rust
が、今回はsrc の中に入れてしまいます。
※本当はworkspaceで分けた方がビルドが速くなる、かもしれない?(未検証)

Makefile.toml
[tasks.entity]
command = "sea-orm-cli"
args = [
    "generate",
    "entity",
    "-o",
    "src/infrastructure/repository/rdb/entity",
]
> cargo make entity

↓こんな感じでファイルが生成されます。

src/infrastructure/repository/rdb/entity
├── mod.rs
├── prelude.rs
└── users.rs
mod.rs
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.10.5

pub mod prelude;

pub mod users;
prelude.rs
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.10.5

pub use super::users::Entity as Users;
users.rs
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.10.5

use sea_orm::entity::prelude::*;

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "users")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i64,
    pub name: String,
    pub age: Option<i32>,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

impl ActiveModelBehavior for ActiveModel {}

設定情報の管理

DBの接続先などは環境変数で受け取るようにしているので、グローバルな設定情報として管理できるようにしています。

src/config.rs
use once_cell::sync::Lazy;

pub static CONFIG: Lazy<Config> = Lazy::new(|| Config {
    postgres_host: std::env::var("POSTGRES_HOST").unwrap(),
    postgres_port: std::env::var("POSTGRES_PORT").unwrap(),
    postgres_user: std::env::var("POSTGRES_USER").unwrap(),
    postgres_password: std::env::var("POSTGRES_PASSWORD").unwrap(),
    #[cfg(not(test))]
    postgres_database: std::env::var("POSTGRES_DB").unwrap(),
    #[cfg(test)]
    postgres_database: std::env::var("POSTGRES_DB_TEST").unwrap(),
});

pub struct Config {
    postgres_host: String,
    postgres_port: String,
    postgres_user: String,
    postgres_password: String,
    postgres_database: String,
}

impl Config {
    pub fn database_url(&self) -> String {
        format!(
            "postgres://{}:{}@{}:{}/{}",
            self.postgres_user,
            self.postgres_password,
            self.postgres_host,
            self.postgres_port,
            self.postgres_database
        )
    }
}

テスト時はデータベースを変える

テスト時とそれ以外でデータベースを切り替えたいので、参照する環境変数を変えるようにしています。

...
    #[cfg(not(test))]
    postgres_database: std::env::var("POSTGRES_DB").unwrap(),
    #[cfg(test)]
    postgres_database: std::env::var("POSTGRES_DB_TEST").unwrap(),
...

Repositoryの実装

DBに永続化するRepositoryの構造体

SeaORMのコネクションを受け取る構造体を定義します。

src/infrastructure/repository/rdb.rs
use std::time::Duration;

use sea_orm::{ConnectOptions, ConnectionTrait, Database, DatabaseConnection};

use crate::config::CONFIG;

pub mod entity;
pub mod user;

pub struct RdbRepository<'a, C: ConnectionTrait> {
    conn: &'a C,
}

impl<'a, C: ConnectionTrait> RdbRepository<'a, C> {
    pub fn new(conn: &'a C) -> Self {
        Self { conn }
    }
}

pub async fn create_connection() -> anyhow::Result<DatabaseConnection> {
    let mut opt = ConnectOptions::new(CONFIG.database_url());
    opt.max_connections(100)
        .min_connections(5)
        .sqlx_logging(true)
        .connect_timeout(Duration::from_secs(100))
        .idle_timeout(Duration::from_secs(300));

    Ok(Database::connect(opt).await?)
}

Traitを実装する

src/infrastructure/repository/rdb/user.rs
use sea_orm::{ActiveModelTrait, ConnectionTrait, EntityTrait};
use validator::Validate;

use crate::domain::{
    repository::user_repository::UserRepository,
    user::{NewUser, User, UserId},
};

use super::{
    entity::{self, users},
    RdbRepository,
};

#[async_trait::async_trait]
impl<'a, C: ConnectionTrait> UserRepository for RdbRepository<'a, C> {
    async fn get_users(&self) -> anyhow::Result<Vec<User>> {
        Ok(entity::prelude::Users::find()
            .all(self.conn)
            .await?
            .into_iter()
            .map(Into::into)
            .collect())
    }

    async fn get_user(&self, id: &UserId) -> anyhow::Result<Option<User>> {
        Ok(entity::prelude::Users::find_by_id(id.0)
            .one(self.conn)
            .await?
            .map(Into::into))
    }

    async fn create_user(&self, user: NewUser) -> anyhow::Result<User> {
        user.validate()?;
        Ok(entity::users::ActiveModel {
            name: sea_orm::ActiveValue::Set(user.name),
            age: sea_orm::ActiveValue::Set(user.age.try_into().ok()),
            ..Default::default()
        }
        .insert(self.conn)
        .await?
        .into())
    }
}

impl From<users::Model> for User {
    fn from(x: users::Model) -> Self {
        Self {
            id: UserId(x.id),
            name: x.name,
            age: x.age.and_then(|x| x.try_into().ok()).unwrap_or_default(),
        }
    }
}

Testする

#[cfg(test)]
mod tests {
    use anyhow::Context;
    use assert_matches::assert_matches;
    use pretty_assertions::assert_eq;
    use sea_orm::{ActiveModelTrait, DatabaseTransaction, TransactionTrait};
    use validator::ValidationErrors;

    use crate::infrastructure::repository::rdb::{create_connection, entity};

    use super::*;

    async fn create_transaction() -> anyhow::Result<DatabaseTransaction> {
        Ok(create_connection()
            .await?
            .begin()
            .await
            .context("begin transaction")?)
    }

    #[tokio::test]
    async fn test_get_users() -> anyhow::Result<()> {
        let tx = create_transaction().await?;

        entity::users::ActiveModel {
            name: sea_orm::ActiveValue::Set("name".into()),
            age: sea_orm::ActiveValue::Set(Some(100)),
            ..Default::default()
        }
        .save(&tx)
        .await
        .context("insert fixture")?;

        let repo = RdbRepository::new(&tx);

        let users = repo.get_users().await.context("get_users")?;

        assert_matches!(&users[..], [user] => {
            assert_matches!(user.id, UserId(x) => {
                assert!(x > 0);
            });
            assert_eq!(user.name, "name");
            assert_eq!(user.age, 100);
        });

        Ok(())
    }
}
  • anyhow::Resultを返すようにしているので、context でどこで失敗したのかわかりやすくしています。
  • RepositoryのテストではtransactionをRepositoryにわたすようにして、テスト終了時に自動的にロールバックするようにしています。
    • tx がDropされるときにロールバックされます

まとめ

  • 今回は主にRepositoryについて考えてみました
    • Traitでインターフェースと実装が分離されていることで、UsecaseではRepositoryが実装されていなくてもテストできる
    • Repositoryの実装は永続化レイヤーについてのみ考えればいいので、関心事が分離されていて、実装時にあれこれ考えずに集中しやすい
  • 次回は Interface/Web/CLI 層について考えてみようかなぁと思います

Discussion