2
0
お題は不問!Qiita Engineer Festa 2024で記事投稿!
Qiita Engineer Festa20242024年7月17日まで開催中!

Rust 100 Ex 🏃【37/37】 Axumでクラサバ! ~最終回~

Last updated at Posted at 2024-07-17

前の記事

全記事一覧

100 Exercise To Learn Rust 演習第37回になります!ついに最終回です!

[08_futures/08_outro] Tokio・AxumでREST API組んでみる

最終問題になります!問題指示は次のとおりです。

  • 今まで構築してきたチケット管理システムの 非同期 REST API を作りましょう!
  • 以下の機能を持つエンドポイントをさらしてください。
    • チケット作成
    • チケット詳細の取得
    • チケット編集
  • サードパーティクレート使い放題!

最後の問題だし、非同期ということでどうせならと思い、 Axum を用いてWebサーバーを建ててみました!

リポジトリ: https://github.com/anotherhollow1125/100-exercises-to-learn-rust/tree/my-solutions/exercises/08_futures/08_outro

実装方針

使用したクレートと実装方針を解説します。 Cargo.toml はこんな感じです。

Cargo.toml
[package]
name = "outro_08"
version = "0.1.0"
edition = "2021"

[dependencies]
thiserror = "1.0.61"
tokio = { version = "1", features = ["full"] }
ticket_fields = { path = "../../../helpers/ticket_fields" }
anyhow = "1.0.86"
chrono = { version = "0.4.38", features = ["now", "serde"] }
serde = { version = "1.0.204", features = ["derive"] }
sqlx = { version = "0.7.4", features = [
    "sqlite",
    "runtime-tokio-native-tls",
    "chrono",
    "uuid",
] }
uuid = { version = "1.9.1", features = ["v7", "serde"] }
shaku = { version = "0.6.1", features = ["derive"] }
async-trait = "0.1.81"
axum = "0.7.5"
serde_json = "1.0.120"
dotenvy = "0.15.7"
derive_more = "0.99.18"
http = "1.1.0"

各クレートの紹介です。ここまでのエクササイズに登場してきたものもふんだんに使っています。

クレート名 概要
ticket_fields 100 Exercisesが提供しているチケットシステム関連のユーティリティ(少し改変)
thiserror エラー定義用クレート
anyhow エラーハンドリング横着用クレート。エラー定義面倒だったので...(中途半端...)
tokio 非同期クレート
serde パース用クレート。JSONやTOMLとRustのデータ構造との変換に使用
serde_json serdeの補助クレート。JSONを取り扱うために使用
axum Webアプリケーションフレームワーククレート
http HTTP周りの汎用クレート
sqlx データベースアクセス用クレート。DBとしては今回はSQLiteを使用
shaku DI(依存性注入)用クレート
chrono 時刻・時間関係のクレート
uuid UUID取り扱い用クレート
async-trait トレイトのメソッドに async fn を使えるようにするためのクレート
dotenvy .env ファイルから環境変数を読み込めるようにするクレート
derive_more 便利なderiveマクロが揃っているクレート

実装方針は以下のようにしました。

  • 機能1: データベースの使用
    • 非同期アクセスにしたい処理の代表例ということで採用
    • SQLiteを用いることでローカルにファイルとして保存することとし、再起動してもデータを持ち越せるようにする
    • IDとしてUUIDv7を利用
    • 時刻及びバージョニングのために、 chrono を利用する
  • 機能2: キャッシュ機能
    • DBの内容をそのまま返すだけだと味気ないので、本エクササイズまでに登場した BTreeMap を利用したキャッシュを導入
    • サーバー起動後からアクセスがあったチケットをキャッシュに保存する
    • 挿入時・更新時にはキャッシュとDB両方にアクセスする
    • 取得時は、キャッシュにデータがあればそれを、なければDBから、それでもなければ None を返すようにする
  • その他特徴: DI (依存性注入)
    • 最近使用する機会があったので shaku クレートを用いて依存性注入をした
    • 具体的には、各レイヤー間はトレイトで定義されたメソッドのみを呼び出すようにすることで具象の差し替えを可能とし、具象自体はトレイトオブジェクトで実行時に差し込まれる形になっている
  • 備考
    • 躊躇なく改変は施すものの、基本的にはエクササイズで取り扱ってきたチケット管理システムに準拠するようにした

