じゃあ、おうちで学べる

本能を呼び覚ますこのコードに、君は抗えるか

Rustによる郵便番号検索API (yubin_api) の技術解説

こちらの記事は Rust Advent Calendar 2024 シリーズ 3 7日目の記事です!

qiita.com

はじめに

Rustを使用したWebアプリケーション開発は、高いパフォーマンスと堅牢性を両立させる方法として注目を集めています。本記事では、日本の郵便番号システムにアクセスするRESTful APIyubin_api」の実装を通じて、Rustの実践的な開発手法を解説します。

workspace_2024/yubin_api at main · nwiizo/workspace_2024 · GitHub

このプロジェクトでは、axumを使用したWebサーバーの構築非同期プログラミング(async/await)、構造化されたエラーハンドリングを実装しています。また、プロダクション環境を想定したメトリクス収集とモニタリング型安全なAPIデザインにも焦点を当てています。

ちなみに元ライブラリーの実装についてはsyumai さんの実装を全面的に参考にさせていただいております。

blog.syum.ai

1. プロジェクトの構成

まず、Cargo.tomlの依存関係から見ていきましょう:

[dependencies]
# Webフレームワーク関連
axum = { version = "0.7", features = ["macros"] }  # Webフレームワーク
tokio = { version = "1.0", features = ["full"] }   # 非同期ランタイム
tower = { version = "0.4", features = ["full"] }   # HTTPサービス抽象化
tower-http = { version = "0.5", features = ["cors", "trace", "limit", "request-id"] }

# ロギングと監視
tracing = "0.1"                # ログ出力
tracing-subscriber = "0.3"     # ログ設定
metrics = "0.21"              # メトリクス収集
metrics-exporter-prometheus = "0.12"  # Prometheus形式出力

# シリアライズ/デシリアライズ
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

# ユーティリティ
thiserror = "1.0"   # エラー定義
uuid = { version = "1.0", features = ["v4"] }  # ユニークID生成
utoipa = { version = "4.1", features = ["uuid"] }  # OpenAPI生成

# 郵便番号データベース
jpostcode_rs = "0.1.3"

2. エラー処理の実装(error.rs)

エラー処理は、APIの信頼性を確保する重要な部分です:

use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
    Json,
};
use thiserror::Error;
use tracing::warn;

// APIのエラー型を定義
#[derive(Debug, Error)]
pub enum ApiError {
    #[error("Invalid postal code format")]
    InvalidPostalCode,
    #[error("Address not found")]
    NotFound,
    #[error("Internal server error: {0}")]
    Internal(String),
}

// エラーをHTTPレスポンスに変換する実装
impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        // エラーの種類に応じてステータスコードを設定
        let (status, error_message) = match self {
            ApiError::InvalidPostalCode => (StatusCode::BAD_REQUEST, self.to_string()),
            ApiError::NotFound => (StatusCode::NOT_FOUND, self.to_string()),
            ApiError::Internal(ref e) => {
                // 内部エラーはログに記録
                warn!("Internal server error: {}", e);
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    "Internal server error".to_string(),
                )
            }
        };

        // JSONレスポンスの構築
        let body = Json(serde_json::json!({
            "error": error_message,
            "status": status.as_u16(),
            // エラー追跡用のユニークID
            "request_id": uuid::Uuid::new_v4().to_string()
        }));

        (status, body).into_response()
    }
}

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

APIで使用するデータ構造を定義します:

use serde::{Deserialize, Serialize};

// 住所情報のレスポンス構造体
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct AddressResponse {
    pub postal_code: String,
    pub prefecture: String,
    pub prefecture_kana: String,
    pub prefecture_code: i32,
    pub city: String,
    pub city_kana: String,
    pub town: String,
    pub town_kana: String,
    pub street: Option<String>,
    pub office_name: Option<String>,
    pub office_name_kana: Option<String>,
}

// jpostcode_rsのAddress型からの変換を実装
impl From<jpostcode_rs::Address> for AddressResponse {
    fn from(addr: jpostcode_rs::Address) -> Self {
        AddressResponse {
            postal_code: addr.postcode,
            prefecture: addr.prefecture,
            prefecture_kana: addr.prefecture_kana,
            prefecture_code: addr.prefecture_code,
            city: addr.city,
            city_kana: addr.city_kana,
            town: addr.town,
            town_kana: addr.town_kana,
            street: addr.street,
            office_name: addr.office_name,
            office_name_kana: addr.office_name_kana,
        }
    }
}

