🦍

RustとDDDでAPIサーバーを構築する

2024/02/29に公開

はじめに

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 を使っています。今回、EntityValue 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 では implstruct にメソッドを実装します。

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のようなものです。

インフラストラクチャレイヤー

インフラストラクチャレイヤーは、永続化を担います。
永続化先は問いません。FirestorePostgres など、なんでも良いです。
今回は、メモリに保存することにしました。

インフラストラクチャレイヤーでは、ドメインレイヤーのインターフェースを実装します。

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という名前で表しています。今回は、CircleDataMemberData です。
このデータベースの型をドメインレイヤーの型に変換するために、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 を使って、排他制御をしています。

アプリケーションレイヤー

アプリケーションレイヤーでは、ユースケースの実現のために、EntityVO を使って、レポジトリー (インフラ層) に処理を依頼します。
レポジトリーに処理を依頼しますが、インフラ層には依存しません。
いわゆる、依存性逆転の原則を用いて、ユースケースを実現します。
実態ではなく、抽象に依存させるために、トレイトを使います。
今回は、サークルを作成するユースケースを実装します。

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),
            })
    }
}

ひとつひとつ見ていきます。

  1. usecase の io (入出力)
    usecase の io を、CreateCircleInputCreateCircleOutput として定義します。

  2. CreateCircleUsecase 構造体
    CreateCircleUsecase はジェネリクス構造体です。フィールドには、CircleRepositoryInterface トレイトを実装したものを受け取ります。

  3. impl CreateCircleUsecase
    CreateCircleUsecase の構造体に対して、2つメソッド、newexecute を実装します。

    • 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(())
}

ひとつひとつ見ていきます。

  1. io (入出力)
    CreateCircleRequestBody は、リクエストのボディを受け取るための構造体です。
    CreateCircleRequestBodyfrom を実装して、CreateCircleInput を構築できるようにしています。

    レスポンスも同じような形で実装しています。

  2. AppState
    AppState は、アプリケーションの状態を表す構造体です。axum が提供しています。このアプリケーションでは、レポジトリーを保持していて、依存関係を解決する DI コンテナのような役割をしています。
    今回は依存が一つしかないので、ありがたみを感じられませんが、例えば、テスト時に、CircleRepositoryCircleRepositoryMock のようなモックに差し替えることができます。

  3. handle_create_circle
    handle_create_circle 関数は、リクエストを受け取り、CreateCircleUsecase を実行します。
    第一引数の State(state) では AppState の値を取り出せます。
    取り出した、値を、CreateCircleUsecase に注入して、execute メソッドを呼び出します。前述したusecase を実行している部分です。io はそれぞれ任意の形式に変換しています。

まとめ

Rust axum DDD を使って、API サーバーを実装してみました。Rust はまだまだ、業務での採用が少ないですが、バックエンドをはじめに盛り上がってくると思うので、ぜひ、使ってみてください。また、新しい、ユースケースや、集約を追加していただいたりして、学習の手助けになればと思います。
今回のソースコードは、こちら にあります。

GitHubで編集を提案
ドクターメイト

Discussion