実装解説

なんちゃってレイヤードアーキテクチャとし、 web -> store -> db と依存する3層構造にしました。store がここまでのエクササイズで扱ってきたチケット管理ストアと同等のものになります。

上の階層から軽く紹介していければと思います。実行主体は src/web/mod.rsserve メソッドになっています。

src/web/mod.rs
use crate::db::{SqliteImpl, SqliteImplParameters};
use crate::store::StoreImpl;
use anyhow::anyhow;
use anyhow::Result;
use axum::routing::{get, post};
use axum::Router;
use shaku::module;
use sqlx::sqlite::SqlitePool;
use std::sync::Arc;
use tokio::net::TcpListener;

mod end_points;
use end_points::{create_ticket, get_all_ticket, get_ticket_by_id, update_ticket};

module! {
    StoreModule {
        // 今回必要な具象をここに記述
        // impl Store for StoreImplである
        // StoreImplはimpl Dbな何某を必要とする
        // impl Db for SqliteImplである
        components = [StoreImpl, SqliteImpl],
        providers = []
    }
}

pub struct Config {
    pub database_url: String,
}

pub async fn serve(Config { database_url }: Config) -> Result<()> {
    let pool = SqlitePool::connect(&database_url)
        .await
        .map_err(|e| anyhow!("[@{} in {}] {:?}", line!(), file!(), e))?;

    // StoreModuleを作成
    let store = StoreModule::builder()
        .with_component_parameters::<SqliteImpl>(SqliteImplParameters { pool })
        .build();

    // エンドポイントを定義
    // エンドポイントに対して、対応する処理には関数を指定
    let app = Router::new()
        .route("/tickets", get(get_all_ticket))
        .route("/tickets/id/:ticket_id", get(get_ticket_by_id))
        .route("/tickets/create", post(create_ticket))
        .route("/tickets/update", post(update_ticket))
        .with_state(Arc::new(store));

    // 3000番ポートで待ち受けることにする
    let listener = TcpListener::bind("0.0.0.0:3000")
        .await
        .map_err(|e| anyhow!("[@{} in {}] {:?}", line!(), file!(), e))?;

    // ループ実行
    axum::serve(listener, app)
        .await
        .map_err(|e| anyhow!("[@{} in {}] {:?}", line!(), file!(), e))?;

    Ok(())
}

エンドポイントの定義は src/web/end_points.rs にあります。

src/web/end_points.rs
use super::StoreModule;
use crate::data::{TicketDraftDto, TicketDto, TicketId, TicketPatchDto};
use crate::store::Store;
use crate::store::UpdateResult;
use axum::extract::{Json, Path, State};
use axum::response::{IntoResponse, Result};
use http::status::StatusCode;
use shaku::HasComponent;
use std::sync::Arc;
use uuid::Uuid;

#[allow(unused)]
pub async fn get_ticket_by_id(
    State(module): State<Arc<StoreModule>>,
    Path(ticket_id): Path<Uuid>,
) -> Result<Json<TicketDto>> {
    let store: &dyn Store = module.resolve_ref();

    let ticket = store.get(TicketId(ticket_id)).await?;

    match ticket {
        Some(t) => Ok(Json(t.into())),
        None => Err(StatusCode::NOT_FOUND.into()),
    }
}

#[allow(unused)]
pub async fn get_all_ticket(
    State(module): State<Arc<StoreModule>>,
) -> Result<Json<Vec<TicketDto>>> {
    let store: &dyn Store = module.resolve_ref();

    let tickets = store.get_all().await?;

    Ok(Json(tickets.into_iter().map(TicketDto::from).collect()))
}

#[allow(unused)]
pub async fn create_ticket(
    State(module): State<Arc<StoreModule>>,
    Json(draft): Json<TicketDraftDto>,
) -> Result<Json<Uuid>> {
    let store: &dyn Store = module.resolve_ref();

    let draft = draft.try_into().map_err(|e| {
        (StatusCode::BAD_REQUEST, format!("Invalid Format: {:?}", e)).into_response()
    })?;

    let id = store.add_ticket(draft).await?;

    Ok(Json(id.0))
}

