Axumの概要と設計思想

Axumは、Rustの非同期ランタイムであるTokioチームが開発・メンテナンスしているWebアプリケーションフレームワークです。HTTPの低レベル処理を担うHyperと、ミドルウェアの抽象化レイヤーであるTowerの上に構築されており、Rustエコシステムの中核コンポーネントとシームレスに統合される設計になっています。

GitHub上のスター数は約24,900、crates.ioでの累計ダウンロード数は2億3,700万回を超えています。出典: crates.io 出典: GitHub

Axumの設計には3つの明確な方針があります。

1. Tokioエコシステムとの一体化

独自のランタイムや独自の抽象化レイヤーを持たず、Tokio・Tower・Hyperという既に広く使われているクレートの上に薄いAPIレイヤーを提供するという方針です。これにより、Tower向けに書かれたミドルウェア(タイムアウト、レート制限、認証など)をそのままAxumで利用できます。

2. マクロに頼らない型駆動設計

Actix-webやRocketがプロシージャルマクロを多用するのに対し、Axumはマクロの使用を最小限に抑えています。ルーティングやリクエスト処理はRustの型システムとトレイトによって実現されており、コンパイル時に不整合を検出できます。

3. 学習曲線の緩和

Axumが要求する独自概念は少なく、HandlerExtractorRouterという3つの概念を理解すれば基本的なWebアプリケーションを構築できます。TowerのServiceトレイトを理解していればミドルウェアの実装もスムーズです。


Axum 0.8の主な変更点

2025年1月1日にリリースされたAxum 0.8は、APIの一貫性向上と開発体験の改善を目的としたメジャーアップデートです。最新バージョンは2025年12月20日にリリースされたv0.8.8です。出典: Tokio公式ブログ 出典: crates.io

パスパラメータの構文変更

0.7以前ではコロン記法/:idを使用していましたが、0.8からは波括弧記法/{id}に変更されました。

// 0.7以前
Router::new().route("/users/:id", get(get_user));

// 0.8以降
Router::new().route("/users/{id}", get(get_user));

波括弧記法はOpenAPI仕様や多くのWebフレームワークで採用されている標準的な記法であり、エコシステム全体との整合性が向上しています。

#[async_trait]マクロの廃止

Rust 1.75で安定化されたRPITIT(Return Position Impl Trait in Traits)により、FromRequestFromRequestPartsトレイトの実装に#[async_trait]マクロが不要になりました。

// 0.7以前
#[async_trait]
impl<S> FromRequest<S> for MyExtractor
where
    S: Send + Sync,
{
    type Rejection = StatusCode;

    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
        // ...
    }
}

// 0.8以降(#[async_trait]が不要)
impl<S> FromRequest<S> for MyExtractor
where
    S: Send + Sync,
{
    type Rejection = StatusCode;

    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
        // ...
    }
}

外部マクロへの依存が減り、コンパイル時間の短縮とエラーメッセージの明瞭化が実現されています。

ユーティリティのフィーチャーゲート化

一部のユーティリティがフィーチャーフラグの背後に移動しました。バイナリサイズの削減と、必要な機能だけを取り込む設計が可能です。

[dependencies]
axum = { version = "0.8", features = ["json", "query"] }

今後の展望

Axum 0.9の開発が進行中であり、GitHubリポジトリのmainブランチにはすでに破壊的変更が含まれています。出典: GitHub README


Axumの特徴と強み

Axumが他のRust Webフレームワークと一線を画す技術的な強みを、具体的に掘り下げます。

型安全なエクストラクタシステム

Axumのエクストラクタは、HTTPリクエストの各部分(パス、クエリ、ヘッダ、ボディ)をRustの型に自動的にデシリアライズする仕組みです。型が合わなければコンパイルエラーになるため、ランタイムエラーを事前に防止できます。

use axum::extract::{Path, Query};
use serde::Deserialize;

#[derive(Deserialize)]
struct Pagination {
    page: u32,
    per_page: u32,
}

async fn get_user(
    Path(user_id): Path<u64>,
    Query(pagination): Query<Pagination>,
) -> String {
    format!(
        "User {} - page {}, {} items",
        user_id, pagination.page, pagination.per_page
    )
}

