最近Rustしか触っていませんが、Rustが全くわかりません!!!
しかも言語仕様もよくわかってないのにWeb開発をしようとすると更によく分からなくなります。
なのでRustのwebフレームワークであるAxum
を利用してTodoアプリに必要なWebAPIの開発を行いつつ、練習がてらに「レイヤードアーキテクチャ」を意識して、構築してみました!
不完全かもしれない or あんまり綺麗なものとは言えないですが、何とか動いたので以下に公開しておきます!
アーキテクチャ
今回採用したレイヤードアーキテクチャは、よく見る3層アーキテクチャを改良したものです(と認識しています)。
アプリケーションアーキテクチャ理解に必要な“3層構造”プレゼンテーション層・ビジネスロジック層・データアクセス層それぞれの役割
3層のアーキテクチャではプログラムを以下の3つの層に分けて開発を行います。
- プレゼンテーション層: 入出力を行う層
- ビジネスロジック層: それ以外の処理(ビジネスロジック)を行う層
- データアクセス層: データベースなどとの入出力をする層
で、このアーキテクチャですが、「ビジネスロジック層: それ以外の処理(ビジネスロジック)を行う層」と記載した通り、とにかく抽象的というか曖昧で、この層に何でもかんでもロジックが埋め込まれがちでメンテナンスが大変です。
(いわゆる、神クラス)
そこで以下のように分割し、もう少し責務を明確にしたものが「レイヤードアーキテクチャ」です。
- プレゼンテーション(ユーザーインターフェース)層: 入力を受け取り、出力を返す層
- アプリケーション(ユースケース)層: ビジネスロジックの実行やエラーハンドリングを行なう層
- ドメイン(ビジネスロジック)層: データをもとにビジネスロジックを実装する層
- インフラストラクチャ層: 必要なデータにアクセスし、返却する層
ソリューションアーキテクチャーデザイン連載(10/13):レイヤードアーキテクチャーとは何ですか?
DDDの一般的なアーキテクチャをまとめてみた
ビジネスロジックを「実行する層」と「実装する層」に分かれたイメージですね。
個人的には上記に加え
- アプリケーション(ユースケース)層: 複数のビジネスロジックを組み合わせたユースケースを実装する層
- ドメイン(ビジネスロジック)層: 固有のビジネスロジックを実装する層
とも認識しています。
また、層を分けただけでなく、依存関係を1方向に保ちます(保つ努力をします)。
上位の層は下位の層に依存(利用)しますが、下位の層は上位の層に依存してはいけません。
当然、実装を知らなくても良いように実装する必要があります。
こうすることで依存関係を明確にし、「依存性注入(DI)によりテストを簡単に行えるよう」にしたり、「データベースの接続先などを変更しやすく」します。
良さげですね!
さらに上位のアーキテクチャとして「オニオンアーキテクチャ」や「クリーンアーキテクチャ」などが存在しますが、今回はシンプルなこちらのアーキテクチャで実装してみようと思います!
実装
今回は「Axum」というWebフレームワークを利用します。
Axumはtokioという有名な非同期ライブラリを作っているチームが開発し、tokioと親和性の高いフレームワークです。
ちょっと古い記事ですが、こちらを参考にして作っていきます。
Rust の新しい HTTP サーバーのクレート Axum をフルに活用してサーバーサイドアプリケーション開発をしてみる
環境構築
最終的に、以下のようなフォルダ構成になっています。
❯ tree . -L 2
.
├── Cargo.lock
├── Cargo.toml
├── Dockerfile
├── Makefile
├── README.md
├── docker-compose.yml
├── src
│ ├── handlers.rs
│ ├── main.rs
│ └── repositories.rs
├── target
│ ├── CACHEDIR.TAG
│ ├── debug
│ └── release
├── todos-adapter
│ ├── Cargo.toml
│ └── src
├── todos-app
│ ├── Cargo.toml
│ └── src
├── todos-controller
│ ├── Cargo.toml
│ └── src
└── todos-domain
├── Cargo.toml
└── src
詳細はリポジトリを見てください、ということで詳細を省きつつ簡単に環境構築について説明します。
まずは適当にフォルダを作成して、中にCargo.tomlを作ります。
[workspace]
members = [
"todos-*"
]
resolver = "2"
今回はワークスペースという機能を利用して、各層のモジュール(クレート)を物理的に分けていきます。
その後以下のようなコマンドを実行し、層ごとのモジュールを作成します。
% cargo init todos-controller --lib
% cargo init todos-app --lib
% cargo init todos-domain --lib
% cargo init todos-adapter --lib
これで各種モジュールの雛形ができました。
あとはひたすら開発していきます!
controller(プレゼンテーション層)
特徴的な部分のみ記載していきます。
まずは、APIのエントリーポイントとなる「プレゼンテーション層」から書いていきます。
依存関係は以下のようになりました。
todos-adapter/Cargo.toml
[package]
name = "todos-controller"
version = "0.1.0"
edition = "2021"
[dependencies]
todos-app = { path = "../todos-app" }
todos-adapter = { path = "../todos-adapter" }
todos-domain = { path = "../todos-domain" }
anyhow = "1.0.78"
axum = { version = "0.7.3", features = ["macros"] }
axum-macros = "0.4.0"
hyper = { version = "1.1.0", features = ["full"] }
mime = "0.3.17"
serde = { version = "1.0.193", features = ["derive"] }
serde_json = "1.0.108"
thiserror = "1.0.53"
tokio = { version = "1.35.1", features = ["full"] }
tower = "0.4.13"
tracing = "0.1.40"
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
validator = { version = "0.16.1", features = ["derive"] }
[profile.release]
strip = true
プレゼンテーション層では、サーバーの起動やルーターの設定などを行います。
合わせて、リクエストやパラメーターをアプリケーション層に渡す、アプリケーション層からのレスポンスをハンドリングし、適切なレスポンスやステータスコードを返す、といった領域を担当します。
処理の流れで言うと
リクエスト → controller → app → ... → cotroller → レスポンス
といった感じ。
このため、基本的には下位レイヤーである、todos-app
にのみ依存します。
が、Cargo.toml
を見るとtodos-controller
はdependencies
の中でtodos-app
・todos-adapter
・todos-domain
が読み込まれているため、自作した全てのモジュールに依存していることがわかりますね。
今回の実装では、最上位のプレゼンテーション層の中でModule
というモジュールを作成し、その中でDIを行いたかったため、このような実装にしました。
todos-controller/src/module/mod.rs
use std::sync::Arc;
use todos_adapter::repository::{health::HealthCheckRepository, todo::TodoRepositoryForMemory};
use todos_app::usecase::{health::HealthCheckUseCase, todo::TodoUseCase};
// module is a collection of use cases
pub struct Modules {
health_check_use_case: HealthCheckUseCase,
todo_use_case: TodoUseCase<TodoRepositoryForMemory>,
}
impl Modules {
pub async fn new() -> Self {
let health_check_use_case = HealthCheckUseCase::new(HealthCheckRepository::new());
let todo_use_case = TodoUseCase::new(Arc::new(TodoRepositoryForMemory::new()));
Self {
health_check_use_case,
todo_use_case,
}
}
pub fn health_check_use_case(&self) -> &HealthCheckUseCase {
&self.health_check_use_case
}
pub fn todo_use_case(&self) -> &TodoUseCase<TodoRepositoryForMemory> {
&self.todo_use_case
}
}
Module
の中ではアプリケーションで利用するアプリケーション層やドメイン層を差し替えられるようになっています。
具体的には、「普段はRDBMSに接続しているが、ローカルでテストを行いたくなった」場合にデータ取得のロジック(repository)を差し替えてオンメモリでテストしたり、することが簡単になります。
後述しますが、UseCaseは具体的なRepositoryに依存しているわけではなく、インターフェース(トレイト)に依存しているため、このようなことができます。
本来はDIコンテナなどを利用するようですが、DIについてそこまで理解が深くないため、このような実装を行いました。
このModuleは以下のようにtodos-controller/src/main.rs
で利用されます。
このmain.rs
はcargo run
のようなコマンドで一番最初に実行されるとても大事なファイルだったりします。
Axumのお作法にしたがい、非同期でプログラムを実行できるようにtokioのマクロを定義したり、ModuleをArcでラップしたり、async関数を定義したりしています。
use std::sync::Arc;
use todos_controller::{module::Modules, setup::create_app};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let module = Modules::new().await;
let _ = create_app(Arc::new(module)).await;
Ok(())
}
create_app
も見てみましょう。
todos-controller/src/setup/mod.rs
use crate::handler::{health::health_check, todo::*};
use crate::module::Modules;
use axum::{extract::Extension, routing::get, Router};
use std::{env, sync::Arc};
// All the setup code is in this file
pub async fn create_app(modules: Arc<Modules>) {
let hc_router = Router::new().route("/", get(health_check));
let todo_router = Router::new()
.route("/", get(all_todo).post(create_todo))
.route(
"/:id",
get(find_todo).delete(delete_todo).patch(update_todo),
);
let app = Router::new()
.nest("/hc", hc_router)
.nest("/todo", todo_router)
.layer(Extension(modules));
let log_level = env::var("RUST_LOG").unwrap_or("info".to_string());
env::set_var("RUST_LOG", log_level);
tracing_subscriber::fmt::init();
tracing::debug!("Starting server on");
tracing::debug!("http://localhost:3000");
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
ログ定義などの細かいtipsはありますが、基本的には「利用するパスの決定」・「付随するロジックを決定」・「サーバーの起動」を行なっています。
handlerは以下のような実装になっており、こちらもAxumのお作法に従って、記述をしていきます。
todos-controller/src/handler/todo.rs
use todos_domain::model::todo::{CreateTodo, UpdateTodo};
use axum::{
async_trait,
extract::{Extension, FromRequest, Path, Request},
http::StatusCode,
response::IntoResponse,
Json,
};
use serde::de::DeserializeOwned;
use std::sync::Arc;
use validator::Validate;
use crate::module::Modules;
#[derive(Debug)]
pub struct ValidatedJson<T>(T);
#[async_trait]
impl<T, S> FromRequest<S> for ValidatedJson<T>
where
T: DeserializeOwned + Validate,
S: Send + Sync,
{
type Rejection = (StatusCode, String);
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
let Json(value) = Json::<T>::from_request(req, state)
.await
.map_err(|rejection| {
let message = format!("Json parse error: [{}]", rejection);
(StatusCode::BAD_REQUEST, message)
})?;
value.validate().map_err(|rejection| {
let message = format!("Validation error: [{}]", rejection).replace('\n', ", ");
(StatusCode::BAD_REQUEST, message)
})?;
Ok(ValidatedJson(value))
}
}
pub async fn create_todo(
Extension(module): Extension<Arc<Modules>>,
ValidatedJson(payload): ValidatedJson<CreateTodo>,
) -> impl IntoResponse {
let todo = module.todo_use_case().create(payload).await;
(StatusCode::CREATED, Json(todo))
}
pub async fn find_todo(
Extension(module): Extension<Arc<Modules>>,
Path(id): Path<i32>,
) -> Result<impl IntoResponse, StatusCode> {
let todo = module
.todo_use_case()
.find(id)
.await
.ok_or(StatusCode::NOT_FOUND)?;
Ok((StatusCode::OK, Json(todo)))
}
pub async fn all_todo(Extension(module): Extension<Arc<Modules>>) -> impl IntoResponse {
let todo = module.todo_use_case().all().await;
(StatusCode::OK, Json(todo))
}
pub async fn update_todo(
Extension(module): Extension<Arc<Modules>>,
Path(id): Path<i32>,
ValidatedJson(payload): ValidatedJson<UpdateTodo>,
) -> Result<impl IntoResponse, StatusCode> {
let todo = module
.todo_use_case()
.update(id, payload)
.await
.or(Err(StatusCode::NOT_FOUND))?;
Ok((StatusCode::CREATED, Json(todo)))
}
pub async fn delete_todo(
Extension(module): Extension<Arc<Modules>>,
Path(id): Path<i32>,
) -> StatusCode {
module
.todo_use_case()
.delete(id)
.await
.map(|_| StatusCode::NO_CONTENT)
.unwrap_or(StatusCode::NOT_FOUND)
}
基本的には、渡されたModule内のUseCaseを実行させ、戻ってきた値を適切にハンドリングしていきます。
もしかするとtodos-domain
への依存を切っておいたり、Moduleの構成を変更させた方が良いかもしれませんが、今回はこの辺りで…
上記までの通り、controllerはアプリケーション層やドメイン(ビジネスロジック)、根底となる値オブジェクトなどは記載せず、リクエスト・レスポンスのハンドリングやDIに徹します。
app(アプリケーション層)
次に、アプリケーション(ユースケース)層となるappを見ていきましょう。
これより下位の層では、Axumに関する知識は出てきません。
依存関係も以下のようになっており、下位の層にしか依存しないようになっています。
todos-app/Cargo.toml
[package]
name = "todos-app"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1.0.79"
todos-domain = { path = "../todos-domain" }
これにより、インターフェースがhttpリクエスト(Web)であっても、CLIツールになっても、その他別のツールになっても同じような機能を持つツールを作成することができます。
便利ですね!
具体的なユースケースは以下のように定義しました。
TodoRepository
というトレイト(インターフェース)に依存してデータを取得し、値を返します。
トレイトにのみ依存しているため、データ取得の方法はサクッと切り替え可能です。
todos-app/src/usecase/todo.rs
use anyhow;
use std::sync::Arc;
use todos_domain::{
model::todo::{CreateTodo, Todo, UpdateTodo},
repository::todo::TodoRepository,
};
pub struct TodoUseCase<T: TodoRepository> {
todo_repository: Arc<T>,
}
impl<T: TodoRepository> TodoUseCase<T> {
pub fn new(todo_repository: Arc<T>) -> Self {
Self { todo_repository }
}
pub async fn all(&self) -> Vec<Todo> {
self.todo_repository.all().await
}
pub async fn find(&self, id: i32) -> Option<Todo> {
self.todo_repository.find(id).await
}
pub async fn create(&self, payload: CreateTodo) -> Todo {
self.todo_repository.create(payload).await
}
pub async fn update(&self, id: i32, payload: UpdateTodo) -> anyhow::Result<Todo> {
self.todo_repository.update(id, payload).await
}
pub async fn delete(&self, id: i32) -> anyhow::Result<()> {
self.todo_repository.delete(id).await
}
}
ただ、今回はシンプルなTodoアプリのため、アプリケーション(ユースケース)層に記述されるロジックはほぼなく、データを取得してそのまま返すだけか、シンプルにデータを登録・削除するのみです。
このため、あまり層を分割する理由もないですが、勉強のために実装してみました。
テストが簡単になるため、実際に仕事でプログラムを書く場合にはとても便利だと思います。
domain(ドメイン層)
ドメイン層では固有のビジネスロジックを定義していきます。
前述の通り、今回はシンプルなTodoアプリなので中身は薄いですが、しっかりやっていきます。
データを取得してから、ビジネスロジックを実装するという都合上、ドメイン層は「インフラ層に依存」します。
が、ドメイン層がインフラ層に依存してしまうと、ビジネスロジックが特定のデータベースに依存することになり、テストしづらく、実装もしづらいです。
このため、ドメイン層にはアプリケーション(ユースケース)の代わりとなるインターフェースのみを定義し、それをインフラ層で実装すると、データベースの知識はインフラ層に隠蔽され、データの取得方法がドメイン層に影響することもありません。
結局、依存関係は以下のようになります。
todos-domain/Cargo.toml
[package]
name = "todos-domain"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1.0.78"
async-trait = "0.1.77"
serde = { version = "1.0.193", features = ["derive"] }
thiserror = "1.0.53"
validator = { version = "0.16.1", features = ["derive"] }
本来のレイヤードアーキテクチャではドメイン層はインフラ層に依存するはずですが、上記説明の通り、依存関係を逆転させたので、何にも依存せず、尚且つここにはインターフェースのみが定義されることになります。
todos-domain/src/repository/todo.rs
use anyhow;
use async_trait::async_trait;
use thiserror::Error;
use crate::model::todo::{CreateTodo, Todo, UpdateTodo};
#[derive(Debug, Error)]
pub enum RepositoryError {
#[error("NotFound, id is {0}")]
NotFound(i32),
}
#[async_trait]
pub trait TodoRepository: Clone + std::marker::Send + std::marker::Sync + 'static {
async fn create(&self, payload: CreateTodo) -> Todo;
async fn find(&self, id: i32) -> Option<Todo>;
async fn all(&self) -> Vec<Todo>;
async fn update(&self, id: i32, payload: UpdateTodo) -> anyhow::Result<Todo>;
async fn delete(&self, id: i32) -> anyhow::Result<()>;
}
adapter(インフラストラクチャー層)
サクサクいきます!
todos-adapter/Cargo.toml
[package]
name = "todos-adapter"
version = "0.1.0"
edition = "2021"
[dependencies]
todos-domain = { path = "../todos-domain" }
anyhow = "1.0.78"
serde = { version = "1.0.193", features = ["derive"] }
thiserror = "1.0.53"
validator = { version = "0.16.1", features = ["derive"] }
async-trait = "0.1.77"
逆に、インフラ層ではドメイン層に依存し、ドメイン層に定義されたインターフェースを実装することになります。
use anyhow::Context;
use async_trait::async_trait;
use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard};
use todos_domain::{
model::todo::{CreateTodo, Todo, TodoDatas, UpdateTodo},
repository::todo::{RepositoryError, TodoRepository},
};
#[derive(Debug, Clone, Default)]
pub struct TodoRepositoryForMemory {
store: Arc<RwLock<TodoDatas>>,
}
impl TodoRepositoryForMemory {
pub fn new() -> Self {
TodoRepositoryForMemory {
store: Arc::default(),
}
}
fn write_store_ref(&self) -> RwLockWriteGuard<TodoDatas> {
self.store.write().unwrap()
}
fn read_store_ref(&self) -> RwLockReadGuard<TodoDatas> {
self.store.read().unwrap()
}
}
#[async_trait]
impl TodoRepository for TodoRepositoryForMemory {
async fn create(&self, payload: CreateTodo) -> Todo {
let mut store = self.write_store_ref();
let id = (store.len() + 1) as i32;
let todo = Todo::new(id, payload.text.clone());
store.insert(id, todo.clone());
todo
}
async fn find(&self, id: i32) -> Option<Todo> {
let store = self.read_store_ref();
store.get(&id).map(|todo| todo.clone())
}
async fn all(&self) -> Vec<Todo> {
let store = self.read_store_ref();
Vec::from_iter(store.values().map(|todo| todo.clone()))
}
async fn update(&self, id: i32, payload: UpdateTodo) -> anyhow::Result<Todo> {
let mut store = self.write_store_ref();
let todo = store.get(&id).context(RepositoryError::NotFound(id))?;
let text = payload.text.unwrap_or(todo.text.clone());
let completed = payload.completed.unwrap_or(todo.completed);
let todo = Todo {
id,
text,
completed,
};
store.insert(id, todo.clone());
Ok(todo)
}
async fn delete(&self, id: i32) -> anyhow::Result<()> {
let mut store = self.write_store_ref();
store.remove(&id).ok_or(RepositoryError::NotFound(id))?;
Ok(())
}
}
今回は、学習用プロジェクトのため、データベースは利用せず、メモリ上で動作するようにしました。
実際のデータベースに接続したいときは、このインターフェースの実装方法変更し、controllerでDIするのみで良いはずです。
余談ですが、Rustのワークスペースを利用すると依存関係を明確に切れるのでとても便利だなーと思いました。
終わりに
あらためて、リポジトリを貼っておきます。
ちょっと端折って記事を書いたので、詳しくは以下を参考にしてください!
今回はRustのAxumを利用し、Todoアプリをレイヤードアーキテクチャで構築していきました。
シンプルなTodoアプリを作っただけにしてはコード量が増え、複雑になってしまいましたが、ちゃんとしたソフトウェアを作成する際には、レイヤードアーキテクチャに限らずですがこのような実装は必須だろうなーと思いました。
これをクリーンアーキテクチャに進化(?)させたりとかも今後やっていこうと思います!
何かまちがっていることがあれば指摘いただけると嬉しいですー!