// 住所検索用のクエリ構造体
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct AddressQuery {
    pub query: String,
    #[serde(default = "default_limit")]
    pub limit: usize,
}

// デフォルトの検索結果制限数
fn default_limit() -> usize {
    10
}

4. メトリクス収集の設定(metrics.rs)

アプリケーションのパフォーマンスを監視するためのメトリクス設定:

use metrics::{describe_counter, describe_histogram, register_counter, register_histogram};
use metrics_exporter_prometheus::PrometheusBuilder;

pub fn setup_metrics() {
    // リクエスト数のカウンター
    describe_counter!(
        "yubin_api_postal_lookups_total",
        "Total number of postal code lookups"
    );
    describe_counter!(
        "yubin_api_address_searches_total",
        "Total number of address searches"
    );

    // レスポンス時間のヒストグラム
    describe_histogram!(
        "yubin_api_postal_lookup_duration_seconds",
        "Duration of postal code lookups in seconds"
    );
    describe_histogram!(
        "yubin_api_address_search_duration_seconds",
        "Duration of address searches in seconds"
    );

    // メトリクスの登録
    register_counter!("yubin_api_postal_lookups_total");
    register_counter!("yubin_api_address_searches_total");
    register_histogram!("yubin_api_postal_lookup_duration_seconds");
    register_histogram!("yubin_api_address_search_duration_seconds");

    // Prometheusレコーダーの設定
    PrometheusBuilder::new()
        .install()
        .expect("Failed to install Prometheus recorder");
}

Rustの知っておいたほうがいいポイント解説(前編)

  1. 属性マクロの使用

    • #[derive(...)]: 自動実装の導入
    • #[error(...)]: エラーメッセージの定義
    • #[serde(...)]: シリアライズ設定
  2. トレイトの実装

    • From<T>: 型変換の実装
    • IntoResponse: HTTPレスポンスへの変換
    • Error: カスタムエラー型の定義
  3. ジェネリクスとライフタイム

    • Option<T>: 省略可能な値の表現
    • Result<T, E>: エラーハンドリング
    • Vec<T>: 可変長配列の使用
  4. 型システムの活用

    • カスタム構造体の定義
    • 列挙型によるエラー表現
    • デフォルト値の実装

Rust初学者のためのyubin_api実装解説 - 後編

5. APIルートの実装(routes.rs)

APIの実際のエンドポイントを実装します:

use axum::{extract::Path, http::StatusCode, response::IntoResponse, Json};
use metrics::{counter, histogram};
use tracing::info;

// ヘルスチェックエンドポイント
pub async fn health_check() -> impl IntoResponse {
    StatusCode::OK
}

// 郵便番号検索エンドポイント
pub async fn lookup_by_postal_code(
    Path(code): Path<String>,  // URLパスからパラメータを取得
) -> Result<Json<Vec<AddressResponse>>, ApiError> {
    // リクエストのログ記録
    info!("Looking up postal code: {}", code);
    
    // メトリクスのカウントアップ
    counter!("yubin_api_postal_lookups_total", 1);
    
    // 処理時間の計測開始
    let start = std::time::Instant::now();

    // 郵便番号検索の実行
    let result = jpostcode_rs::lookup_address(&code).map_err(|e| match e {
        jpostcode_rs::JPostError::InvalidFormat => ApiError::InvalidPostalCode,
        jpostcode_rs::JPostError::NotFound => ApiError::NotFound,
    })?;

    // 処理時間の計測と記録
    let duration = start.elapsed().as_secs_f64();
    histogram!("yubin_api_postal_lookup_duration_seconds", duration);

    // 結果の返却
    Ok(Json(result.into_iter().map(Into::into).collect()))
}

// 住所検索エンドポイント
pub async fn search_by_address(
    Json(query): Json<AddressQuery>,  // リクエストボディからのJSONパース
) -> Result<Json<Vec<AddressResponse>>, ApiError> {
    info!("Searching address with query: {}", query.query);
    
    // 入力値の検証
    if query.query.trim().is_empty() {
        return Err(ApiError::InvalidPostalCode);
    }

    counter!("yubin_api_address_searches_total", 1);
    let start = std::time::Instant::now();

    // 住所検索の実行
    let mut results: Vec<AddressResponse> = jpostcode_rs::search_by_address(&query.query)
        .into_iter()
        .map(Into::into)
        .collect();

    // 結果数の制限適用
    results.truncate(query.limit);

    let duration = start.elapsed().as_secs_f64();
    histogram!("yubin_api_address_search_duration_seconds", duration);

    Ok(Json(results))
}

