- Authors

- Name
- Youngju Kim
- @fjvbn20031
- はじめに — なぜRustでWeb APIか
- Axum最初の一歩 — ハンドラとルーティング
- 抽出器 — リクエストから欲しいものを取り出す
- 共有状態 — データベースプールと設定を分け合う
- towerミドルウェア — 横断的関心事を乗せる
- serdeでJSONを扱う — シリアライズとデシリアライズ
- sqlxでデータベース連携 — コンパイル時に検証されるクエリ
- エラー処理 — Resultをレスポンスへ
- Actix Webとの比較 — 何を選ぶか
- おわりに
- 参考資料
はじめに — なぜ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();
}
AppState に Clone を要求する理由は、リクエストごとに状態の値の複製が必要だからです。そこでデータベースプールのような実際の資源は Arc で包み、安価に複製されるようにするのが慣例です。先のスマートポインタの記事で見た Arc がここで自然に使われます。プールそのものは一つだけ存在し、各リクエストはその Arc のクローンを共有します。
towerミドルウェア — 横断的関心事を乗せる
ロギング、認証、タイムアウト、圧縮、CORSのように複数のルートへ共通に適用するロジックはミドルウェアで扱います。Axumは独自のミドルウェアシステムを新たに作らず、Rustエコシステムの標準的なサービス抽象であるtowerをそのまま使います。おかげで tower と tower-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バックエンドでもそのまま有効です。
参考資料
- Axum公式ドキュメント (docs.rs): https://docs.rs/axum/latest/axum/
- Axumのサンプル集 (GitHub): https://github.com/tokio-rs/axum/tree/main/examples
- tower / tower-http: https://docs.rs/tower-http/latest/tower_http/
- serde公式サイト: https://serde.rs/
- sqlx (GitHub): https://github.com/launchbadge/sqlx
- Actix Web公式サイト: https://actix.rs/