Rust/ActixWebでRestAPIのルーティング、エラーハンドリングなどなど
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?)
- ・Mac(Intel)
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形式ではなくテキストでレスポンスされてしまうんですね。
ので手を加えます。
以下ざっくり手順
- ライブラリ追加
- Enum(Apiエラー種について)とエラーレスポンスの構造体を定義する
- 定義したEnumに対し、actixのResponseError(トレイト)を実装する
- エラー判定を追加する
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ですが色んな記事見てると サービスロケータ
なんじゃ?とか思ったり思わなかったりしてまして。
サービスロケータパターンってゴリゴリのアンチパターンだからちょっとあれだなと思う今日この頃でした。
ライトコードでは、エンジニアを積極採用中!
ライトコードでは、エンジニアを積極採用しています!社長と一杯しながらお話しする機会もご用意しております。そのほかカジュアル面談等もございますので、くわしくは採用情報をご確認ください。
採用情報へ