ハンドラの引数にエクストラクタを並べるだけで、Axumが自動的にリクエストからデータを抽出します。引数の型を変えればバリデーションも変わるため、ドキュメントとコードの乖離が起きません。

Towerミドルウェアの完全互換

Towerは、Rustエコシステムにおけるミドルウェアの標準仕様です。Axumは TowerのServiceトレイトとLayerトレイトをそのままサポートしているため、以下のような既存クレートを設定なしで利用できます。

クレート機能
tower-http::corsCORSヘッダ制御
tower-http::compressionレスポンス圧縮(gzip, br)
tower-http::traceリクエストトレーシング
tower-http::timeoutリクエストタイムアウト
tower-http::limitリクエストボディサイズ制限
tower::retryリトライ処理

これらはActix-webやRocket固有のミドルウェアとは異なり、Axum以外のTowerベースサービス(gRPCのtonicなど)でも共用できます。

メモリ効率とゼロコスト抽象化

再現可能なベンチマーク(rust_web_benchmark)によると、Axumの起動時メモリ使用量は約0.75MiBであり、Actix-webの1.3MiB、Rocketの0.97MiBと比較して最も軽量です。負荷時のピークメモリもAxum/Actix-webが71MiBに対し、Rocketは102MiBとなっています。

この効率性は、Rustのゼロコスト抽象化と、不要な機能を含めないフィーチャーゲート設計によるものです。

コンパイル時のルーティング検証

ハンドラのシグネチャがルーティングの要件を満たさない場合、実行時ではなくコンパイル時にエラーが報告されます。たとえば、Path<u64>を期待するルートに文字列パラメータが渡されるような不整合は、cargo checkの段階で検出されます。


開発環境のセットアップとHello World

AxumでRust Webアプリケーションを始める手順を示します。

前提条件

Rustのツールチェインが必要です。未インストールの場合はrustupで導入します。

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup default stable

プロジェクト作成

cargo new axum-demo
cd axum-demo

Cargo.tomlの設定

[package]
name = "axum-demo"
version = "0.1.0"
edition = "2021"

