Skip to content

필사 모드: RustでWeb APIを作る:AxumとActix

日本語
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

はじめに — なぜRustでWeb APIか

かつてWebバックエンドはRustから遠く見えました。所有権やライフタイムといった概念が、リクエスト・レスポンスのコードにとって参入障壁のように感じられたからです。しかし状況は変わりました。前の記事で見たasync/awaitとTokioの上に成熟したWebフレームワークが乗ることで、RustでWeb APIを作る体験は驚くほど整いました。

RustのWebバックエンドの魅力は明確です。コンパイル時に捕まる膨大なバグ、ガベージコレクタなしで得られる予測可能な低遅延、そして型システムが強制する堅牢さです。JSONのフィールド名を一つ打ち間違えればコンパイルが拒否し、Option がnull処理を強制します。

この記事は、現在もっとも人気のあるフレームワークAxumを中心に、ルーティングからデータベース連携、エラー処理までの実践的な流れを押さえます。最後にもう一つの代表格であるActix Webと比較します。AxumはTokioチームが作り、towerのエコシステムと滑らかに噛み合う点で良い出発点です。

Axum最初の一歩 — ハンドラとルーティング

Axumでもっとも基本になる二つの概念は**ハンドラ(handler)ルーター(router)**です。ハンドラはリクエストを受け取ってレスポンスを返す async 関数で、ルーターはどのパスとメソッドがどのハンドラへ行くかを定義します。

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

#[tokio::main]
async fn main() {
    // ルーター: パスとハンドラを結ぶ
    let app = Router::new()
        .route("/", get(root))
        .route("/health", get(health));

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
        .await
        .unwrap();
    axum::serve(listener, app).await.unwrap();
}

// ハンドラ: ただのasync関数だ
async fn root() -> &'static str {
    "Hello, Axum!"
}

async fn health() -> &'static str {
    "OK"
}

ハンドラがただの async 関数であることはAxumの大きな利点です。マクロで包んだり特別なトレイトを実装したりする必要はなく、引数に欲しいものを受け取り、レスポンスになれる値を返せばよいのです。戻り値の型が &'static str なのに自動でHTTPレスポンスになるのは、Axumが IntoResponse というトレイトでさまざまな型をレスポンスへ変換するからです。文字列、ステータスコード、JSON、タプルなど、多くがそのままレスポンスになります。

抽出器 — リクエストから欲しいものを取り出す

Axumの本当の優雅さは**抽出器(extractor)**にあります。ハンドラの引数に特定の型を書くと、Axumがリクエストからその部分を取り出して渡してくれます。パスパラメータ、クエリ文字列、JSON本文、ヘッダなど、ほとんどのリクエスト要素に抽出器があります。

use axum::{
    extract::{Path, Query, Json},
    routing::{get, post},
    Router,
};
use serde::Deserialize;

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

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

// パスパラメータの抽出: /users/42 -> id = 42
async fn get_user(Path(id): Path<u64>) -> String {
    format!("user {id}")
}

// クエリ文字列の抽出: /users?page=2&per_page=20
async fn list_users(Query(p): Query<Pagination>) -> String {
    format!("page {} ({} per page)", p.page, p.per_page)
}

// JSON本文の抽出
async fn create_user(Json(payload): Json<CreateUser>) -> String {
    format!("created {} <{}>", payload.name, payload.email)
}

fn app() -> Router {
    Router::new()
        .route("/users/{id}", get(get_user))
        .route("/users", get(list_users).post(create_user))
}

抽出器の順序にも規則があります。リクエスト本文を消費する抽出器(例: Json)は本文を丸ごと読むので一つだけ、しかも引数リストの最後に来なければなりません。ヘッダやパスのように本文を消費しない抽出器は、その前に複数置けます。抽出に失敗すると(たとえばJSONのパース失敗)、Axumが自動的に適切な4xxレスポンスを返すので、ハンドラ本体は成功経路だけに集中できます。

共有状態 — データベースプールと設定を分け合う

実際のアプリケーションのハンドラは、データベース接続プールや設定値のような共有資源にアクセスする必要があります。Axumはこれを**状態(state)**として扱い、State 抽出器でハンドラへ注入します。

use axum::{extract::State, routing::get, Router};
use std::sync::Arc;

// アプリケーション全体で共有する状態
#[derive(Clone)]
struct AppState {
    // 実際にはここにsqlxプール、設定などが入る
    app_name: Arc<String>,
}

async fn handler(State(state): State<AppState>) -> String {
    format!("running: {}", state.app_name)
}

#[tokio::main]
async fn main() {
    let state = AppState {
        app_name: Arc::new("my-service".to_string()),
    };

    let app = Router::new()
        .route("/", get(handler))
        .with_state(state); // ルーターに状態を付ける

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
        .await
        .unwrap();
    axum::serve(listener, app).await.unwrap();
}

