前の記事
- 【0】 準備 ← 初回
- ...
- 【36】 ブロッキング・非同期用の実装・キャンセル ~ラストスパート!~ ← 前回
- 【37】 Axumでクラサバ! ~最終回~ ← 今回
全記事一覧
- 【0】 準備
- 【1】 構文・整数・変数
- 【2】 if・パニック・演習
- 【3】 可変・ループ・オーバーフロー
- 【4】 キャスト・構造体 (たまにUFCS)
- 【5】 バリデーション・モジュールの公開範囲 ~ → カプセル化!~
- 【6】 カプセル化の続きと所有権とセッター ~そして不変参照と可変参照!~
- 【7】 スタック・ヒープと参照のサイズ ~メモリの話~
- 【8】 デストラクタ(変数の終わり)・トレイト ~終わりと始まり~
- 【9】 Orphan rule (孤児ルール)・演算子オーバーロード・derive ~Empowerment 💪 ~
- 【10】 トレイト境界・文字列・Derefトレイト ~トレイトのアレコレ~
- 【11】 Sized トレイト・From トレイト・関連型 ~おもしろトレイトと関連型~
- 【12】 Clone・Copy・Dropトレイト ~覚えるべき主要トレイトたち~
- 【13】 トレイトまとめ・列挙型・match式 ~最強のトレイトの次は、最強の列挙型~
- 【14】 フィールド付き列挙型とOption型 ~チョクワガタ~
- 【15】 Result型 ~Rust流エラーハンドリング術~
- 【16】 Errorトレイトと外部クレート ~依存はCargo.tomlに全部お任せ!~
- 【17】 thiserror・TryFrom ~トレイトもResultも自由自在!~
- 【18】 Errorのネスト・慣例的な書き方 ~Rustらしさの目醒め~
- 【19】 配列・動的配列 ~スタックが使われる配列と、ヒープに保存できる動的配列~
- 【20】 動的配列のリサイズ・イテレータ ~またまたトレイト登場!~
- 【21】 イテレータ・ライフタイム ~ライフタイム注釈ようやく登場!~
- 【22】 コンビネータ・RPIT ~ 「
Iterator
トレイトを実装してるやつ」~ - 【23】
impl Trait
・スライス ~配列の欠片~ - 【24】 可変スライス・下書き構造体 ~構造体で状態表現~
- 【25】 インデックス・可変インデックス ~インデックスもトレイト!~
- 【26】 HashMap・順序・BTreeMap ~Rustの辞書型~
- 【27】 スレッド・'staticライフタイム ~並列処理に見るRustの恩恵~
- 【28】 リーク・スコープ付きスレッド ~ライフタイムに技あり!~
- 【29】 チャネル・参照の内部可変性 ~Rustの虎の子、mpscと
Rc<RefCell<T>>
~ - 【30】 双方向通信・リファクタリング ~返信用封筒を入れよう!~
- 【31】 上限付きチャネル・PATCH機能 ~パンクしないように制御!~
- 【32】
Send
・排他的ロック(Mutex
)・非対称排他的ロック(RwLock
) ~真打Arc<Mutex<T>>
登場~ - 【33】 チャネルなしで実装・Syncの話 ~考察回です~
- 【34】
async fn
・非同期タスク生成 ~Rustの非同期入門~ - 【35】 非同期ランタイム・Futureトレイト ~非同期のお作法~
- 【36】 ブロッキング・非同期用の実装・キャンセル ~ラストスパート!~
- 【37】 Axumでクラサバ! ~最終回~
- 【おまけ1】 Rustで勘違いしていたこと3選 🏄🌴 【100 Exercises To Learn Rust 🦀 完走記事 🏃】
- 【おまけ2】 【🙇 懺悔 🙇】Qiitanグッズ欲しさに1日に33記事投稿した話またはQiita CLIとcargo scriptを布教する的な何か
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>;
}
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
トレイトに対して具体的な実装は以下のような感じです。
#[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 全体
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
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.
思いっきり本コラムに書いた内容ですね ...というわけで、最近Rust 1.75がリリースされ要らなくなったはずのasync_traitがまだ必要だったというおまけ話でした。
完走した感想
ついに Qiita Engineer Festa 2024 投稿マラソン を完走しました!...完走した感想ですが...本記事に書くと長くなってしまうので別な記事に分けようと思います!
完走記事: Rustで勘違いしていたこと3選 🏄🌴 【100 Exercises To Learn Rust 🦀 完走記事 🏃】