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

    はじめに

    最近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ですが色んな記事見てると サービスロケータ なんじゃ?とか思ったり思わなかったりしてまして。

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

    ライトコードでは、エンジニアを積極採用中!

    ライトコードでは、エンジニアを積極採用しています!社長と一杯しながらお話しする機会もご用意しております。そのほかカジュアル面談等もございますので、くわしくは採用情報をご確認ください。

    採用情報へ

    おすすめ記事

    エンジニア大募集中!

    ライトコードでは、エンジニアを積極採用中です。

    特に、WEBエンジニアとモバイルエンジニアは是非ご応募お待ちしております!

    また、フリーランスエンジニア様も大募集中です。

    background