[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

Axum 0.8はTokio 1.x系に対応しています。tokiofullフィーチャーを有効にすると、マルチスレッドランタイム・ネットワークIO・タイマーなどが一括で利用可能になります。

Hello Worldの実装

src/main.rsを以下の内容に書き換えます。

use axum::{routing::get, Router};

#[tokio::main]
async fn main() {
    let app = Router::new().route("/", get(|| async { "Hello, World!" }));

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
        .await
        .unwrap();

    println!("Listening on http://localhost:3000");
    axum::serve(listener, app).await.unwrap();
}
cargo run

ブラウザでhttp://localhost:3000にアクセスすると「Hello, World!」が表示されます。

axum::serveの引数がTcpListenerRouterのみという点が特徴です。0.7以前のServer::bindと比べてAPIが簡素化されています。


ルーティングとハンドラの実装

AxumのルーティングはRouter構造体を中心に組み立てます。

基本的なルーティング

use axum::{
    routing::{get, post, put, delete},
    Router,
};

let app = Router::new()
    .route("/", get(index))
    .route("/users", get(list_users).post(create_user))
    .route("/users/{id}", get(get_user).put(update_user).delete(delete_user));

HTTPメソッドごとにハンドラを割り当てる形式です。同一パスに対して複数のメソッドをチェインで記述できます。

パスパラメータ

0.8では波括弧{}でパスパラメータを定義します。

use axum::extract::Path;

async fn get_user(Path(id): Path<u64>) -> String {
    format!("User ID: {}", id)
}

// 複数のパスパラメータ
async fn get_user_post(
    Path((user_id, post_id)): Path<(u64, u64)>,
) -> String {
    format!("User {} / Post {}", user_id, post_id)
}

let app = Router::new()
    .route("/users/{id}", get(get_user))
    .route("/users/{user_id}/posts/{post_id}", get(get_user_post));

クエリパラメータ

QueryエクストラクタでURLクエリ文字列を構造体にデシリアライズします。

use axum::extract::Query;
use serde::Deserialize;

#[derive(Deserialize)]
struct SearchParams {
    q: String,
    page: Option<u32>,
    sort: Option<String>,
}

async fn search(Query(params): Query<SearchParams>) -> String {
    let page = params.page.unwrap_or(1);
    format!("Searching '{}' - page {}", params.q, page)
}

ルートのネスト

大規模アプリケーションではルートをモジュール単位で分割し、nestでまとめます。

fn user_routes() -> Router {
    Router::new()
        .route("/", get(list_users).post(create_user))
        .route("/{id}", get(get_user).put(update_user).delete(delete_user))
}

fn post_routes() -> Router {
    Router::new()
        .route("/", get(list_posts).post(create_post))
        .route("/{id}", get(get_post))
}

let app = Router::new()
    .nest("/users", user_routes())
    .nest("/posts", post_routes());

/users配下のルートと/posts配下のルートがそれぞれ独立した関数として定義されるため、ファイル分割も容易です。


リクエスト/レスポンス処理

JSONリクエストの受信

Jsonエクストラクタを使うと、リクエストボディのJSONを構造体に変換できます。

use axum::Json;
use serde::{Deserialize, Serialize};

#[derive(Deserialize)]
struct CreateUser {
    name: String,
    email: String,
}

#[derive(Serialize)]
struct UserResponse {
    id: u64,
    name: String,
    email: String,
}

async fn create_user(
    Json(payload): Json<CreateUser>,
) -> Json<UserResponse> {
    let response = UserResponse {
        id: 1,
        name: payload.name,
        email: payload.email,
    };
    Json(response)
}

Content-Typeがapplication/jsonでない場合や、JSONのパースに失敗した場合は、Axumが自動的に400 Bad Requestを返します。

アプリケーション状態の共有(State)

データベースプールや設定値などをハンドラ間で共有するにはStateエクストラクタを使います。

use axum::extract::State;
use std::sync::Arc;

#[derive(Clone)]
struct AppState {
    db_pool: sqlx::PgPool,
    config: Arc<AppConfig>,
}

struct AppConfig {
    api_key: String,
    max_retries: u32,
}

async fn get_users(
    State(state): State<AppState>,
) -> Json<Vec<UserResponse>> {
    // state.db_pool を使ってデータベースにアクセス
    todo!()
}

// Routerに状態を注入
let state = AppState {
    db_pool: pool,
    config: Arc::new(config),
};

let app = Router::new()
    .route("/users", get(get_users))
    .with_state(state);

StateCloneを要求するため、Arcを使って共有参照にするパターンが一般的です。

ヘッダとステータスコードの制御

use axum::{
    http::{StatusCode, HeaderMap},
    response::IntoResponse,
};

async fn custom_response() -> impl IntoResponse {
    let mut headers = HeaderMap::new();
    headers.insert("X-Custom-Header", "my-value".parse().unwrap());

    (StatusCode::CREATED, headers, Json(serde_json::json!({
        "message": "Resource created"
    })))
}

タプルで(StatusCode, Headers, Body)を返すと、Axumがそれぞれを HTTPレスポンスの対応する部分に変換します。

カスタムエクストラクタの実装

独自のエクストラクタを定義することで、認証トークンの検証などの横断的関心事をハンドラの引数として表現できます。

use axum::{
    extract::FromRequestParts,
    http::{request::Parts, StatusCode},
};

struct AuthenticatedUser {
    user_id: u64,
    role: String,
}

impl<S> FromRequestParts<S> for AuthenticatedUser
where
    S: Send + Sync,
{
    type Rejection = StatusCode;

    async fn from_request_parts(
        parts: &mut Parts,
        _state: &S,
    ) -> Result<Self, Self::Rejection> {
        let auth_header = parts
            .headers
            .get("Authorization")
            .and_then(|v| v.to_str().ok())
            .ok_or(StatusCode::UNAUTHORIZED)?;

        // トークンを検証してユーザー情報を取得
        let user = verify_token(auth_header)
            .map_err(|_| StatusCode::UNAUTHORIZED)?;

        Ok(user)
    }
}

// ハンドラで使うだけで認証が自動適用される
async fn protected_route(user: AuthenticatedUser) -> String {
    format!("Welcome, user {}", user.user_id)
}

FromRequestPartsトレイトを実装するだけで、ハンドラの引数に追加するだけで認証ロジックが実行されます。0.8では#[async_trait]が不要な点に注目してください。


ミドルウェアとエラーハンドリング

Towerレイヤーによるミドルウェア適用

TowerのLayerを使ってミドルウェアを適用します。tower-httpクレートには実用的なミドルウェアが多数用意されています。

[dependencies]
tower-http = { version = "0.6", features = ["cors", "compression", "trace", "timeout"] }
tracing = "0.1"
tracing-subscriber = "0.3"
use axum::Router;
use tower_http::{
    cors::{CorsLayer, Any},
    compression::CompressionLayer,
    trace::TraceLayer,
    timeout::TimeoutLayer,
};
use std::time::Duration;

let app = Router::new()
    .route("/", get(index))
    .route("/users", get(list_users))
    .layer(TraceLayer::new_for_http())
    .layer(CompressionLayer::new())
    .layer(
        CorsLayer::new()
            .allow_origin(Any)
            .allow_methods(Any)
            .allow_headers(Any),
    )
    .layer(TimeoutLayer::new(Duration::from_secs(30)));

.layer()はスタック構造で適用されます。最後に追加したレイヤーが最も外側(リクエストを最初に受け取る側)になります。

カスタムミドルウェアの実装

axum::middleware::from_fnを使うと、関数ベースで簡潔にミドルウェアを書けます。

use axum::{
    middleware::{self, Next},
    http::Request,
    response::Response,
};
use std::time::Instant;

async fn timing_middleware(
    request: Request<axum::body::Body>,
    next: Next,
) -> Response {
    let start = Instant::now();
    let path = request.uri().path().to_string();
    let method = request.method().to_string();

    let response = next.run(request).await;

    let duration = start.elapsed();
    tracing::info!(
        method = %method,
        path = %path,
        duration_ms = %duration.as_millis(),
        status = %response.status(),
        "Request completed"
    );

    response
}

let app = Router::new()
    .route("/", get(index))
    .layer(middleware::from_fn(timing_middleware));

構造化エラーハンドリング

Axumでのエラーハンドリングは、RustのResult型を活用するパターンが推奨されます。

use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
    Json,
};