#[allow(unused)]
pub async fn update_ticket(
    State(module): State<Arc<StoreModule>>,
    Json(patch): Json<TicketPatchDto>,
) -> Result<Json<UpdateResult<TicketPatchDto, TicketDto>>> {
    let store: &dyn Store = module.resolve_ref();

    let patch = patch.try_into().map_err(|e| {
        (StatusCode::BAD_REQUEST, format!("Invalid Format: {:?}", e)).into_response()
    })?;

    let update_result = store.update(patch).await?.into_dto();

    Ok(Json(update_result))
}

Axumに限らないですが、Rustのクレートの設計パターンに「関数の引数に、管理元から使えるアセット・リソースやコンテキストを指定できる」というものがあります!(名称を知らないですが...アセットパターン?Extractパターンとか...?) Axum以外だとGUIフレームワークのTauriやゲームエンジンの Bevy 等がこのパターンで書けるようになっています。

すなわち関数の引数に、リソースとして State<Arc<StoreModule>> を受け取れたり、 APIのペイロードである Json<T> を受け取れたりします。Rustの所有権システムとの相性もよく、下手にグローバル変数等を導入しなくても処理を記述できるので、とても便利な機能です。

storedb モジュールでは、Store トレイト・ Db トレイトの定義と、そのそれぞれの具体実装とを与えてDIしています。RustのトレイトとDIの相性はなかなか良さそうです。

Store トレイトを例に取ると、こんな感じにメソッドだけ定義しています、ここまでのエクササイズで見てきたものと同じですね!名前と引数型・返り値型を明確にしておけば、何をしてくれるメソッドなのか大体わかるようになっています。

Rust
#[async_trait]
pub trait Store: Interface {
    async fn add_ticket(&self, ticket: TicketDraft) -> Result<TicketId, StoreError>;

    async fn get(&self, id: TicketId) -> Result<Option<Ticket>, StoreError>;

    async fn get_all(&self) -> Result<Vec<Ticket>, StoreError>;

    async fn update(
        &self,
        patch: TicketPatch,
    ) -> Result<UpdateResult<TicketPatch, Ticket>, StoreError>;
}
src/store/mod.rs 全体
src/store/mod.rs
use crate::data::{Ticket, TicketDraft, TicketDto, TicketId, TicketPatch, TicketPatchDto};
use async_trait::async_trait;
use axum::response::{IntoResponse, Response};
use http::status::StatusCode;
use serde::{ser::SerializeStruct, Serialize};
use shaku::Interface;

pub mod with_db;
pub use with_db::StoreImpl;

pub enum UpdateResult<P, T> {
    Conflict { yours: P, now: T },
    Accept(T),
}

