• トップ
  • ブログ一覧
  • Rust/ActixWebでRestAPIのルーティング、エラーハンドリングなどなど
  • Rust/ActixWebでRestAPIのルーティング、エラーハンドリングなどなど

    たむけん(エンジニア)たむけん(エンジニア)
    2024.01.31

    IT技術

    はじめに

    最近Rustのお勉強してます。
    C/C++並に早く、かつ安全性が高いと言われているあれです。
    色々な用途で利用されてますが、バックエンドを生業としている身としてはWebフレームワークが気になりまして。

    • ・ActixWeb
    • ・Rocket
    • ・Axum

    上記が人気みたいですね。

    で、その中からActixWebを使用し、簡単なRestAPI作ってみようかと思いました。採用した理由はだいたい1番目に紹介されてたので。吟味はしてないです、許してください。

    いきなりですが感想

    ドキュメント ありますが、これを読めばすぐにビジネスロジック書けます、な感じでは無かったです。
    フレームワークをベースにバリデータやロギングなどは他のライブラリ(Rsutではクレートと言う)を追加して作り込んでいく感じでした。

    他のフレームワーク(Axum)もそんな感じ。

    うーん、覚えることたくさん?

    ともあれ導入部やってみました。

    • 動作環境
      • ・Mac(Intel)
        • ・vscode / devcontainer でよしなに
      • ・Rust: 1.75.0
      • ・actix-web: 4(4.4.1?)

    HelloWorld

    プロジェクト作成して、フレームワーク追加して( > cargo add actix-web )、main.rsに ドキュメント(getting-started) の記載内容を記述します。

    1#[get("/")]
    2async fn hello() -> impl Responder {
    3    HttpResponse::Ok().body("Hello world!")
    4}
    5
    6#[actix_web::main]
    7async fn main() -> std::io::Result<()> {
    8    HttpServer::new(|| {
    9        App::new()
    10            .service(hello)
    11    })
    12    .bind(("127.0.0.1", 8080))?
    13    .run()
    14    .await
    15}

    > cargo run で実行し、 http://127.0.0.1:8080  へのアクセスでHello world!確認。

    エントリポイント(mainメソッド)ではルーティングの定義とサーバー起動を実施、
    ルーティングで設定されたメソッドの属性/アトリビュートでHTTPメソッドとURLパスを記載するみたいですね。

    シンプル。

    パスパラメータ

    何らかのIDなど一意なリソースの取得等でおなじみのパスパラメータの定義。

    1#[get("/path1/{param1}")]
    2async fn get_path1(param: actix_web::web::Path<u32>) -> impl Responder {
    3    let req_param = param.into_inner();
    4    HttpResponse::Ok().body(format!("param: {}", req_param))
    5}

    {param1}actix_web::web::Path<u32> のu32型(符号無しな整数型)として定義/紐付け。u32型以外(文字列等)でリクエストすると404エラーとなる。

    複数定義もできる。その場合はタプルで受け取る。

    1#[get("/path2/{param1}/{param2}")]
    2async fn get_path3(params: actix_web::web::Path<(u32, u32)>) -> impl Responder {
    3    let req_params = params.into_inner();
    4    HttpResponse::Ok().body(format!(
    5        "param1: {}, param2: {}",
    6        req_params.0, req_params.1
    7    ))
    8}

    スラッシュを含めたURLパラメータ(というかパス)をそのまま受け取りたい、なんてこともできちゃう。

    1#[get("/path3/{param:.*}")]
    2async fn get_path3(param: actix_web::web::Path<String>) -> impl Responder {
    3    let req_param = param.into_inner();
    4    HttpResponse::Ok().body(format!("param: {}", req_param))
    5}

    {param:.*} と正規表現(全ての値)指定するだけ。
    これで例えば http://localhost:8080/path3/do/re/mi へアクセスすると、paramには do/re/mi が設定される。
    素敵!需要は無さそうだけど。

    Jsonリクエスト/レスポンス

    RestApiなのでJson形式でリクエスト/レスポンスしたい。
    という時に利用するのが serde というライブラリ。
    serialize/deseliarize、略してserdeみたいな?しかし読み方が分からない。素直に読めばサーデとかなんでしょうけどセルデやシリデもあるかもしれない、とか考え出すとおちおち眠れない。Rustって読みが難しいワード多い気がするですよね。deriveとかもそう。道草。

    serdeライブラリを追加して( > cargo add serde --features derive)、リクエスト/レスポンスのstruct/構造体を作成する。

    1/// リクエスト
    2#[derive(serde::Deserialize)]
    3struct JsonReq {
    4    val1: u32,
    5    val2: String,
    6}
    7
    8/// レスポンス
    9#[derive(serde::Serialize)]
    10struct JsonRes {
    11    res_val1: u32,
    12    res_val2: String,
    13}

    リクエストはデシリアライズして、レスポンスはシリアライズする。

    つづいて処理を定義する。

    1#[post("/json")]
    2async fn post_json(data: actix_web::web::Json<JsonReq>) -> impl Responder {
    3    let req = data.into_inner();
    4    HttpResponse::Ok().json(JsonRes {
    5        res_val1: req.val1,
    6        res_val2: req.val2,
    7    })
    8}

    actix_web::web::Json<JsonReq> でJson形式のリクエストをリクエスト構造体に変換してくれる。
    非Json形式や項目指定に不備がある場合にリクエストと400エラーとなる。
    また、HttpResponse::Ok().json(JsonRes {〜 でレスポンス構造体をJson形式に変換してくれる。

    Json形式の取扱もシンプルですね。

    エラーハンドリング

    ここまではシンプルでしたが問題がありまして。
    RestAPIなので正常/エラー問わずレスポンスはJson形式で返したいのですがエラーの際に手を加えないとJson形式ではなくテキストでレスポンスされてしまうんですね。

    ので手を加えます。

    以下ざっくり手順

    1. ライブラリ追加
    2. Enum(Apiエラー種について)とエラーレスポンスの構造体を定義する
    3. 定義したEnumに対し、actixのResponseError(トレイト)を実装する
    4. エラー判定を追加する

    1. ライブラリ追加

    エラーハンドリングを簡単に実施するライブラリとなります。

    • thiserror
    • anyhow

    (> cargo add thiserror, anyhow)

    2. Enumとエラーレスポンスの構造体を定義

    1#[derive(thiserror::Error, Debug)]
    2enum ApiCustomError {
    3	/// ルーティング未定義URLへのアクセス
    4    #[error("Not Found.")]
    5    NotFound,
    6
    7	/// actix内の処理で発生したエラー
    8    #[error(transparent)]
    9    ActixWebError(#[from] actix_web::Error),
    10
    11	/// その他
    12    #[error(transparent)]
    13    Other(anyhow::Error),
    14}
    15
    16/// エラーレスポンス
    17#[derive(Serialize)]
    18struct ErrRes {
    19    message: String,
    20}

    ざっくり言うと引数の無い列挙子(NotFound)は #[error("Not Found.")] の()内がエラーメッセージになり、それ以外(ActixWebError、Other)は引数(エラー)の std::fmt::Display の設定値がメッセージになります。

    うーん、説明難しい。

    3. actixのResponseError(トレイト)を実装

    1impl ResponseError for ApiCustomError {
    2	/// ステータスコード
    3    fn status_code(&self) -> actix_web::http::StatusCode {
    4        match self {
    5            ApiCustomError::NotFound => StatusCode::NOT_FOUND,
    6            ApiCustomError::ActixWebError(err) => err.as_response_error().status_code(),
    7            ApiCustomError::Other(_) => StatusCode::INTERNAL_SERVER_ERROR,
    8        }
    9    }
    10
    11	/// エラーレスポンス
    12    fn error_response(&self) -> HttpResponse<BoxBody> {
    13        match self {
    14            ApiCustomError::NotFound => HttpResponse::build(self.status_code()).json(ErrRes {
    15                message: format!("{}", self),
    16            }),
    17            ApiCustomError::ActixWebError(err) => {
    18                let message = match self.status_code() {
    19                    StatusCode::NOT_FOUND => "Not Found.",
    20                    _ => "Bad Request.",
    21                };
    22                HttpResponse::build(self.status_code()).json(ErrRes {
    23                    message: format!("{} [{}]", message, err),
    24                })
    25            }
    26            ApiCustomError::Other(err) => HttpResponse::build(self.status_code()).json(ErrRes {
    27                message: format!("Internal Server Error. [{}]", err),
    28            }),
    29        }
    30    }
    31}

    定義したエラーEnum(ApiCustomError)に応じて、ステータスとレスポンス内容を定義してます。
    ActixWebErrorで分岐を入れてるですが、これは actix_web::Error が取りうるエラーは単一では無いようでしたのでエラー内容に応じたレスポンスをするための措置となります(404と400を定義してますが足りてないかもですmm)。

    4. エラー判定追加

    4-1. ルーティング未定義URLへのアクセス

    1async fn route_unmatch() -> Result<HttpResponse, ApiCustomError> {
    2    Err(ApiCustomError::NotFound)
    3}
    4
    5#[actix_web::main]
    6async fn main() -> std::io::Result<()> {
    7    HttpServer::new(|| {
    8        App::new()
    9            .service(hello)
    10			// >>> 追加
    11            .default_service(
    12                web::route().to(route_unmatch),
    13            )
    14			// <<< ここまで
    15    })
    16    .bind(("127.0.0.1", 8080))?
    17    .run()
    18    .await
    19}

    `.default_service()` 追加が肝ですね。これでルーティング未定義のURLへのアクセス時はJsonでレスポンスされます。

    4-2. actix内の処理で発生したエラー

    パスパラメータ、Jsonリクエスト/レスポンス時のを変更する

    1/// パスパラメータ
    2#[derive(Serialize)]
    3struct Path1Res {
    4    value: u32,
    5}
    6
    7#[get("/path1/{param1}")]
    8async fn get_path1(
    9    param: Result<actix_web::web::Path<u32>, actix_web::Error>,
    10) -> Result<HttpResponse, ApiCustomError> {
    11    let req_param = match param {
    12        Ok(param) => param.into_inner(),
    13        Err(err) => return Err(ApiCustomError::ActixWebError(err)),
    14    };
    15    Ok(HttpResponse::Ok().json(Path1Res { value: req_param }))
    16}
    17
    18/// Jsonリクエスト/レスポンス
    19#[post("/json")]
    20async fn post_json(
    21    data: Result<actix_web::web::Json<JsonReq>, actix_web::Error>,
    22) -> Result<impl Responder, ApiCustomError> {
    23    let req = match data {
    24        Ok(json_req) => json_req.into_inner(),
    25        Err(err) => return Err(ApiCustomError::ActixWebError(err)),
    26    };
    27    Ok(HttpResponse::Ok().json(JsonRes {
    28        res_val1: req.val1,
    29        res_val2: req.val2,
    30    }))
    31}

    どちらも引数を Result<T, E> に変更してます。actix_web::web::Path<T>actix_web::web::Json<T> で不整合(変換できないなど)がある場合actix_web::Errorとして処理されるので、それを利用して独自エラー(ApiCustomError)に渡してよしなにレスポンス内容を制御する、という感じとなります。

    これでエラー時もJson形式でレスポンスするようになりました。しかし、急にややこしい。

    今回はここまでmm

    まとめ

    という感じで覚えることたくさんです。全然ビジネスロジック書くに至らないって言う。
    ちなみに導入部の追加の対応/覚えることは下記な感じですかね?

    • バリデータ
    • ログ関連
    • DB関連
    • AOP/ミドルウェア?
    • (みんな大好き)DI関連

    機会があれば次回に続きを書こーかなと思います。乞うご期待(?)

    おまけ

    RustでのDIですが色んな記事見てると サービスロケータ なんじゃ?とか思ったり思わなかったりしてまして。

    サービスロケータパターンってゴリゴリのアンチパターンだからちょっとあれだなと思う今日この頃でした。

    たむけん(エンジニア)

    たむけん(エンジニア)

    おすすめ記事