// アプリケーション固有のエラー型
enum AppError {
    NotFound(String),
    BadRequest(String),
    InternalError(anyhow::Error),
}

// エラー型をHTTPレスポンスに変換
impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
            AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
            AppError::InternalError(err) => {
                tracing::error!("Internal error: {:?}", err);
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    "Internal server error".to_string(),
                )
            }
        };

        let body = serde_json::json!({
            "error": message,
        });

        (status, Json(body)).into_response()
    }
}

// anyhow::Errorからの自動変換
impl From<anyhow::Error> for AppError {
    fn from(err: anyhow::Error) -> Self {
        AppError::InternalError(err)
    }
}

// ハンドラでResult<T, AppError>を返す
async fn get_user(Path(id): Path<u64>) -> Result<Json<UserResponse>, AppError> {
    let user = find_user(id)
        .await
        .ok_or_else(|| AppError::NotFound(format!("User {} not found", id)))?;

    Ok(Json(user))
}

IntoResponseトレイトを実装したエラー型を定義することで、ハンドラ内では?演算子によるエラー伝搬が自然に書けます。


REST API構築の実践例

タスク管理APIを題材に、AxumでCRUD APIを構築する流れを示します。

プロジェクト構成

src/
├── main.rs          # エントリポイント
├── handlers.rs      # ハンドラ関数
└── models.rs        # データモデル

データモデルの定義(models.rs)

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Task {
    pub id: u64,
    pub title: String,
    pub description: Option<String>,
    pub completed: bool,
}