#[derive(Debug, thiserror::Error)]
#[error("Store Error!: {0}")]
pub struct StoreError(#[from] anyhow::Error);

impl<P, T> Serialize for UpdateResult<P, T>
where
    P: Serialize,
    T: Serialize,
{
    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
    where
        S: serde::Serializer,
    {
        match self {
            UpdateResult::Conflict { yours, now } => {
                let mut s = serializer.serialize_struct("Conflict", 2)?;
                s.serialize_field("yours", &yours)?;
                s.serialize_field("now", &now)?;
                s.end()
            }
            UpdateResult::Accept(t) => serializer.serialize_newtype_struct("Accept", &t),
        }
    }
}

#[async_trait]
pub trait Store: Interface {
    async fn add_ticket(&self, ticket: TicketDraft) -> Result<TicketId, StoreError>;

    async fn get(&self, id: TicketId) -> Result<Option<Ticket>, StoreError>;

    async fn get_all(&self) -> Result<Vec<Ticket>, StoreError>;

    async fn update(
        &self,
        patch: TicketPatch,
    ) -> Result<UpdateResult<TicketPatch, Ticket>, StoreError>;
}

impl IntoResponse for StoreError {
    fn into_response(self) -> Response {
        (StatusCode::INTERNAL_SERVER_ERROR, self.0.to_string()).into_response()
    }
}

impl UpdateResult<TicketPatch, Ticket> {
    pub fn into_dto(self) -> UpdateResult<TicketPatchDto, TicketDto> {
        match self {
            UpdateResult::Conflict { yours, now } => UpdateResult::Conflict {
                yours: yours.into(),
                now: now.into(),
            },
            UpdateResult::Accept(t) => UpdateResult::Accept(t.into()),
        }
    }
}

Store トレイトに対して具体的な実装は以下のような感じです。

src/store/with_db.rs (抜粋)
#[derive(Component)]
#[shaku(interface = Store)]
pub struct StoreImpl {
    #[shaku(inject)]
    db: Arc<dyn Db>,
    #[shaku(default)]
    cache: Mutex<BTreeMap<TicketId, Ticket>>,
}

#[async_trait]
impl Store for StoreImpl {
    async fn add_ticket(&self, ticket: TicketDraft) -> Result<TicketId, StoreError> {
        //...
    }
    //...
}

StoreImpl はそのフィールドに db: Arc<dyn Db> というデータベースへのハンドラを持っています。こちらのハンドラは要は「 Db トレイトを実装している某」で、具体的な実装は要求しておらず、DIになっています!

src/store/with_db.rs 全体
src/store/with_db.rs
use super::{Store, StoreError, UpdateResult};
use crate::data::{Status, Ticket, TicketDraft, TicketId, TicketPatch};
use crate::db::Db;
use anyhow::anyhow;
use async_trait::async_trait;
use chrono::Local;
use shaku::Component;
use std::collections::BTreeMap;
use std::sync::Arc;
use tokio::sync::Mutex;
use uuid::Uuid;

#[derive(Component)]
#[shaku(interface = Store)]
pub struct StoreImpl {
    #[shaku(inject)]
    db: Arc<dyn Db>,
    #[shaku(default)]
    cache: Mutex<BTreeMap<TicketId, Ticket>>,
}

#[async_trait]
impl Store for StoreImpl {
    async fn add_ticket(&self, ticket: TicketDraft) -> Result<TicketId, StoreError> {
        let id = TicketId(Uuid::now_v7());

        let ticket = Ticket {
            id,
            title: ticket.title,
            description: ticket.description,
            status: Status::ToDo,
            updated_at: Local::now().fixed_offset(),
        };

        self.db.insert(ticket.clone()).await?;
        self.cache.lock().await.insert(id, ticket);

        Ok(id)
    }

    async fn get(&self, id: TicketId) -> Result<Option<Ticket>, StoreError> {
        if let Some(ticket) = self.cache.lock().await.get(&id) {
            return Ok(Some(ticket.clone()));
        }

        if let Some(ticket) = self.db.select(id).await? {
            self.cache.lock().await.insert(id, ticket.clone());

            return Ok(Some(ticket));
        }

        Ok(None)
    }

    async fn get_all(&self) -> Result<Vec<Ticket>, StoreError> {
        let tickets = self.db.select_all().await?;

        // Sync cache
        let mut cache = self.cache.lock().await;
        *cache = tickets
            .iter()
            .cloned()
            .map(|t| (t.id, t))
            .collect::<BTreeMap<TicketId, Ticket>>();

        Ok(tickets)
    }

    async fn update(
        &self,
        patch: TicketPatch,
    ) -> Result<UpdateResult<TicketPatch, Ticket>, StoreError> {
        let TicketPatch {
            id,
            version,
            title,
            description,
            status,
        } = patch.clone();

        let mut ticket = match self.get(id).await {
            Ok(Some(ticket)) => ticket,
            Ok(None) => {
                return Err(anyhow!("[@ {} in {}] Ticket Not Found !", file!(), line!()).into())
            }
            Err(e) => return Err(e),
        };

        if version < ticket.updated_at {
            return Ok(UpdateResult::Conflict {
                yours: patch,
                now: ticket,
            });
        }

        ticket.updated_at = Local::now().fixed_offset();

        if let Some(title) = title {
            ticket.title = title;
        }

        if let Some(description) = description {
            ticket.description = description;
        }

        if let Some(status) = status {
            ticket.status = status;
        }

        self.db.update(ticket.clone()).await?;
        self.cache.lock().await.insert(id, ticket.clone());

        Ok(UpdateResult::Accept(ticket))
    }
}

ちなみにジェネリクス等でトレイトと具象をやり取りするようなこと(静的ディスパッチ)をせず、トレイトオブジェクト(動的ディスパッチ)を用いている理由としては、複雑な型パズルを避けるためです!

静的ディスパッチでもDIのようなことができなくはないですが、コンパイル時に全ての型の依存関係が解決している必要があります。そして、もし何かに依存している具象があらば、その具象は依存先の構造体をハッキリ記述しなければなりません。例えば、 StoreImpl に対して動的ディスパッチを使わないで実装を施すと、 一番上のmain.rs では StoreImpl<SqliteImpl> のように一々全ての構造体を書く必要が出てきます。

これでは依存先が多くなった時に記述量がえらいことになります...一方、トレイトオブジェクト(動的ディスパッチ)ならば、多少のオーバーヘッドは生じてしまいますが一回一回依存先の型が決定されている必要はありません。そのため、動的ディスパッチでDIが可能な shaku クレートを使用していました。

shaku クレートを使わなくてもトレイトオブジェクトを扱うことは可能ですが、特にセットアップにおいて記述量の削減に貢献してくれています。本来だったら依存し合う具象間で Arc による参照を張り合わなければなりませんが、 shaku はその辺りをよしなに解決してくれます!

アレ...?非同期の話あまりしなかった...まぁここまでのエクササイズで解説してきたので良しとしましょう!特に新しいことはしていないです。

async_traitがまだ必要だった話

今までトレイト内ではRPIT (Return Position Impl Trait。第22回参照)なメソッドを持つことが不可能でした。

脱糖した正体が fn xxx() -> impl Future<Output = T> である非同期関数 async fn xxx() -> T も同様であったため、「非同期関数を持ったトレイト」の定義ができなかったのです!

しかしそれでは不便なため、これまでは、非同期関数を持ったトレイトを定義するための専用のマクロ #[async_trait] を使用して非同期関数をトレイト内に定義していました。

そんな折、Rust 1.75 からトレイトが持つメソッドのRPITが可能になりました。よって「async_trait 要らなくなったか...?!」と思ったのですが、やっぱり今回は必要でしたという話です。

というのも、RPITもジェネリクスの一種であるためトレイトオブジェクトの要件を満たせず (vtableを作れず)、よって今回行ったトレイトオブジェクト(動的ディスパッチ)を利用したDIができないためです...!

PoCソースコード

Playground: https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=7246306a5a8a7dfbabf28656182f0d68

Rust (コンパイルエラー)
use std::fmt::Debug;

trait Hoge {
    fn hoge(&self);
}

trait Fuga {
    fn fuga(&self) -> impl Debug;
}

macro_rules! hogefuga {
    ($t:ty, $s:expr) => {
        impl Hoge for $t {
            fn hoge(&self) {
                println!("Hoge: {}", $s);
            }
        }
        
        impl Fuga for $t {
            fn fuga(&self) -> impl Debug {
                format!("Fuga: {}", $s)
            }
        }
    };
}

struct Bar;
struct Baz;

hogefuga!(Bar, "Bar");
hogefuga!(Baz, "Baz");

fn main() {
    let r = Bar;
    let z = Baz;

    let hoges: Vec<&dyn Hoge> = vec![&r, &z];
    for hoge in hoges {
        hoge.hoge();
    }
    
    // 「トレイトオブジェクト化できません😡」と怒られる
    let fugas: Vec<&dyn Fuga> = vec![&r, &z];
    for fuga in fugas {
        println!("{:?}", fuga.fuga());
    }   
    
}

一方で、RPITな impl Future ではなく元から動的ディスパッチする Pin<Box<dyn Future>> への脱糖(?)を行うasync_traitは、この制約を回避できます!

そしてどうやらasync_trait公式もそれをアイデンティティとしているようで、ドキュメント冒頭に上記に関する記載があります。

The stabilization of async functions in traits in Rust 1.75 did not include support for using traits containing async functions as dyn Trait.

思いっきり本コラムに書いた内容ですね :sweat_smile: ...というわけで、最近Rust 1.75がリリースされ要らなくなったはずのasync_traitがまだ必要だったというおまけ話でした。

完走した感想

ついに Qiita Engineer Festa 2024 投稿マラソン を完走しました!...完走した感想ですが...本記事に書くと長くなってしまうので別な記事に分けようと思います!

完走記事: Rustで勘違いしていたこと3選 🏄🌴 【100 Exercises To Learn Rust 🦀 完走記事 🏃】

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0