RustにおけるAxumとActix Webを使ったAPIバージョニングのナビゲーション
Lukas Schneider
DevOps Engineer · Leapcell

Webアプリケーションが進化するにつれて、そのAPIも必然的に変化します。新機能の追加、旧機能の非推奨化、データ構造の洗練などが行われます。これらの変更によって既存のクライアントアプリケーションが壊れないようにするためには、堅牢なAPIバージョニングが不可欠なプラクティスとなります。明確な戦略なしにAPIを更新すると、重大な互換性の問題、ユーザーの不満、そして重いメンテナンス負荷につながる可能性があります。RustのWebエコシステムでは、AxumやActix Webのようなフレームワークは、高性能で信頼性の高いAPIを構築するための強力なツールを提供します。この記事では、URLパスの使用とAcceptヘッダーの活用という2つの主要なAPIバージョニング戦略に焦点を当て、これらの人気のあるRustフレームワークのコンテキストにおける実装の詳細、利点、および欠点を検討します。
APIバージョニング戦略の理解
詳細に入る前に、APIバージョニングに関連するコアコンセプトを定義しましょう。APIバージョニングとは、APIの複数のバージョンを同時に維持し、クライアントが対話するバージョンを選択できるようにするプラクティスです。これにより、破壊的な変更が古いクライアントに影響を与えるのを防ぎつつ、新しいクライアントが最新機能の恩恵を受けられるようになります。
これから探求する2つの主な戦略は次のとおりです。
- URLパスバージョニング: APIバージョンをURLパスに直接埋め込みます。例:「/api/v1/users」と「/api/v2/users」。
- Acceptヘッダーバージョニング: クライアントは、
Accept
HTTPヘッダー内にカスタムメディアタイプを送信することにより、希望するAPIバージョンを指定します。例:「Accept: application/vnd.myapi.v1+json」。
URLパスバージョニング
原理と実装
URLパスバージョニングは、おそらく最も簡単で広く採用されている方法です。バージョン番号は、エンドポイントパスの明確で目に見える部分となります。これにより、開発者はどのバージョンと対話しているかを直感的に理解でき、簡単なルーティングルールが可能になります。
Rustでは、AxumとActix Webの両方で、ルーティングメカニズムを通じてURLパスバージョニングを非常に自然に実装できます。
Axumの例
Axumのルーティングはサービスに基づいており、バージョンプレフィックスを持つルートの定義は非常にクリーンです。
use axum::{ routing::get, Router, response::IntoResponse, }; async fn get_users_v1() -> impl IntoResponse { "Getting users from API V1!" } async fn get_users_v2() -> impl IntoResponse { "Getting users from API V2!" } #[tokio::main] async fn main() { let app = Router::new() .nest("/api/v1", Router::new().route("/users", get(get_users_v1))) .nest("/api/v2", Router::new().route("/users", get(get_users_v2))); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); println!("Listening on {}", listener.local_addr().unwrap()); axum::serve(listener, app).await.unwrap(); }
このAxumの例では、/api/v1
用と/api/v2
用の2つのネストされたRouter
インスタンスを作成します。それぞれが/users
エンドポイントを独立して処理し、要求をそれぞれのバージョン固有のハンドラにルーティングします。
Actix Webの例
Actix Webは、ルーティングにマクロと関数属性を使用しており、これもパスベースのバージョニングに適しています。
use actix_web::{get, web, App, HttpServer, Responder}; #[get("/api/v1/users")] async fn get_users_v1_actix() -> impl Responder { "Getting users from API V1!" } #[get("/api/v2/users")] async fn get_users_v2_actix() -> impl Responder { "Getting users from API V2!" } #[actix_web::main] async fn main() -> std::io::Result<()> { HttpServer::new(|| { App::new() .service(get_users_v1_actix) .service(get_users_v2_actix) }) .bind(("127.0.0.1", 8080))?; .run() .await }
ここでは、Actix Webの#[get]
マクロがバージョンを含む完全なパスを直接定義しており、非常に明確です。各バージョンのハンドラは、個別のサービスとして登録されます。
URLパスバージョニングの利点
- 検出可能性: URLでバージョンがすぐにわかります。開発者やドキュメントにとって簡単です。
- キャッシング: プロキシやキャッシュは、異なるバージョンを完全に独立したリソースとして扱うことができ、キャッシング戦略を簡素化します。
- ブラウザフレンドリー: Webブラウザから直接異なるAPIバージョンにアクセスできます。
- シンプルさ: ルーティングは、実装と理解が一般的に簡単です。
URLパスバージョニングの欠点
- URLの汚染: URLは、バージョン番号とともに長くなり、洗練さが失われる可能性があります。
- クライアントサイドの「固着」: URLにバージョン番号をハードコーディングしているクライアントは、新しいバージョンがデフォルトになるか、古いバージョンが非推奨になったときに一斉に更新する必要があるかもしれません。
- ルーティングのオーバーヘッド: 多くのエンドポイントがバージョン管理されている場合、管理するルートが増える可能性があります。
Acceptヘッダーバージョニング
原理と実装
Acceptヘッダーバージョニング(コンテンツネゴシエーションとしても知られる)は、Accept
HTTPヘッダーに依存します。クライアントはAPIバージョンを含むカスタムメディアタイプを指定し、サーバーはそれに応じて応答します。これにより、URLはクリーンに保たれ、同じURLパスでリソースの複数のバージョンを提供できるようになります。
カスタムメディアタイプは、通常 application/vnd.company.resource.vX+json
のような規則に従います。
この戦略を実装するには、Accept
ヘッダーを検査し、その内容に基づいて要求をルーティングする必要があります。
Axumの例
Axumのエクストラクタは、ヘッダーベースのバージョニングを実装するのに優れています。Accept
ヘッダーを解析するカスタムエクストラクタを作成できます。
use axum::{ routing::get, Router, async_trait, extract::{FromRequestParts, Request, State}, http::{header, request::Parts}, response::{IntoResponse, Response}, }; use std::collections::HashMap; // APIバージョンのカスタムエクストラクタ pub struct ApiVersion(pub u8); #[async_trait] impl<S> FromRequestParts<S> for ApiVersion where S: Send + Sync, { type Rejection = Response; async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> { let accept_header = parts.headers .get(header::ACCEPT) .and_then(|h| h.to_str().ok()) .unwrap_or_default(); // 例: "application/vnd.myapi.v1+json"を解析 if let Some(version_str) = accept_header.find("vnd.myapi.v") { let start = version_str + "vnd.myapi.v".len(); if let Some(end) = accept_header[start..].find('+') { if let Ok(version) = accept_header[start..start + end].parse::<u8>() { return Ok(ApiVersion(version)); } } } // 特定のバージョンまたはサポートされていないバージョンが要求されない場合は、バージョン1にデフォルト設定 Ok(ApiVersion(1)) } } async fn get_users_versioned(ApiVersion(version): ApiVersion) -> impl IntoResponse { match version { 1 => "Getting users from API V1 via Accept header!".to_string(), 2 => "Getting users from API V2 via Accept header!".to_string(), _ => format!("Unsupported API version: {}", version), } } #[tokio::main] async fn main() { let app = Router::new() .route("/api/users", get(get_users_versioned)); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); println!("Listening on {}", listener.local_addr().unwrap()); axum::serve(listener, app).await.unwrap(); }
Axumでは、Accept
ヘッダーを処理するカスタムエクストラクタとしてApiVersion
を定義します。from_request_parts
の実装は、ヘッダーからバージョン番号を解析しようとします。get_users_versioned
ハンドラは、このApiVersion
を消費して、どのロジックを実行するかを決定します。
Actix Webの例
Actix Webもカスタムエクストラクタを可能にし、ヘッダー検査を強力にサポートしています。
use actix_web::{web, App, HttpServer, HttpRequest, HttpResponse, Responder, HttpMessage}; use actix_web::http::header::{ACCEPT, CONTENT_TYPE}; // Actix WebでのAPIバージョンのカスタムエクストラクタ struct ApiVersionActix(u8); impl actix_web::FromRequest for ApiVersionActix { type Error = actix_web::Error; type Future = std::future::Ready<Result<Self, Self::Error>>; fn from_request(req: &HttpRequest, _: &mut actix_web::dev::Payload) -> Self::Future { let accept_header = req.headers() .get(ACCEPT) .and_then(|h| h.to_str().ok()) .unwrap_or_default(); let mut version = 1; // V1にデフォルト設定 if let Some(version_str_idx) = accept_header.find("vnd.myapi.v") { let start = version_str_idx + "vnd.myapi.v".len(); if let Some(end_idx) = accept_header[start..].find('+') { if let Ok(parsed_version) = accept_header[start..start + end_idx].parse::<u8>() { version = parsed_version; } } } std::future::ready(Ok(ApiVersionActix(version))) } } async fn get_users_versioned_actix(api_version: ApiVersionActix) -> impl Responder { match api_version.0 { 1 => HttpResponse::Ok().body("Getting users from API V1 via Accept header!"), 2 => HttpResponse::Ok().body("Getting users from API V2 via Accept header!"), _ => HttpResponse::BadRequest().body(format!("Unsupported API version: {}", api_version.0)), } } #[actix_web::main] async fn main() -> std::io::Result<()> { HttpServer::new(|| { App::new() .service( web::resource("/api/users") .route(web::get().to(get_users_versioned_actix)) ) }) .bind(("127.0.0.1", 8080))?; .run() .await }
Actix WebのApiVersionActix
もFromRequest
を実装しており、ハンドラ関数の引数として使用できます。Accept
ヘッダーの解析ロジックは、Axumの例と似ています。
Acceptヘッダーバージョニングの利点
- クリーンなURL: URLはAPIバージョン間で安定しており、公開APIの視覚性とSEOを向上させます。
- 複数のバージョンのための単一エンドポイント: 同じパス
/api/users
が、クライアントが要求するバージョンに基づいて、ユーザーの異なる表現を提供できます。 - 柔軟性: クライアントは、URL構造全体を変更することなく、簡単に特定のバージョンを要求できます。
Acceptヘッダーバージョニングの欠点
- 検出可能性の低下: バージョンはヘッダー内に隠されており、ネットワーク要求を検査しないと、どのバージョンにアクセスしているかがわかりにくくなります。
- キャッシングの複雑さ: キャッシュキーに
Accept
ヘッダーを含める必要があるため、キャッシュプロキシは効率的なキャッシュ応答に苦労する可能性があり、キャッシュミスにつながる可能性があります。 - ブラウザの非互換性: ブラウザは通常、
Accept
ヘッダーのカスタマイズを直接許可しないため、Webブラウザからのバージョンの直接アクセスは困難です。 - エラー処理: サポートされていないバージョンを要求していることや、
Accept
ヘッダーが不正であることをクライアントに伝えるのが難しい場合があります。
適切な戦略の選択
URLパスとAcceptヘッダーバージョニングのどちらを選択するかは、いくつかの主要な考慮事項に帰着します。
- APIの対象ユーザー: 検出可能性と直接のブラウザアクセスが重要な公開APIの場合、URLパスバージョニングが好まれるかもしれません。内部APIまたはマシン間APIの場合、AcceptヘッダーバージョニングはよりクリーンなURLを提供します。
- キャッシングのニーズ: キャッシュが重要であり、外部プロキシで実装されている場合、URLパスバージョニングはキャッシング戦略を簡素化します。
- クライアントサイドの柔軟性: クライアントが同じリソースのバージョンを頻繁に切り替える必要がある場合、Acceptヘッダーバージョニングはより適応性があります。
- アーキテクチャのシンプルさ: URLパスバージョニングは、ほとんどの開発者にとって、初期実装が一般的に理解しやすくなっています。
これらの戦略が相互に排他的ではないことも注目に値します。一部のAPIはハイブリッドアプローチを使用したり、マイナーな変更のために日付ベースのバージョニングを使用したりすることさえあります。しかし、ほとんどの一般的なユースケースでは、これらのどちらかで十分です。
結論
APIバージョニングは、スケーラブルで保守可能なWebサービスを設計する上で不可欠な側面です。Rustエコシステムでは、AxumとActix Webは、URLパスとAcceptヘッダーバージョニングの両方を効果的に実装するための必要なツールと柔軟性を提供します。URLパスバージョニングはシンプルさと検出可能性を提供しますが、AcceptヘッダーバージョニングはよりクリーンなURLとリソース表現におけるより大きな柔軟性をもたらします。プロジェクトに最適な戦略を慎重に選択することで、APIの寿命と使いやすさが大幅に向上します。