#[derive(Debug, Deserialize)]
pub struct CreateTask {
    pub title: String,
    pub description: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct UpdateTask {
    pub title: Option<String>,
    pub description: Option<String>,
    pub completed: Option<bool>,
}

アプリケーション状態とハンドラ(handlers.rs)

use axum::{
    extract::{Path, State},
    http::StatusCode,
    Json,
};
use std::sync::Arc;
use tokio::sync::RwLock;
use std::collections::HashMap;

use crate::models::{Task, CreateTask, UpdateTask};

pub type SharedState = Arc<RwLock<HashMap<u64, Task>>>;

pub async fn list_tasks(
    State(state): State<SharedState>,
) -> Json<Vec<Task>> {
    let tasks = state.read().await;
    let mut result: Vec<Task> = tasks.values().cloned().collect();
    result.sort_by_key(|t| t.id);
    Json(result)
}

pub async fn create_task(
    State(state): State<SharedState>,
    Json(payload): Json<CreateTask>,
) -> (StatusCode, Json<Task>) {
    let mut tasks = state.write().await;
    let id = tasks.len() as u64 + 1;

    let task = Task {
        id,
        title: payload.title,
        description: payload.description,
        completed: false,
    };

    tasks.insert(id, task.clone());
    (StatusCode::CREATED, Json(task))
}

pub async fn get_task(
    State(state): State<SharedState>,
    Path(id): Path<u64>,
) -> Result<Json<Task>, StatusCode> {
    let tasks = state.read().await;
    tasks
        .get(&id)
        .cloned()
        .map(Json)
        .ok_or(StatusCode::NOT_FOUND)
}

pub async fn update_task(
    State(state): State<SharedState>,
    Path(id): Path<u64>,
    Json(payload): Json<UpdateTask>,
) -> Result<Json<Task>, StatusCode> {
    let mut tasks = state.write().await;
    let task = tasks.get_mut(&id).ok_or(StatusCode::NOT_FOUND)?;

    if let Some(title) = payload.title {
        task.title = title;
    }
    if let Some(description) = payload.description {
        task.description = Some(description);
    }
    if let Some(completed) = payload.completed {
        task.completed = completed;
    }

    Ok(Json(task.clone()))
}

pub async fn delete_task(
    State(state): State<SharedState>,
    Path(id): Path<u64>,
) -> Result<StatusCode, StatusCode> {
    let mut tasks = state.write().await;
    tasks
        .remove(&id)
        .map(|_| StatusCode::NO_CONTENT)
        .ok_or(StatusCode::NOT_FOUND)
}

ルーティングとサーバー起動(main.rs)

use axum::{
    routing::get,
    Router,
};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;

mod handlers;
mod models;

use handlers::SharedState;

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt::init();

    let state: SharedState = Arc::new(RwLock::new(HashMap::new()));

    let app = Router::new()
        .route("/tasks", get(handlers::list_tasks).post(handlers::create_task))
        .route(
            "/tasks/{id}",
            get(handlers::get_task)
                .put(handlers::update_task)
                .delete(handlers::delete_task),
        )
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
        .await
        .unwrap();

    tracing::info!("Listening on http://localhost:3000");
    axum::serve(listener, app).await.unwrap();
}

動作確認

# タスク作成
curl -X POST http://localhost:3000/tasks \
  -H "Content-Type: application/json" \
  -d '{"title":"Axumを学ぶ","description":"チュートリアルを完了する"}'

# タスク一覧取得
curl http://localhost:3000/tasks

# タスク更新
curl -X PUT http://localhost:3000/tasks/1 \
  -H "Content-Type: application/json" \
  -d '{"completed":true}'

# タスク削除
curl -X DELETE http://localhost:3000/tasks/1

この例ではインメモリのHashMapを使用していますが、本番環境ではデータベースに置き換えます。


データベース連携(SQLx)

Rustの非同期SQLクライアントであるSQLxをAxumと組み合わせる方法を示します。SQLxはコンパイル時にSQLクエリの型チェックを行うため、Rustの安全性をデータベース層まで拡張できます。

依存関係の追加

[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "chrono", "uuid"] }
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }

データベーススキーマ

