前回はアーキテクチャ、Domain層、Usecase層について考えてみました。
今回はInfrastructure層について考えてみようと思います。
環境
- Rust: 1.67
- PostgreSQL: 15
前回のおさらい
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テーブルを用意します。
CREATE TABLE users (
id BIGSERIAL NOT NULL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
age INTEGER
);
マイグレーションは cargo-make を使って実行しています。
※今回はPostgreSQLを使っているので psqldef
です。
[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で設定しています。
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用のデータベースも作成しています。
#!/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で分けた方がビルドが速くなる、かもしれない?(未検証)
[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
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.10.5
pub mod prelude;
pub mod users;
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.10.5
pub use super::users::Entity as Users;
//! `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の接続先などは環境変数で受け取るようにしているので、グローバルな設定情報として管理できるようにしています。
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のコネクションを受け取る構造体を定義します。
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を実装する
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