AppStateClone を要求する理由は、リクエストごとに状態の値の複製が必要だからです。そこでデータベースプールのような実際の資源は Arc で包み、安価に複製されるようにするのが慣例です。先のスマートポインタの記事で見た Arc がここで自然に使われます。プールそのものは一つだけ存在し、各リクエストはその Arc のクローンを共有します。

towerミドルウェア — 横断的関心事を乗せる

ロギング、認証、タイムアウト、圧縮、CORSのように複数のルートへ共通に適用するロジックはミドルウェアで扱います。Axumは独自のミドルウェアシステムを新たに作らず、Rustエコシステムの標準的なサービス抽象であるtowerをそのまま使います。おかげで towertower-http が提供する膨大なミドルウェアをすぐに乗せられます。

use axum::{routing::get, Router};
use tower_http::trace::TraceLayer;
use tower_http::timeout::TimeoutLayer;
use std::time::Duration;

fn app() -> Router {
    Router::new()
        .route("/", get(|| async { "Hello" }))
        // ミドルウェアはlayerで乗せる
        .layer(TraceLayer::new_for_http())          // リクエストのロギング/トレース
        .layer(TimeoutLayer::new(Duration::from_secs(10))) // リクエストのタイムアウト
}

ミドルウェアを layer で乗せると、その下のすべてのルートに適用されます。複数のlayerを積むと、外から内へリクエストが通り抜け、レスポンスは逆に抜け出ます。towerを共有するこの設計のおかげで、gRPCフレームワークtonicなど他のtowerベースのツールとミドルウェアを再利用できる点がAxumの大きな強みです。

serdeでJSONを扱う — シリアライズとデシリアライズ

Web APIの核心はJSONをやり取りすることです。Rustでこれは事実上serdeクレートで処理します。構造体に Serialize/Deserialize を導出すると、その型とJSONの間の変換をコンパイラが生成してくれます。

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

// リクエスト本文を受け取る型
#[derive(Deserialize)]
struct CreateTodo {
    title: String,
    #[serde(default)] // なければfalse
    done: bool,
}

// レスポンスとして送り出す型
#[derive(Serialize)]
struct Todo {
    id: u64,
    title: String,
    done: bool,
}

async fn create_todo(Json(input): Json<CreateTodo>) -> Json<Todo> {
    let todo = Todo {
        id: 1,
        title: input.title,
        done: input.done,
    };
    Json(todo) // Jsonで包むと自動でJSONレスポンスになる
}

Json 抽出器でリクエスト本文を型として受け取り、戻り値を Json で包むとレスポンスがJSONで出ます。serdeの属性で細部を制御することもできます。#[serde(rename_all = "camelCase")] でフィールド名の規則を変えたり、#[serde(default)] で欠けたフィールドに既定値を与えたり、Option<T> フィールドであってもなくてもよい値を表現したりします。これらすべてが型に宣言的に付くので、JSONの契約がコードにそのまま現れます。

sqlxでデータベース連携 — コンパイル時に検証されるクエリ

データを保存・取得するにはデータベースが必要です。Rustの非同期エコシステムで広く使われるのがsqlxです。sqlxの独特な点は、ORMではなく生のSQLを書きつつ、そのクエリをコンパイル時に実際のデータベーススキーマと照合して検証することです。打ち間違えたカラム名や型の不一致が、実行時ではなくコンパイル時に捕まります。

use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;

#[derive(sqlx::FromRow)]
struct User {
    id: i64,
    name: String,
    email: String,
}

async fn setup_pool() -> PgPool {
    PgPoolOptions::new()
        .max_connections(5)
        .connect("postgres://localhost/mydb")
        .await
        .unwrap()
}

async fn find_user(pool: &PgPool, id: i64) -> Result<Option<User>, sqlx::Error> {
    // query_asで結果を構造体にマッピングする
    let user = sqlx::query_as::<_, User>(
        "SELECT id, name, email FROM users WHERE id = $1",
    )
    .bind(id)
    .fetch_optional(pool)
    .await?;

    Ok(user)
}

sqlxは接続プールを提供し、このプールを先に見た State でアプリケーションへ共有します。パラメータはドル記号に番号を付けたプレースホルダ(1番目・2番目のパラメータを表す記法)と bind で渡し、SQLインジェクションを根本から遮断します。結果の行は FromRow を導出した構造体へマッピングされます。fetch_optional は結果がないかもしれないことを Option で表現し、複数行なら fetch_all、正確に一行を期待するなら fetch_one を使います。さらに query! マクロを使えば、コンパイル時に実際のDBへ接続してクエリ自体を検証する強力なモードもあります。

エラー処理 — Resultをレスポンスへ

ここまでの例はほとんど成功経路だけを扱いましたが、実際のAPIは失敗を優雅に扱わねばなりません。Rustの慣用句は、ハンドラが Result を返し、エラー型が自らHTTPレスポンスへ変換されるようにすることです。エラー型に IntoResponse を実装すればよいのです。

use axum::{
    extract::{Path, State},
    http::StatusCode,
    response::{IntoResponse, Response},
    Json,
};
use serde_json::json;