6. メインアプリケーションの実装(main.rs)

アプリケーションのエントリーポイントとサーバーの設定:

use axum::{routing::{get, post}, Router};
use std::net::SocketAddr;
use tower::ServiceBuilder;
use tower_http::{
    cors::{Any, CorsLayer},
    trace::{DefaultMakeSpan, DefaultOnResponse, TraceLayer},
};
use tracing::info;

#[tokio::main]
async fn main() {
    // ロギングの初期化
    tracing_subscriber::fmt()
        .with_env_filter(
            tracing_subscriber::EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| "yubin_api=debug,tower_http=debug".into()),
        )
        .init();

    // メトリクス収集の初期化
    setup_metrics();

    // リクエストトレース設定
    let trace_layer = TraceLayer::new_for_http()
        .make_span_with(DefaultMakeSpan::new().include_headers(true))
        .on_response(DefaultOnResponse::new().include_headers(true));

    // CORS設定
    let cors = CorsLayer::new()
        .allow_methods(Any)
        .allow_headers(Any)
        .allow_origin(Any);

    // ルーターの設定
    let app = Router::new()
        .route("/health", get(health_check))
        .route("/postal/:code", get(lookup_by_postal_code))
        .route("/address/search", post(search_by_address))
        .layer(ServiceBuilder::new()
            .layer(trace_layer)
            .layer(cors));

    // サーバーアドレスの設定
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    info!("Server listening on {}", addr);

    // サーバーの起動
    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

7. 重要な実装パターンの解説

非同期処理

// 非同期関数の定義
pub async fn lookup_by_postal_code(...) -> Result<...> {
    // 非同期処理の実行
    let result = jpostcode_rs::lookup_address(&code)?;
    // ...
}

// 非同期ランタイムの設定
#[tokio::main]
async fn main() {
    // ...
}

エラーハンドリング

// Result型を使用したエラー処理
let result = jpostcode_rs::lookup_address(&code).map_err(|e| match e {
    JPostError::InvalidFormat => ApiError::InvalidPostalCode,
    JPostError::NotFound => ApiError::NotFound,
})?;

ミドルウェアの構成

let app = Router::new()
    .route(...)
    .layer(ServiceBuilder::new()
        .layer(trace_layer)
        .layer(cors));

8. API使用例

郵便番号による検索

curl http://localhost:3000/postal/1000001

レスポンス例:

[
  {
    "postal_code": "1000001",
    "prefecture": "東京都",
    "city": "千代田区",
    "town": "千代田",
    ...
  }
]

住所による検索

curl -X POST http://localhost:3000/address/search \
  -H "Content-Type: application/json" \
  -d '{"query": "東京都千代田区", "limit": 10}'

9. Rustの知っておいたほうがいいポイント解説(後編)

  1. 非同期プログラミング

    • async/awaitの使用方法
    • tokioランタイムの理解
    • 非同期関数の定義と呼び出し
  2. エラーハンドリングパターン

    • Result型の活用
    • エラー変換のベストプラクティス
    • エラーの伝播(?演算子
  3. HTTPサーバーの実装

  4. テスト可能な設計

    • モジュール分割
    • 依存性の分離
    • エラー処理の一貫性

おわりに

yubin_apiの実装を通じて、Rustによる実践的なWeb API開発の全体像を見てきました。

このプロジェクトでは、カスタムエラー型の定義型安全なデータ変換トレイトの実装といった堅牢な型システムの活用を行いました。また、tokioによる非同期ランタイムasync/awaitの効果的な使用、エラーハンドリングとの統合などの非同期プログラミングの実践も重要な要素となっています。さらに、メトリクス収集構造化ログエラートラッキングといった運用面の考慮など、重要な概念と技術を学ぶことができました。

このプロジェクトは、単なる郵便番号検索APIの実装を超えて、Rustの実践的な使用方法と、プロダクション品質のWebサービス開発の基本を学ぶ良い例となっています。