More than 1 year has passed since last update.
Rust 100 Ex 🏃【37/37】 Axumでクラサバ! ~最終回~
前の記事
- 【0】 準備 ← 初回
- ...
- 【36】 ブロッキング・非同期用の実装・キャンセル ~ラストスパート!~ ← 前回
- 【37】 Axumでクラサバ! ~最終回~ ← 今回
100 Exercise To Learn Rust 演習第37回になります!ついに最終回です!
[08_futures/08_outro] Tokio・AxumでREST API組んでみる
最終問題になります!問題指示は次のとおりです。
- 今まで構築してきたチケット管理システムの 非同期 REST API を作りましょう!
- 以下の機能を持つエンドポイントをさらしてください。
- チケット作成
- チケット詳細の取得
- チケット編集
- サードパーティクレート使い放題!
最後の問題だし、非同期ということでどうせならと思い、 Axum を用いてWebサーバーを建ててみました!
実装方針
使用したクレートと実装方針を解説します。 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を返すようにする
- DBの内容をそのまま返すだけだと味気ないので、本エクササイズまでに登場した
- その他特徴: DI (依存性注入)
- 最近使用する機会があったので
shakuクレートを用いて依存性注入をした - 具体的には、各レイヤー間はトレイトで定義されたメソッドのみを呼び出すようにすることで具象の差し替えを可能とし、具象自体はトレイトオブジェクトで実行時に差し込まれる形になっている
- 最近使用する機会があったので
- 備考
- 躊躇なく改変は施すものの、基本的にはエクササイズで取り扱ってきたチケット管理システムに準拠するようにした
実装解説
なんちゃってレイヤードアーキテクチャとし、 web -> store -> db と依存する3層構造にしました。store がここまでのエクササイズで扱ってきたチケット管理ストアと同等のものになります。
上の階層から軽く紹介していければと思います。実行主体は src/web/mod.rs の serve メソッドになっています。
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 にあります。
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の所有権システムとの相性もよく、下手にグローバル変数等を導入しなくても処理を記述できるので、とても便利な機能です。
store や db モジュールでは、Store トレイト・ Db トレイトの定義と、そのそれぞれの具体実装とを与えてDIしています。RustのトレイトとDIの相性はなかなか良さそうです。
Store トレイトを例に取ると、こんな感じにメソッドだけ定義しています、ここまでのエクササイズで見てきたものと同じですね!名前と引数型・返り値型を明確にしておけば、何をしてくれるメソッドなのか大体わかるようになっています。
#[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>;
}
Store トレイトに対して具体的な実装は以下のような感じです。
#[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になっています!
ちなみにジェネリクス等でトレイトと具象をやり取りするようなこと(静的ディスパッチ)をせず、トレイトオブジェクト(動的ディスパッチ)を用いている理由としては、複雑な型パズルを避けるためです!
静的ディスパッチでも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ができないためです...!
一方で、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 🦀 完走記事 🏃】
Register as a new user and use Qiita more conveniently
- You get articles that match your needs
- You can efficiently read back useful information
- You can use dark theme