CREATE TABLE tasks (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    title VARCHAR(255) NOT NULL,
    description TEXT,
    completed BOOLEAN NOT NULL DEFAULT FALSE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

アプリケーション状態にプールを組み込む

use sqlx::postgres::PgPoolOptions;

#[derive(Clone)]
struct AppState {
    pool: sqlx::PgPool,
}

#[tokio::main]
async fn main() {
    let database_url = std::env::var("DATABASE_URL")
        .expect("DATABASE_URL must be set");

    let pool = PgPoolOptions::new()
        .max_connections(20)
        .connect(&database_url)
        .await
        .expect("Failed to create pool");

    // マイグレーション実行
    sqlx::migrate!("./migrations")
        .run(&pool)
        .await
        .expect("Failed to run migrations");

    let state = AppState { pool };

    let app = Router::new()
        .route("/tasks", get(list_tasks).post(create_task))
        .route("/tasks/{id}", get(get_task).put(update_task).delete(delete_task))
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
        .await
        .unwrap();

    axum::serve(listener, app).await.unwrap();
}

SQLxを使ったハンドラ実装

use axum::extract::{Path, State};
use axum::Json;
use axum::http::StatusCode;
use sqlx::FromRow;
use uuid::Uuid;
use chrono::{DateTime, Utc};

#[derive(Debug, Serialize, FromRow)]
struct Task {
    id: Uuid,
    title: String,
    description: Option<String>,
    completed: bool,
    created_at: DateTime<Utc>,
    updated_at: DateTime<Utc>,
}

async fn list_tasks(
    State(state): State<AppState>,
) -> Result<Json<Vec<Task>>, StatusCode> {
    let tasks = sqlx::query_as::<_, Task>(
        "SELECT id, title, description, completed, created_at, updated_at
         FROM tasks ORDER BY created_at DESC"
    )
    .fetch_all(&state.pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok(Json(tasks))
}

async fn create_task(
    State(state): State<AppState>,
    Json(payload): Json<CreateTask>,
) -> Result<(StatusCode, Json<Task>), StatusCode> {
    let task = sqlx::query_as::<_, Task>(
        "INSERT INTO tasks (title, description)
         VALUES ($1, $2)
         RETURNING id, title, description, completed, created_at, updated_at"
    )
    .bind(&payload.title)
    .bind(&payload.description)
    .fetch_one(&state.pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok((StatusCode::CREATED, Json(task)))
}

async fn get_task(
    State(state): State<AppState>,
    Path(id): Path<Uuid>,
) -> Result<Json<Task>, StatusCode> {
    let task = sqlx::query_as::<_, Task>(
        "SELECT id, title, description, completed, created_at, updated_at
         FROM tasks WHERE id = $1"
    )
    .bind(id)
    .fetch_optional(&state.pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
    .ok_or(StatusCode::NOT_FOUND)?;

    Ok(Json(task))
}

sqlx::query_asにより、SQLの結果が自動的にTask構造体にマッピングされます。$1のようなプレースホルダと.bind()メソッドによりSQLインジェクションが防止されます。

マイグレーション管理

SQLxにはマイグレーションツールが同梱されています。

# SQLx CLIのインストール
cargo install sqlx-cli

# マイグレーションファイル作成
sqlx migrate add create_tasks_table

# マイグレーション実行
sqlx migrate run

./migrations/ディレクトリにマイグレーションファイルが生成され、アプリケーション起動時にsqlx::migrate!()マクロで自動適用することも可能です。


Actix-web・Rocketとの性能比較

Rustの主要Webフレームワーク3つの技術的な違いを整理します。

ベンチマーク比較

以下は再現可能なオープンソースベンチマークに基づくパフォーマンスデータです。

Hello Worldスループットrust-web-benchmarks、AMD Ryzen 9 5900X、500並列接続):

フレームワークリクエスト/秒平均レイテンシメモリ使用量
Actix-web933,2970.54ms13.6 MB
Axum768,6070.65ms12.1 MB
Rocket396,5011.26ms19.1 MB

現実的なワークロードrust_web_benchmark、100並列タスク):

テスト種別Axum (req/s)Actix-web (req/s)Rocket (req/s)
単純レスポンス12,01012,4837,419
I/O待機あり(20ms遅延)3,7993,7893,696
CPU負荷(bcrypt)938691

Hello Worldのような純粋なHTP処理ではActix-webが約21%高いスループットを記録しますが、データベースアクセスやCPU処理など現実的なワークロードが加わると、3フレームワーク間の差はほぼ消失します。

メモリ使用量rust_web_benchmark):

状態AxumActix-webRocket
起動時0.75 MiB1.3 MiB0.97 MiB
負荷時ピーク71 MiB71 MiB102 MiB
負荷後0.91 MiB2.4 MiB1.8 MiB

Axumは起動時・負荷後ともに最も少ないメモリ使用量を示しています。

アーキテクチャの違い

観点AxumActix-webRocket
非同期ランタイムTokio(必須)Tokio(デフォルト)/ カスタム可Tokio(0.5以降)
ミドルウェア基盤Tower(エコシステム共通)独自ミドルウェアシステムFairing(独自概念)
マクロ依存度低(型ベース)中(#[get]等のマクロ)高(#[get] + #[launch]
HTTP実装Hyper独自(actix-http)Hyper(0.5以降)
状態管理Stateエクストラクタweb::DataStateマネージド

フレームワーク選定の判断基準

Axumが適するケース:

  • Tokioエコシステム(tonic, reqwest等)と統合する必要がある場合
  • Towerミドルウェアを活用したい場合
  • マクロよりも明示的な型ベースの設計を好む場合
  • メモリ効率が重要なマイクロサービスを構築する場合

Actix-webが適するケース:

  • スループットの最大化が最優先の場合
  • 成熟したエコシステムと日本語の情報が必要な場合
  • WebSocketを多用するリアルタイムアプリケーションの場合

Rocketが適するケース:

  • Rust初学者がWeb開発を始める場合
  • フォームバリデーションやテンプレートエンジンの統合が標準で必要な場合
  • プロトタイピングを迅速に行いたい場合

いずれのフレームワークも本番環境での運用実績があり、パフォーマンス差はユースケースによって変動します。チームの習熟度やエコシステムとの親和性を重視することが実践的です。


本番環境へのデプロイ

Axumアプリケーションを本番環境で運用するための構成要素を説明します。

Dockerコンテナ化

マルチステージビルドによりイメージサイズを最小化します。

# ビルドステージ
FROM rust:1.83-slim AS builder
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY src ./src
COPY migrations ./migrations
RUN cargo build --release

# 実行ステージ
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /app/target/release/axum-demo /app/axum-demo
COPY migrations ./migrations
EXPOSE 3000
CMD ["./axum-demo"]

Rustのリリースビルドは静的に近いバイナリを生成するため、実行ステージにRustツールチェインは不要です。最終イメージは数十MB程度になります。

構造化ログ(tracing)

tracingクレートを使った構造化ログの導入方法です。

[dependencies]
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};

#[tokio::main]
async fn main() {
    tracing_subscriber::registry()
        .with(
            tracing_subscriber::EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| "axum_demo=debug,tower_http=debug".into()),
        )
        .with(tracing_subscriber::fmt::layer().json())
        .init();

    tracing::info!("Starting server");

    // ... アプリケーション起動コード
}

