Contents
Axum の State 抽出器の仕組みと公式が推奨する実装パターン
Axum ではリクエストハンドラに対して State 抽出器を使うことで、アプリ全体で共有するデータへ安全かつ効率的にアクセスできます。本セクションでは、State<T> が内部でどのように動作し、公式ドキュメントが示すベストプラクティスを具体例とともに解説します。
State 抽出器の基本構造
axum::extract::State<T> は Router::with_state(state) で渡した値を内部的に Arc<T> に包み、リクエストごとにその Arc をクローンしてハンドラへ渡します。重要なのは、T が Clone を実装している必要はなく、Arc::clone によってポインタだけがコピーされる点です。したがって、状態オブジェクト自体に重い clone 実装を用意する必要はありません。
|
1 2 3 4 5 6 7 |
// State<T> が内部で行うこと(擬似コード) let shared = Arc::new(state); // Router::with_state の段階 fn extract<State<T>>(req: &Request) -> T { let arc_clone = Arc::clone(&shared); // ハンドラは `Arc<T>` を受け取るか、所有権を取得しない形で利用できる } |
この仕組みにより、スレッド間で安全に共有でき、所有権の移動も発生しません。公式ドキュメントでも「State は内部で Arc::clone する」ことが明記されています。
公式推奨コード例(非同期環境に適した実装)
以下は Axum アプリケーションで State を利用する際の最小構成です。ミューテックスには標準ライブラリ版ではなく、非同期タスクがブロックされない tokio::sync::Mutex を使用しています。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
use axum::{ extract::State, routing::get, Router, }; use std::{collections::HashMap, sync::Arc}; use tokio::sync::Mutex; #[derive(Clone)] struct AppState { // 複数タスクから同時に書き込み可能なカウンタ counter: Arc<Mutex<u64>>, } async fn handler(State(state): State<AppState>) -> String { // `tokio::sync::Mutex` の非同期ロックを取得 let mut cnt = state.counter.lock().await; *cnt += 1; format!("counter = {}", cnt) } #[tokio::main] async fn main() { let shared_state = AppState { counter: Arc::new(Mutex::new(0)), }; let app = Router::new() .route("/", get(handler)) .with_state(shared_state); // axum のサーバ起動(例) axum::Server::bind(&"0.0.0.0:3000".parse().unwrap()) .serve(app.into_make_service()) .await .expect("server failed"); } |
ポイントまとめ
State<T>は内部でArc::cloneされるだけなので、TにClone制約は不要です。- 非同期ハンドラでは必ず
tokio::sync::Mutex(あるいはRwLock)を使い、標準のstd::sync::Mutexがタスクをブロックしないようにします。 Router::with_state(state)→ ハンドラでState(state): State<T>の流れが公式推奨パターンです。
DI(依存性注入)としての State 活用シーン
Axum の State はアプリ全体で共有するコンテキストを提供できるため、DI コンテナ的に利用できます。このセクションでは、サービスごとにトレイトを定義し、実装とモックを切り替える手順を具体例で示します。
DI パターンの設計フロー
- 機能単位でトレイトを作成
- 例: データベースアクセス用に
UserRepositoryトレイトを定義。 - 本番実装とテスト実装を分離
- 本番は SQLx のプール、テストはインメモリのフェイク実装。
- トレイトオブジェクト (
Arc<dyn Trait + Send + Sync>) をAppStateに格納 - これによりハンドラ側は具体型を意識せずに利用可能です。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
use async_trait::async_trait; use std::{sync::Arc}; #[async_trait] pub trait UserRepository: Send + Sync { async fn find_by_id(&self, id: i64) -> Option<User>; } // 本番実装(SQLx 使用) struct PgUserRepo { pool: sqlx::PgPool } #[async_trait] impl UserRepository for PgUserRepo { async fn find_by_id(&self, id: i64) -> Option<User> { // 実際のクエリは省略 None } } // テスト用フェイク実装 struct FakeUserRepo; #[async_trait] impl UserRepository for FakeUserRepo { async fn find_by_id(&self, _id: i64) -> Option<User> { Some(User::default()) } } #[derive(Clone)] struct AppState { user_repo: Arc<dyn UserRepository>, } |
テストコードでは AppState { user_repo: Arc::new(FakeUserRepo) } を渡すだけで、ハンドラは本番と同じインターフェイスを呼び出します。
ポイントまとめ
Arc<dyn Trait>により実装の差し替えが容易になり、テストの高速化やモックの注入がシンプルに。- DI の観点からは State = アプリ全体のコンテナ と位置づけると考えやすいです。
Arc と Mutex / RwLock を使ったスレッド安全な可変共有
マルチスレッド・非同期環境で状態を変更する場合、Arc<Mutex<T>> または Arc<RwLock<T>> が一般的です。ここではそれぞれの特徴と、async コンテキストでの正しい使い方 を解説します。
Arc\<Mutex> と Arc\<RwLock> の比較
| 特性 | Arc<Mutex<T>> |
Arc<RwLock<T>> |
|---|---|---|
| 同時アクセス形態 | 1 スレッドだけが排他ロック可能 | 複数の読み取りは同時に許可、書き込みは排他 |
| 書き込み頻度が高いケース | シンプルでオーバーヘッドが小さい | 読み取りが多く書き込みが少ないと有利 |
| デッドロックリスク | ロック順序さえ守れば低め | 昇格(read → write)や二段階ロックで注意必要 |
Arc の役割
Arc が参照カウントを管理し、クローン時にポインタだけがコピーされます。したがって T 自体に Clone は不要です。
非同期環境でのミューテックス選択
tokio::sync::Mutex/RwLock- タスクをブロックせずに待機でき、スケジューラが他タスクへ切り替えます。
std::sync::Mutex(同期版)- 非同期ハンドラで使用すると、内部でスレッドをブロックしデッドロックやパフォーマンス低下の原因になります。
正しいロック取得例
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
use tokio::sync::{Mutex, RwLock}; use std::{collections::HashMap, sync::Arc}; #[derive(Clone)] struct AppState { // 書き込みが頻繁なカウンタは Mutex、読み取り中心のキャッシュは RwLock counter: Arc<Mutex<u64>>, cache: Arc<RwLock<HashMap<String, String>>>, } async fn update_counter(state: State<AppState>) { let mut guard = state.counter.lock().await; // 非同期ロック取得 *guard += 1; } // 読み取りは RwLock の read() を利用 async fn get_cache(state: State<AppState>, key: String) -> Option<String> { let cache_guard = state.cache.read().await; cache_guard.get(&key).cloned() } |
ロック時間を最小化するテクニック
- スコープを限定:
let guard = ...; …; drop(guard);のように明示的に解放。 - 順序統一:複数ロックが必要な場合は常に同じ取得順序でデッドロック回避。
- 非同期ブロッキング回避:長時間かかる I/O はロック外で実行し、ロックは状態更新だけに限定。
実装サンプル:DB 接続プール・キャッシュ・設定情報をまとめた AppState
ここまでの概念を統合し、実際に動くコード例を示します。DB プール、キャッシュ、設定情報 を一つの AppState に集約し、ハンドラで安全に利用できる構成です。
構造体定義と初期化(導入文)
以下の実装では、データベースは SQLx の非同期プール、キャッシュは RwLock<HashMap<…>>、設定情報は不変な構造体として保持します。各フィールドはすべて Arc でラップし、ハンドラが自由にクローンできるようにしています。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
use axum::{ extract::{Path, State}, routing::{get, post}, Router, }; use sqlx::postgres::PgPoolOptions; use std::{collections::HashMap, sync::Arc}; use tokio::sync::RwLock; // 設定情報は変更しない前提で Clone のみ実装 #[derive(Clone)] struct Config { host: String, port: u16, } // アプリ全体の共有状態 #[derive(Clone)] struct AppState { db_pool: Arc<sqlx::PgPool>, cache: Arc<RwLock<HashMap<String, String>>>, config: Config, } async fn init_state() -> AppState { // DB プール作成(最大 5 接続) let pool = PgPoolOptions::new() .max_connections(5) .connect("postgres://user:pass@localhost/dbname") .await .expect("DB 接続に失敗しました"); AppState { db_pool: Arc::new(pool), cache: Arc::new(RwLock::new(HashMap::new())), config: Config { host: "0.0.0.0".into(), port: 3000 }, } } |
ハンドラでの State 抽出と利用例(導入文)
次に示すハンドラは、キャッシュを先に確認し、ヒットが無ければ DB から取得してキャッシュへ書き戻すという典型的なパターンです。ロックは最小スコープで取得し、非同期 I/O はロック外で実行しています。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
// GET /users/:id → ユーザ名を取得しキャッシュに保存 async fn get_user( State(state): State<AppState>, Path(user_id): Path<i64>, ) -> Result<String, (axum::http::StatusCode, String)> { // ① キャッシュの読み取り(read ロック) { let cache = state.cache.read().await; if let Some(name) = cache.get(&user_id.to_string()) { return Ok(format!("cached: {}", name)); } } // ② DB クエリはロック外で実行 let row: (String,) = sqlx::query_as("SELECT name FROM users WHERE id = $1") .bind(user_id) .fetch_one(&*state.db_pool) // Arc をデリファレンスして参照 .await .map_err(|e| (axum::http::StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // ③ 書き込みロックでキャッシュに保存 { let mut cache = state.cache.write().await; cache.insert(user_id.to_string(), row.0.clone()); } Ok(row.0) } // POST /config → 設定情報は不変なのでコピーして更新例を示すだけ async fn update_config( State(mut state): State<AppState>, axum::extract::Json(new_cfg): axum::extract::Json<Config>, ) -> String { // Config は Clone のみで十分。ここでは単に上書き例を提示。 state.config = new_cfg; format!("config updated: {}:{}", state.config.host, state.config.port) } |
アプリ起動部(導入文)
main 関数では init_state() で作成した AppState を Router::with_state に渡すだけです。これにより、全てのルートが同一インスタンスを共有します。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
#[tokio::main] async fn main() { let state = init_state().await; let app = Router::new() .route("/users/:id", get(get_user)) .route("/config", post(update_config)) .with_state(state); axum::Server::bind(&"0.0.0.0:3000".parse().unwrap()) .serve(app.into_make_service()) .await .expect("server error"); } |
ポイントまとめ
Arc<Pool>とArc<RwLock<_>>の組み合わせで「読み取りは高速、書き込みは安全」に実装できる。- ロックの取得範囲を最小にし、非同期 I/O はロック外で行うことでデッドロックやスループット低下を防げます。
Extension 抽出器との違いと使い分けポイント
Axum には State<T> のほかに Extension\<T> という抽出器があります。どちらを選択すべきかは「可変性」「スコープ」「テスト容易性」によって決まります。このセクションでは比較表とチェックリストで判断基準を示します。
比較表(導入文)
以下の表は State と Extension の主な違いをまとめたものです。実際にプロジェクトでどちらを採用すべきか検討する際の参考にしてください。
| 観点 | State<T> |
Extension<T> |
|---|---|---|
| 所有権取得方法 | Arc<T> が内部でクローンされるので、T は Clone 不要。ハンドラは所有権を取得しない形で利用。 |
任意の型をそのまま保持でき、Clone 制約は不要。ただし取得時に値自体が移動するか参照になるかは設計次第。 |
| 可変データの扱い | Arc<Mutex<_>> / Arc<RwLock<_>> で包む必要がある。 |
同様に Arc 系を推奨。ただしミドルウェアレベルで状態を書き換えるケースは少ない。 |
| スコープ | アプリ全体 (Router::with_state) に対して一度設定すれば全ルートで共有。 |
router.layer(Extension::new(value)) でレイヤー単位に付与でき、サブルーティングごとに異なる値を持たせられる。 |
| DI 表現力 | アプリ全体のコンテナとして機能し、サービス群をまとめて管理しやすい。 | 個別サービスやリクエストスコープの情報(例: トレース ID)を付与する用途に向く。 |
| テスト置換性 | AppState 全体をモックに差し替えるだけで済む。 |
特定の Extension だけを差し替えられるため、粒度が細かいテストが可能。 |
選択指針チェックリスト(導入文)
プロジェクトの要件に合わせて以下の質問に答えてみてください。
- 全体で共有すべき状態はあるか
-
DB プール、キャッシュ、設定情報などアプリ全体で同一インスタンスを使うなら
Stateが自然。 -
ルートごとに異なる設定が必要か
-
認証スキーマやテナント別のコンフィグは
Extensionのレイヤー化で柔軟に対応できる。 -
可変データへの同時書き込みが頻繁に起こるか
-
書き換えが必要な場合は
State+Arc<Mutex>がシンプル。 -
テストで特定サービスだけを差し替える必要があるか
-
その場合は
Extensionを使うと、対象だけモックに置き換えて他は実装のままにできる。 -
所有権エラーが頻発しているか
Stateは常にArc::cloneで済むので、型設計を見直すだけで解決できることが多い。
実務的なヒント:大規模プロジェクトでは「DB・キャッシュ・設定」は
Stateに集約し、認証情報やリクエスト単位のトレース ID はExtensionで付与するとコードベースがすっきりします。
まとめと次に取るべきアクション
本稿では以下の点を解説しました。
- State の内部実装:
Arc::cloneによって所有権は移動せず、TがCloneを要求されないこと。 - 非同期環境でのミューテックス選択:
tokio::sync::Mutex/RwLockの使用が必須である理由。 - DI パターンとしての State:トレイトオブジェクトと
Arcによる実装差し替え手法。 - Arc と Mutex / RwLock の比較とベストプラクティス:ロック時間短縮・デッドロック回避策。
- 実装サンプル:DB 接続プール、キャッシュ、設定情報をまとめた
AppStateとハンドラでの具体的な利用例。 - Extension との比較と選択指針:スコープ・可変性・テスト容易性に基づく判断基準。
これらのパターンを自プロジェクトへ取り入れることで、安全かつテストしやすい状態共有が実現します。まずは以下のステップで導入を検討してください。
- 既存コードで
std::sync::Mutexを使用している非同期ハンドラをtokio::sync::Mutexに置き換える。 - アプリ全体で共有するデータ構造を
AppStateに集約し、Router::with_stateへ渡す。 - DI が必要なサービスはトレイトオブジェクト化し、テスト時にモック実装と差し替えるパイプラインを作る。
- 必要に応じて
Extensionを導入し、ルートごとのカスタム設定やリクエストスコープ情報を管理する。
サンプルコードは GitHub リポジトリ(https://github.com/yourname/axum-state-example)でも公開しています。ぜひフォークして自分のプロジェクトに合わせて拡張・改善し、実際に動かしてみてください。
参考文献
- Axum 公式ドキュメント – State 抽出器
- Tokio ドキュメント –
tokio::sync::Mutex/RwLock - Zenn 記事「axum State の仕組み」(https://zenn.dev/sbk0716/articles/e1f1c0de4d68e9)
これらを併せて読むことで、さらに深い理解が得られます。 Happy coding!