RustとDDDでAPIサーバーを構築する
はじめに
Rust と フレームワーク axum を使って、API サーバーを実装してみました。
対象読者
- Rust で API サーバーを実装したい人
- Rust で DDD を実装したい人
説明しないこと
- Rust の基本的な文法
- DDD の基本的な考え方
- 使用クレートの使い方
依存の方向
今回の作成する、アーキテクチャの依存関係は、上記のようになります。
上記の依存関係を頭の片隅に置いて、記事を読み進めていただけると、理解が深まると思います。
インフラストラクチャレイヤーは、アプリケーションレイヤーと依存しないことが重要です。
いざ、実装
仕様を決める
今回は、大学が、サークルを管理するシステムを作ることにしました。
- メンバーを追加できる
- 4 年生は、追加できない
- メンバーを削除できる
- オーナーは削除できない
- 4 年生は、卒業する
- サークルは最低 3 人以上でないと、活動できない
- サークルは、最大人数が決まっている
- サークルには、代表者が必要
- 20 歳以上の人は、飲み会に参加できる
- 3 年生のみ、サークルの代表者になれる
ドメインレイヤー
サークル集約
まずは、ドメインレイヤーから作成します。
サークル集約は、集約ルートになる Circle
と、集約内のメンバーを表す Member
の 2 つのエンティティから構成されます。
pub struct Circle {
pub id: CircleId, // サークルのID (Value Object)
pub name: String,
pub capacity: usize,
pub owner: Member,
pub members: Vec<Member>,
}
pub struct Member {
pub id: MemberId, // メンバーのID (Value Object)
pub name: String,
pub age: usize,
pub grade: Grade,
pub major: Major,
}
id には Value Object
を使っています。今回、Entity
と Value Object
の違いなどは、説明しませんので、興味がある方は、調べてみてください。
CircleId
use std::fmt;
use std::hash::{Hash, Hasher};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CircleId(usize);
impl CircleId {
pub fn gen() -> Self {
Self(rand::random::<usize>())
}
}
impl std::convert::From<usize> for CircleId {
fn from(id: usize) -> Self {
Self(id)
}
}
impl Hash for CircleId {
fn hash<H: Hasher>(&self, state: &mut H) {
self.0.hash(state);
}
}
impl fmt::Display for CircleId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::convert::From<CircleId> for usize {
fn from(circle_id: CircleId) -> usize {
circle_id.0
}
}
次に、集約に知識をあたえるために、メソッドを実装します。
Rust では impl
で struct
にメソッドを実装します。
use crate::domain::aggregate::member::Member;
use crate::domain::aggregate::value_object::circle_id::CircleId;
use super::value_object::grade::Grade;
use anyhow::Error;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Circle {
pub id: CircleId, // サークルのID (Value Object)
pub name: String,
pub capacity: usize,
pub owner: Member,
pub members: Vec<Member>,
}
impl Circle {
// サークルの新規作成メソッド
pub fn new(name: String, owner: Member, capacity: usize) -> Result<Self, Error> {
// オーナーは3年生のみなれる
if owner.grade != Grade::Third {
return Err(Error::msg("Owner must be 3rd grade"));
}
// サークルの定員は3人以上
if capacity < 3 {
return Err(Error::msg("Circle capacity must be 3 or more"));
}
Ok(Circle {
id: CircleId::gen(),
name,
owner,
capacity,
members: vec![],
})
}
// サークルの再構成メソッド
pub fn reconstruct(
id: CircleId,
name: String,
owner: Member,
capacity: usize,
members: Vec<Member>,
) -> Self {
Circle {
id,
name,
owner,
capacity,
members,
}
}
// サークルの更新メソッド
pub fn update(&mut self, name: Option<String>, capacity: Option<usize>) {
if let Some(name) = name {
self.name = name;
}
if let Some(capacity) = capacity {
self.capacity = capacity;
};
}
// サークルが満員かどうかを判定するメソッド
fn is_full(&self) -> bool {
self.members.len() + 1 >= self.capacity
}
// サークルが運営可能かどうかを判定するメソッド
fn is_runnable(&self) -> bool {
self.members.len() + 1 >= 3
}
// 飲み会に参加できるかどうかを判定するメソッド
fn is_drinkable_alcohol(member: &Member) -> bool {
member.is_adult()
}
// メンバーをサークルに追加するメソッド
pub fn add_member(&mut self, member: Member) -> Result<(), Error> {
// 満員の場合はサークルに入れない
if self.is_full() {
return Err(Error::msg("Circle member is full"));
}
// 4年生はサークルに入れない
if member.grade == Grade::Fourth {
return Err(Error::msg("4th grade can't join circle"));
}
self.members.push(member);
Ok(())
}
// メンバーをサークルから削除するメソッド
pub fn remove_member(&mut self, member: &Member) -> Result<(), Error> {
// オーナーは削除できない
if self.owner.id == member.id {
return Err(Error::msg("Owner can't be removed"));
}
self.members.retain(|m| m.id != member.id);
Ok(())
}
// 4年生を卒業させるメソッド
pub fn graduate(&mut self) {
self.members.retain(|m| m.grade != Grade::Fourth);
}
}
impl Member {
// メンバーの新規作成メソッド
pub fn new(name: String, age: usize, grade: Grade, major: Major) -> Self {
Member {
id: MemberId::gen(),
name,
age,
grade,
major,
}
}
// メンバーの再構成メソッド
pub fn reconstruct(id: MemberId, name: String, age: usize, grade: Grade, major: Major) -> Self {
Member {
id,
name,
age,
grade,
major,
}
}
// 20歳以上かどうかを判定するメソッド
pub fn is_adult(&self) -> bool {
self.age >= 20
}
}
インターフェース
ドメインの振る舞いを外部に公開するためのインターフェースを作成します。
サークル集約を操作するためのインターフェースを作成します。
pub trait CircleRepositoryInterface {
fn find_circle_by_id(&self, circle_id: &CircleId) -> Result<Circle, Error>;
fn create(&self, circle: &Circle) -> Result<(), Error>;
fn update(&self, circle: &Circle) -> Result<Circle, Error>;
fn delete(&self, circle: &Circle) -> Result<(), Error>;
}
trait
は、他の言語で言うところのinterface
のようなものです。
インフラストラクチャレイヤー
インフラストラクチャレイヤーは、永続化を担います。
永続化先は問いません。Firestore
や Postgres
など、なんでも良いです。
今回は、メモリに保存することにしました。
インフラストラクチャレイヤーでは、ドメインレイヤーのインターフェースを実装します。
use anyhow::Error;
use crate::domain::{
aggregate::{
circle::Circle,
member::Member,
value_object::{circle_id::CircleId, grade::Grade, major::Major, member_id::MemberId},
},
interface::circle_repository_interface::CircleRepositoryInterface,
};
use super::db::Db;
#[derive(Clone, Debug)]
pub struct CircleRepository {
db: Db,
}
impl CircleRepository {
pub fn new() -> Self {
Self { db: Db::new() }
}
}
impl CircleRepositoryInterface for CircleRepository {
fn find_circle_by_id(&self, circle_id: &CircleId) -> Result<Circle, Error> {
match self.db.get::<CircleData, _>(&circle_id.to_string())? {
Some(data) => Ok(Circle::try_from(data)?),
None => Err(Error::msg("Circle not found")),
}
}
fn create(&self, circle: &Circle) -> Result<(), Error> {
match self.db.get::<CircleData, _>(&circle.id.to_string())? {
Some(_) => Err(Error::msg("Circle already exists")),
None => {
self.db
.set(circle.id.to_string(), &CircleData::from(circle.clone()))?;
Ok(())
}
}
}
fn update(&self, circle: &Circle) -> Result<Circle, Error> {
match self.db.get::<CircleData, _>(&circle.id.to_string())? {
Some(_) => self
.db
.set(circle.id.to_string(), &CircleData::from(circle.clone()))
.and_then(|_| self.db.get::<CircleData, _>(&circle.id.to_string()))
.map(|data| match data {
Some(data) => Circle::try_from(data),
None => Err(Error::msg("Failed to convert circle data")),
})?,
None => Err(Error::msg("Circle not found")),
}
}
fn delete(&self, circle: &Circle) -> Result<(), Error> {
match self.db.get::<CircleData, _>(&circle.id.to_string())? {
Some(_) => self.db.remove(circle.id.to_string()),
None => Err(Error::msg("Circle not found")),
}
}
}
#[derive(serde::Deserialize, serde::Serialize)]
struct CircleData {
id: usize,
name: String,
owner: MemberData,
capacity: usize,
members: Vec<MemberData>,
}
impl std::convert::From<Circle> for CircleData {
fn from(circle: Circle) -> Self {
CircleData {
id: circle.id.into(),
name: circle.name,
owner: MemberData::from(circle.owner),
capacity: circle.capacity,
members: circle.members.into_iter().map(MemberData::from).collect(),
}
}
}
impl std::convert::TryFrom<CircleData> for Circle {
type Error = Error;
fn try_from(data: CircleData) -> Result<Self, Self::Error> {
Ok(Circle::reconstruct(
CircleId::from(data.id),
data.name,
Member::reconstruct(
MemberId::from(data.owner.id),
data.owner.name,
data.owner.age,
Grade::try_from(data.owner.grade)?,
Major::from(data.owner.major.as_str()),
),
data.capacity,
data.members
.into_iter()
.map(Member::try_from)
.collect::<Result<Vec<Member>, Error>>()?,
))
}
}
#[derive(serde::Deserialize, serde::Serialize)]
struct MemberData {
id: usize,
name: String,
age: usize,
grade: usize,
major: String,
}
impl std::convert::From<Member> for MemberData {
fn from(value: Member) -> Self {
Self {
id: value.id.into(),
name: value.name,
age: value.age,
grade: value.grade.into(),
major: value.major.into(),
}
}
}
impl std::convert::TryFrom<MemberData> for Member {
type Error = Error;
fn try_from(value: MemberData) -> Result<Self, Self::Error> {
Ok(Member::reconstruct(
MemberId::from(value.id),
value.name,
value.age,
Grade::try_from(value.grade)?,
Major::from(value.major.as_str()),
))
}
}
データベースから取得した値を、XxxData
という名前で表しています。今回は、CircleData
と MemberData
です。
このデータベースの型をドメインレイヤーの型に変換するために、TryFrom
トレイトを実装し、内部で、reconstruct
メソッドを使って、ドメインレイヤーのEntity
の型に変換しています。
今回は DB が オンメモリなので、あまり意味を感じにくいですが、アプリケーション層との依存をなくすのに有効です。
DB が突然、Firebase
に変わっても、データベースの型を変えるだけで、アプリケーション層には影響を与えません。
そのためにも、アプリケーション層がインフラストラクチャレイヤーに依存しないように抽象に依存させることが重要です。(後述します)
DB の実装は DDD にそれほど関係ないので、興味のある方は見てください。
メモリ上に保存する DB の実装
use std::{
collections::HashMap,
sync::{Arc, RwLock},
};
#[derive(Clone, Debug)]
pub struct Db {
db: Arc<RwLock<HashMap<String, String>>>,
}
impl Db {
pub fn new() -> Self {
Self {
db: Arc::new(RwLock::new(HashMap::new())),
}
}
pub fn get<D, K>(&self, key: K) -> anyhow::Result<Option<D>>
where
K: AsRef<str>,
D: serde::de::DeserializeOwned,
{
let db = self
.db
.read()
.map_err(|e| anyhow::anyhow!("Error reading from database: {:?}", e))?;
match db.get(key.as_ref()) {
Some(value) => {
let deserialized_value = serde_json::from_str(value)
.map_err(|e| anyhow::anyhow!("Error deserializing value: {:?}", e))?;
Ok(Some(deserialized_value))
}
None => Ok(None),
}
}
pub fn keys(&self) -> Vec<String> {
let db = self.db.read().expect("read data from db");
db.keys().cloned().collect()
}
pub fn remove<K>(&self, key: K) -> anyhow::Result<()>
where
K: AsRef<str>,
{
let mut db = self
.db
.write()
.map_err(|e| anyhow::anyhow!("Error writing to database: {:?}", e))?;
db.remove(key.as_ref())
.ok_or_else(|| anyhow::anyhow!("Key not found in database"))?;
Ok(())
}
pub fn set<S, K>(&self, key: K, value: &S) -> anyhow::Result<()>
where
K: Into<String>,
S: serde::ser::Serialize,
{
let value = serde_json::to_string(value)?;
let mut db = self
.db
.write()
.map_err(|e| anyhow::anyhow!("Error writing to database: {:?}", e))?;
db.insert(key.into(), value);
Ok(())
}
}
RawLock を使って、排他制御をしています。
アプリケーションレイヤー
アプリケーションレイヤーでは、ユースケースの実現のために、Entity
や VO
を使って、レポジトリー (インフラ層) に処理を依頼します。
レポジトリーに処理を依頼しますが、インフラ層には依存しません。
いわゆる、依存性逆転の原則を用いて、ユースケースを実現します。
実態ではなく、抽象に依存させるために、トレイトを使います。
今回は、サークルを作成するユースケースを実装します。
use anyhow::Result;
use serde::Deserialize;
use crate::domain::{
aggregate::{
circle::Circle,
member::Member,
value_object::{grade::Grade, major::Major},
},
interface::circle_repository_interface::CircleRepositoryInterface,
};
#[derive(Debug, Deserialize)]
pub struct CreateCircleInput {
pub circle_name: String,
pub capacity: usize,
pub owner_name: String,
pub owner_age: usize,
pub owner_grade: usize,
pub owner_major: String,
}
impl CreateCircleInput {
pub fn new(
circle_name: String,
capacity: usize,
owner_name: String,
owner_age: usize,
owner_grade: usize,
owner_major: String,
) -> Self {
CreateCircleInput {
circle_name,
capacity,
owner_name,
owner_age,
owner_grade,
owner_major,
}
}
}
#[derive(Debug, Deserialize)]
pub struct CreateCircleOutput {
pub circle_id: usize,
pub owner_id: usize,
}
pub struct CreateCircleUsecase<T>
where
T: CircleRepositoryInterface,
{
circle_repository: T,
}
impl<T> CreateCircleUsecase<T>
where
T: CircleRepositoryInterface,
{
pub fn new(circle_repository: T) -> Self {
CreateCircleUsecase { circle_repository }
}
pub fn execute(
&mut self,
circle_circle_input: CreateCircleInput,
) -> Result<CreateCircleOutput> {
let grade = Grade::try_from(circle_circle_input.owner_grade)?;
let major = Major::from(circle_circle_input.owner_major.as_str());
let owner = Member::new(
circle_circle_input.owner_name,
circle_circle_input.owner_age,
grade,
major,
);
let owner_id = owner.id;
let circle = Circle::new(
circle_circle_input.circle_name,
owner,
circle_circle_input.capacity,
)?;
self.circle_repository
.create(&circle)
.map(|_| CreateCircleOutput {
circle_id: usize::from(circle.id),
owner_id: usize::from(owner_id),
})
}
}
ひとつひとつ見ていきます。
-
usecase の io (入出力)
usecase の io を、CreateCircleInput
とCreateCircleOutput
として定義します。 -
CreateCircleUsecase 構造体
CreateCircleUsecase
はジェネリクス構造体です。フィールドには、CircleRepositoryInterface
トレイトを実装したものを受け取ります。 -
impl CreateCircleUsecase
CreateCircleUsecase
の構造体に対して、2つメソッド、new
とexecute
を実装します。-
new メソッド
インスタンスを生成するためのメソッドです。CircleRepositoryInterface
トレイトを実装したものを受け取ります。
他の言語で言うところのコンストラクターです。ここに、依存を注入します。実態ではなく、抽象(トレイト)を設定することで、依存性逆転の原則を実現します。 -
execute メソッド
ユースケースを実行するためのメソッドです。CreateCircleInput
を受け取り、Circle Entity
を作成し、レポジトリーに保存します。
self
とは、CreateCircleUsecase
の自身を指します。今回はフィールドが1つしかないので、self.circle_repository
でフィールドにアクセスでき、そのフィールドが、CircleRepositoryInterface
トレイトを実装していることが保証されているため、create
メソッドを呼び出すことができます。
-
これで、ユースケースはドメインのみに依存し、インフラストラクチャレイヤーに依存しないように実装できました。
プレゼンテーションレイヤー
プレゼンテーションレイヤーでは、エンドポイントの定義、リクエストの受け取り、レスポンス、アプリケーション層に渡す値のマッピングを行います。
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct CreateCircleRequestBody {
pub circle_name: String,
pub capacity: usize,
pub owner_name: String,
pub owner_age: usize,
pub owner_grade: usize,
pub owner_major: String,
}
impl std::convert::From<CreateCircleRequestBody> for CreateCircleInput {
fn from(
CreateCircleRequestBody {
circle_name,
capacity,
owner_name,
owner_age,
owner_grade,
owner_major,
}: CreateCircleRequestBody,
) -> Self {
CreateCircleInput::new(
circle_name,
capacity,
owner_name,
owner_age,
owner_grade,
owner_major,
)
}
}
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct CreateCircleResponseBody {
pub circle_id: usize,
pub owner_id: usize,
}
impl std::convert::From<CreateCircleOutput> for CreateCircleResponseBody {
fn from(
CreateCircleOutput {
circle_id,
owner_id,
}: CreateCircleOutput,
) -> Self {
CreateCircleResponseBody {
circle_id,
owner_id,
}
}
}
pub async fn handle_create_circle(
State(state): State<AppState>,
Json(body): Json<CreateCircleRequestBody>,
) -> Result<Json<CreateCircleResponseBody>, String> {
let circle_circle_input = CreateCircleInput::from(body);
let mut usecase = CreateCircleUsecase::new(state.circle_repository);
usecase
.execute(circle_circle_input)
.map(CreateCircleResponseBody::from)
.map(Json)
.map_err(|e| e.to_string())
}
#[derive(Clone)]
struct AppState {
circle_repository: CircleRepository,
}
fn router() -> Router<AppState> {
Router::new()
.route("/circle", post(handle_create_circle))
}
#[tokio::main]
async fn main() -> Result<(), ()> {
let state = AppState {
circle_repository: CircleRepository::new(),
};
let app = router().with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("Listening on: {}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
Ok(())
}
ひとつひとつ見ていきます。
-
io (入出力)
CreateCircleRequestBody
は、リクエストのボディを受け取るための構造体です。
CreateCircleRequestBody
にfrom
を実装して、CreateCircleInput
を構築できるようにしています。レスポンスも同じような形で実装しています。
-
AppState
AppState
は、アプリケーションの状態を表す構造体です。axum
が提供しています。このアプリケーションでは、レポジトリーを保持していて、依存関係を解決する DI コンテナのような役割をしています。
今回は依存が一つしかないので、ありがたみを感じられませんが、例えば、テスト時に、CircleRepository
をCircleRepositoryMock
のようなモックに差し替えることができます。 -
handle_create_circle
handle_create_circle
関数は、リクエストを受け取り、CreateCircleUsecase
を実行します。
第一引数のState(state)
ではAppState
の値を取り出せます。
取り出した、値を、CreateCircleUsecase
に注入して、execute
メソッドを呼び出します。前述したusecase
を実行している部分です。io はそれぞれ任意の形式に変換しています。
まとめ
Rust axum DDD を使って、API サーバーを実装してみました。Rust はまだまだ、業務での採用が少ないですが、バックエンドをはじめに盛り上がってくると思うので、ぜひ、使ってみてください。また、新しい、ユースケースや、集約を追加していただいたりして、学習の手助けになればと思います。
今回のソースコードは、こちら にあります。 main ブランチは更新しているので、記事執筆時点のソースコードは ver 0.1.0 を参照してください。
Discussion