RUST_LOG環境変数でログレベルを制御できます。JSON形式の出力は、CloudWatch、Datadog、Elasticsearchなどのログ集約サービスとの連携に適しています。

RUST_LOG=axum_demo=info,tower_http=debug cargo run

グレースフルシャットダウン

本番環境では、進行中のリクエストを完了させてからプロセスを終了するグレースフルシャットダウンが不可欠です。

use tokio::signal;

#[tokio::main]
async fn main() {
    // ... Router構築コード

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
        .await
        .unwrap();

    tracing::info!("Listening on http://localhost:3000");

    axum::serve(listener, app)
        .with_graceful_shutdown(shutdown_signal())
        .await
        .unwrap();

    tracing::info!("Server shut down gracefully");
}

async fn shutdown_signal() {
    let ctrl_c = async {
        signal::ctrl_c()
            .await
            .expect("Failed to install Ctrl+C handler");
    };

    #[cfg(unix)]
    let terminate = async {
        signal::unix::signal(signal::unix::SignalKind::terminate())
            .expect("Failed to install SIGTERM handler")
            .recv()
            .await;
    };

    #[cfg(not(unix))]
    let terminate = std::future::pending::<()>();

    tokio::select! {
        _ = ctrl_c => {},
        _ = terminate => {},
    }

    tracing::info!("Shutdown signal received");
}

with_graceful_shutdownを使うと、シグナル受信後に新規接続の受付を停止し、既存のリクエスト処理が完了するのを待ってからプロセスを終了します。KubernetesやECSのローリングデプロイではSIGTERMが送信されるため、この対応は必須です。

ヘルスチェックエンドポイント