// アプリケーション専用のエラー型
enum AppError {
    NotFound,
    Database(sqlx::Error),
}

// エラーをHTTPレスポンスへ変換する方法を定義
impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            AppError::NotFound => (StatusCode::NOT_FOUND, "not found"),
            AppError::Database(_) => {
                (StatusCode::INTERNAL_SERVER_ERROR, "internal error")
            }
        };
        (status, Json(json!({ "error": message }))).into_response()
    }
}

// sqlxエラーをAppErrorへ自動変換 (?演算子用)
impl From<sqlx::Error> for AppError {
    fn from(e: sqlx::Error) -> Self {
        AppError::Database(e)
    }
}

async fn get_user(
    State(pool): State<sqlx::PgPool>,
    Path(id): Path<i64>,
) -> Result<Json<String>, AppError> {
    let user = find_user(&pool, id).await?; // sqlx::Errorが?でAppErrorになる
    match user {
        Some(_) => Ok(Json(format!("user {id}"))),
        None => Err(AppError::NotFound), // 404へ変換される
    }
}

このパターンの美しさは ? 演算子との組み合わせにあります。From<sqlx::Error> を実装しておけば、データベース呼び出しのあとに ? を付けるだけで失敗が自動的に AppError になり、それがさらに適切なHTTPステータスコードへ変換されます。ハンドラ本体は成功の流れを自然に書き、エラーは静かに上へ伝播して一貫した形のレスポンスになります。実務ではこの部分を thiserror のようなクレートでさらに簡潔に整えます。

Actix Webとの比較 — 何を選ぶか

Axumのほかにもう一つの代表的なフレームワークがActix Webです。以前から成熟しており、各種ベンチマークで最上位の性能として名を知られてきました。二つのフレームワークは実際かなり似ています。どちらもasync/awaitの上で動き、抽出器スタイルのハンドラを使い、serdeでJSONを扱います。

核心的な違いは次のとおりです。

  • ミドルウェアのエコシステム: Axumはtowerをそのまま採用し、tower/tower-httpのミドルウェアやtonicのような他のtowerベースのツールと共有します。Actixは独自のミドルウェアシステムを持ちます。
  • ランタイム: AxumはTokioに直結します。Actixは独自のアクターランタイム(actix-rt、内部的にTokioベース)の上で動いてきた歴史があります。
  • 設計思想: Actixは名前のとおりアクターモデルから出発し、いまも状態管理にその色が残っています。Axumは関数とtowerサービスというより薄い抽象を志向します。
  • チームと統合: AxumはTokioチームが管理するので、Tokio・hyper・towerとの統合が滑らかです。

簡単な比較のため、Actixのハンドラも一度見ましょう。形がAxumと大きく違わないことが分かります。

use actix_web::{get, web, App, HttpServer, Responder};

#[get("/users/{id}")]
async fn get_user(path: web::Path<u64>) -> impl Responder {
    let id = path.into_inner();
    format!("user {id}")
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| App::new().service(get_user))
        .bind(("0.0.0.0", 3000))?
        .run()
        .await
}

選択の基準をまとめるとこうです。towerエコシステムとの統合、Tokioとの密接さ、薄く予測可能な抽象を重視するなら、Axumが今の自然な既定値です。長く検証された成熟さ、豊富な機能、そして最高水準のベンチマーク性能を求めるなら、Actix Webも依然として優れた選択です。ただし両者の実質的な性能差はほとんどのアプリケーションで無視できるので、エコシステムの適合とチームの好みで選ぶのが現実的です。

おわりに

RustでWeb APIを作ることは、もはや特別な苦行ではなく、むしろ楽しい体験に近いです。この記事で押さえた流れをまとめると、次のとおりです。

  • Axumのハンドラはただの async 関数で、抽出器でリクエストから欲しいものを宣言的に取り出します。
  • データベースプールのような共有資源は Arc で包み、**状態(State)**として注入します。
  • ロギングやタイムアウトのような横断的関心事はtowerミドルウェアで乗せます。
  • serdeでJSONを、sqlxでコンパイル時に検証されるSQLを扱います。
  • エラーは IntoResponse を実装した型と ? 演算子で優雅にレスポンスへ変換します。
  • Actix Webは成熟した強力な代替で、選択は性能よりエコシステムの適合で決まることが多いです。

型システムが契約を強制し、コンパイラが膨大なバグを前もって捕まえるこの環境で、あなたのAPIは最初から堅牢です。Rustが与える安全性と性能は、システムプログラミングだけの話ではなく、Webバックエンドでもそのまま有効です。

参考資料

현재 단락 (1/217)

かつてWebバックエンドはRustから遠く見えました。所有権やライフタイムといった概念が、リクエスト・レスポンスのコードにとって参入障壁のように感じられたからです。しかし状況は変わりました。前の記事で...

작성 글자: 0원문 글자: 8,717작성 단락: 0/217