ロードバランサーやオーケストレーターからの死活監視に対応するエンドポイントを追加します。

async fn health_check(State(state): State<AppState>) -> StatusCode {
    match sqlx::query("SELECT 1")
        .execute(&state.pool)
        .await
    {
        Ok(_) => StatusCode::OK,
        Err(_) => StatusCode::SERVICE_UNAVAILABLE,
    }
}

let app = Router::new()
    .route("/health", get(health_check))
    // ... 他のルート
    .with_state(state);

docker-composeによる開発環境構成

services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgres://postgres:password@db:5432/axum_demo
      RUST_LOG: axum_demo=debug,tower_http=debug
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16
    environment:
      POSTGRES_DB: axum_demo
      POSTGRES_PASSWORD: password
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

テストの実装

Axumアプリケーションのテスト手法を紹介します。Towerのoneshotメソッドを使ったインテグレーションテストにより、HTTPサーバーを実際に起動せずにリクエスト/レスポンスの検証が可能です。

#[cfg(test)]
mod tests {
    use super::*;
    use axum::{
        body::Body,
        http::{Request, StatusCode},
    };
    use http_body_util::BodyExt;
    use tower::ServiceExt;

    fn create_app() -> Router {
        let state: SharedState = Arc::new(RwLock::new(HashMap::new()));
        Router::new()
            .route("/tasks", get(list_tasks).post(create_task))
            .route("/tasks/{id}", get(get_task))
            .with_state(state)
    }

    #[tokio::test]
    async fn test_create_and_list_tasks() {
        let app = create_app();

        // タスク作成
        let response = app
            .clone()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/tasks")
                    .header("content-type", "application/json")
                    .body(Body::from(
                        r#"{"title":"Test task","description":"A test"}"#,
                    ))
                    .unwrap(),
            )
            .await
            .unwrap();

        assert_eq!(response.status(), StatusCode::CREATED);

        // タスク一覧取得
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/tasks")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();

        assert_eq!(response.status(), StatusCode::OK);

        let body = response.into_body().collect().await.unwrap().to_bytes();
        let tasks: Vec<serde_json::Value> = serde_json::from_slice(&body).unwrap();
        assert_eq!(tasks.len(), 1);
        assert_eq!(tasks[0]["title"], "Test task");
    }

    #[tokio::test]
    async fn test_get_nonexistent_task() {
        let app = create_app();

        let response = app
            .oneshot(
                Request::builder()
                    .uri("/tasks/999")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();

        assert_eq!(response.status(), StatusCode::NOT_FOUND);
    }
}

oneshotメソッドにより、Routerに対して1回のリクエストを送信してレスポンスを検証できます。実際のTCPソケットを使わないため、テストは高速に実行されます。


まとめ

Axumは、Tokio・Tower・HyperというRustエコシステムの基盤コンポーネントの上に構築されたWebフレームワークです。独自の抽象化を極力排し、型安全なエクストラクタシステムとTowerミドルウェアの互換性を強みとしています。

技術選定のポイントを整理します。

  • パフォーマンス: Hello Worldベンチマークでは768,607 req/sを記録し、現実的なワークロードではActix-webとほぼ同等の処理速度を示す。メモリ効率では起動時0.75MiB、負荷後0.91MiBと最も軽量(出典: rust_web_benchmark
  • エコシステム統合: Towerミドルウェア、tonic(gRPC)、reqwestなどTokioベースのクレートとシームレスに連携可能
  • 型安全性: ルーティング・リクエスト処理・エラーハンドリングの全てがコンパイル時に検証される
  • 活発な開発: v0.8.8(2025年12月)が最新リリースであり、累計ダウンロード数は2億3,700万回超。v0.9の開発も進行中

Axum 0.8では、波括弧パスパラメータ/{param}#[async_trait]の廃止、フィーチャーゲート化といった変更が導入され、APIの一貫性と開発体験が向上しています。

RustでWeb APIやマイクロサービスを構築する場合、AxumはTokioエコシステムとの親和性・メモリ効率・型安全性のバランスに優れた選択肢です。Towerミドルウェアの豊富なエコシステムを活用できる点は、認証・ログ・トレーシング・レート制限などの横断的関心事を効率よく実装するうえで大きなメリットとなります。