🦀

Rustの新しいWEBフレームワークaxumを触ってみた

2021/07/31に公開

axum version0.2.0 is released!!

本日2021-08-24、axum version0.2.0がリリースされました。

この記事のコードを参考にする場合はバージョンによる違いに注意してください。

2021-08-14追記

axum 0.1.2, 0.1.3のリリースによりこの記事の一部の記述は古いものとなりました。
この記事に関わる変更は以下です。

  • 多くのextractorが Deref を実装した
  • axumhyper::Server をre-exportするようになった
  • extract::UrlParamsextract::UrlParamsMap が非推奨となり、 extract::Path が推奨されるようになった

axum 0.1.3対応版のコードは https://github.com/techno-tanoC/axum_sample/tree/680a5aad51a1b0302910bade890ad82aaf069fda にあります。

追記

Zenn公式ツイッターにピックアップされました!ありがとうございます!

追記ここまで

axum

日本時間本日、tokioからAxumというWEBフレームワークがアナウンスされました。

( リリースされて13時間のできたてホヤホヤの図 )

crates.ioのREADMEによると「エルゴノミックでモジュラー性にフォーカスしたWEBアプリケーションフレームワーク」だそうです。公式のアナウンスによると「tokioのエコシステムの利点をフル活用できる」とのことです。tokioのお膝下で作られているだけあって期待できます。

他にも以下の特徴が挙げられています。

  • マクロのないAPIでルーティングができる
  • 宣言的なリクエストのパース
  • シンプルで予測可能なエラーハンドリング
  • 最小限のボイラープレートでレスポンスを作れる
  • towertower-http のエコシステムをフル活用できる

サンプルを見た感じ、クセが少なく「こういうので良いんだよ、こういうので」という印象を持ったので早速触ってみました。

コード全体は https://github.com/techno-tanoC/axum_sample/tree/91a5b870c9bbc149ea533a3ec78154b8c202de46 のexamplesにあります。

Hello World!

何はともあれ公式に載っているHello Worldを試してみます。

Cargo.toml
[package]
name = "axum_sample"
version = "0.1.0"
edition = "2018"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
axum = "0.1.1"
hyper = { version = "0.14", features = ["full"] }
tokio = { version = "1", features = ["full"] }

hypertokio のfeaturesはサボって full にしています。

helloworld.rs
use axum::prelude::*;
use std::net::SocketAddr;

#[tokio::main]
async fn main() {
    let app = route("/", get(root));
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    hyper::Server::bind(&addr)
        .serve(app.into_make_service())
        .await
        .unwrap();
}

async fn root() -> &'static str {
    "Hello, World!"
}

当然のようにハンドラが async で嬉しくなりますね。

$ curl http://localhost:3000/
Hello, World!

文字列を返すハンドラは文字列をそのまま content-type: text/plain で返すようです。

よさそう

JSON API

続いてはJSONを受け取ってJSONを返すAPIを作ってみます。
今回は簡単に「受け取ったカウントに1を足して返すAPI」を作ってみます。
依存ライブラリに serde を追加しておきます。

json.rs
use axum::{
    extract,
    response,
    prelude::*,
};
use serde::{Serialize, Deserialize};
use std::net::SocketAddr;

#[tokio::main]
async fn main() {
    let app = route("/ping", post(ping));
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    hyper::Server::bind(&addr)
        .serve(app.into_make_service())
        .await
        .unwrap();
}

#[derive(Deserialize)]
struct Ping {
    count: i64,
}

#[derive(Serialize)]
struct Pong {
    count: i64,
}

async fn ping(extract::Json(ping): extract::Json<Ping>) -> response::Json<Pong> {
    response::Json(Pong {
        count: ping.count + 1,
    })
}

リクエストは axum::extract::Json 、レスポンスは axum::response::Json を使うようです。それぞれserdeの DeserializeSerialize を実装していれば良いようなので、非常に楽ですね。

$ curl -H 'Content-Type: application/json' -d '{"count": 0}' http://localhost:3000/ping
{"count":1}
$ curl -H 'Content-Type: application/json' -d '{"count": 3}' http://localhost:3000/ping
{"count":4}

ちゃんと動いています。

パスパラメータ

path_params.rs
use axum::{
    extract,
    response,
    prelude::*,
};
use serde::{Serialize, Deserialize};
use std::net::SocketAddr;

#[tokio::main]
async fn main() {
    let app = route("/users/:user_id/posts/:post_id", get(user_post));
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    hyper::Server::bind(&addr)
        .serve(app.into_make_service())
        .await
        .unwrap();
}

async fn user_post(extract::UrlParams(params): extract::UrlParams<(u64, u64)>) -> String {
    let user_id = params.0;
    let post_id = params.1;
    format!("user_id: {}, post_id: {}", user_id, post_id)
}

axum::extract::UrlParams を使えば良いようです。
パスパラメータがタプルになっているのでインデックスでアクセスします。

$ curl http://localhost:3000/users/1/posts/2
user_id: 1, post_id: 2

UrlParamsMap を使えばハッシュマップのように扱うこともできるっぽいですが、引数部分で型を明示できる UrlParams の方がシンプルなケースでは良さそうです。

パスパラメータ+JSON API

extractorはハンドラの引数部分でそのまま組み合わせられます。
パスパラメータで user_id 、JSONで name を受け取ってメッセージをJSONで返すAPIを作ってみました。

path_json.rs
use axum::{
    extract,
    response,
    prelude::*,
};
use serde::{Serialize, Deserialize};
use std::net::SocketAddr;

#[tokio::main]
async fn main() {
    let app = route("/users/:user_id", post(user_message));
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    hyper::Server::bind(&addr)
        .serve(app.into_make_service())
        .await
        .unwrap();
}

#[derive(Deserialize)]
struct Name {
    name: String,
}

#[derive(Serialize)]
struct Message {
    message: String,
}

async fn user_message(
    extract::UrlParams(params): extract::UrlParams<(u64,)>,
    extract::Json(name): extract::Json<Name>,
) -> response::Json<Message> {
    let user_id = params.0;
    let name = name.name;
    response::Json(Message {
        message: format!("Hello {}, your id is {}", name, user_id),
    })
}

$ curl -H 'Content-Type: application/json' -d '{"name": "techno"}' http://localhost:3000/users/1
{"message":"Hello techno, your id is 1"}

パスパラメータ、クエリパラメータ、ヘッダー、Bodyなどから、欲しいextractorを引数部分に書くだけなので非常に楽です。 Option 型で包めばオプショナルなリクエストパラメータを扱うこともできます。

https://docs.rs/axum/0.1.1/axum/extract/index.html#multiple-extractors
https://docs.rs/axum/0.1.1/axum/extract/index.html#optional-extractors

FromRequest

FromRequest というのを実装すればカスタムextractorを作れます。

from_request という名前の関数を検索するといくつかヒットし、それらを組み合わせてカスタムextractorを定義できるようです。 https://docs.rs/axum/0.1.1/axum/extract/index.html#defining-custom-extractors

  • Multipart
  • Query
  • Form
  • Json
  • Extension
  • BodyStream
  • Body
  • ContentLengthLimit
  • UrlParamsMap
  • UrlParams
  • TypedHeader
  • RawQuery
  • Request

1つ上で作ったパスパラメータ+JSONのカスタムextractorを実装してみます。
依存ライブラリに http http-body serde_json tower を追加。

from_request.rs
use axum::{
    async_trait,
    extract::{self, FromRequest, RequestParts},
    prelude::*,
    response::{self, IntoResponse},
};
use http::Response;
use serde::{Deserialize, Serialize};
use std::net::SocketAddr;
use tower::BoxError;

#[tokio::main]
async fn main() {
    let app = route("/users/:user_id", post(user_message));
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    hyper::Server::bind(&addr)
        .serve(app.into_make_service())
        .await
        .unwrap();
}

#[derive(Deserialize)]
struct Params {
    user_id: u64,
    name: String,
}

#[async_trait]
impl<B> FromRequest<B> for Params
where
    B: Send + http_body::Body,
    B::Data: Send,
    B::Error: Into<BoxError>,
{
    type Rejection = Response<Body>;

    async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
        let url_params = extract::UrlParamsMap::from_request(req)
            .await
            .map_err(IntoResponse::into_response)?;
        let user_id = url_params
            .get_typed("user_id")
            .unwrap()
            .unwrap();

        let json_params: extract::Json<serde_json::Value> = extract::Json::from_request(req)
            .await
            .map_err(IntoResponse::into_response)?;
        let name = json_params.0.get("name")
            .unwrap()
            .as_str()
            .unwrap();

        Ok(Params {
            user_id: user_id,
            name: name.to_string(),
        })
    }
}

#[derive(Serialize)]
struct Message {
    message: String,
}

async fn user_message(
    params: Params
) -> response::Json<Message> {
    let user_id = params.user_id;
    let name = params.name;
    response::Json(Message {
        message: format!("Hello {}, your id is {}", user_id, name),
    })
}

unwrap でエラーハンドリングをサボっています。すみません。

$ curl -H 'Content-Type: application/json' -d '{"name": "techno"}' http://localhost:3000/users/1
{"message":"Hello techno, your id is 1"}

また、 Rejection を上手く使うことで from_request 内でバリデーションや柔軟なエラーレスポンスの構築ができる気がします( 未検証 )

状態

layerAddExtensionLayer を使うことで状態を持つことができるようです。
依存ライブラリに tower-http を追加。

アクセスする度にカウンタが増えていくAPIを作ってみました。

state.rs
use axum::{
    extract,
    prelude::*,
    response,
};
use serde::{Serialize, Deserialize};
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::sync::Mutex;
use tower_http::add_extension::AddExtensionLayer;

#[tokio::main]
async fn main() {
    let state = Arc::new(Mutex::new(State { counter: 0 }));
    let app = route("/", get(increment))
        .layer(AddExtensionLayer::new(state));
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    hyper::Server::bind(&addr)
        .serve(app.into_make_service())
        .await
        .unwrap();
}

#[derive(Serialize, Deserialize)]
struct State {
    counter: i64,
}

type SharedState = Arc<Mutex<State>>;

async fn increment(extract::Extension(state): extract::Extension<SharedState>) -> response::Json<State> {
    let mut s = state.lock().await;
    s.counter += 1;
    let current = s.counter;
    response::Json(State { counter: current })
}
$ curl http://localhost:3000/
{"counter":1}
$ curl http://localhost:3000/
{"counter":2}
$ curl http://localhost:3000/
{"counter":3}

公式のexampleにはPostgresのConnectionPoolを状態として持つものも紹介されています。 https://github.com/tokio-rs/axum/blob/main/examples/tokio_postgres.rs

レスポンス

&'static straxum::response::Json 以外にも axum::response::IntoResponse を実装したものであればなんでもレスポンスとして返せます。

https://docs.rs/axum/0.1.1/axum/index.html#building-responses

デフォルトでは文字列系の型、バイト列系の型、ステータスコード、ヘッダー、 JsonHtml(StatusCode, HeaderMap, IntoResponse) 辺りに実装されているようです。
https://docs.rs/axum/0.1.1/axum/response/trait.IntoResponse.html

今回は (StatusCode, HeaderMap, &'static str) を返してみました。

response.rs
use axum::prelude::*;
use http::{HeaderMap, StatusCode};
use std::net::SocketAddr;

#[tokio::main]
async fn main() {
    let app = route("/", get(response));
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    hyper::Server::bind(&addr)
        .serve(app.into_make_service())
        .await
        .unwrap();
}

async fn response() -> (StatusCode, HeaderMap, &'static str) {
    let mut headers = HeaderMap::new();
    headers.isert("x-hello", "world".parse().unwrap());
    (StatusCode::CREATED, headers, "created")
}
$ curl -v http://localhost:3000/
*   Trying 127.0.0.1:3000...
* Connected to localhost (127.0.0.1) port 3000 (#0)
> GET / HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.78.0
> Accept: */*
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 201 Created
< x-hello: world
< content-length: 7
< date: Sat, 31 Jul 2021 09:03:04 GMT
< 
* Connection #0 to host localhost left intact
created

良い感じ。

その他の機能

今回は動かしていませんが、公式のサンプルにあった機能を見てみました。

所感

  • 全体的に記述が直感的
    • 公式ドキュメントやexamplesを見れば使い方がおおよそ分かり、学習コストが低いように感じました。( rocketwarp などの類似のフレームワークを触ったことがあるからかもしれませんが )
    • rocketwarp と比べてもクセが少ないように感じます
    • 個人的によく使う機能( リクエストパラメータ、JSON、ヘッダ、状態管理辺り )がよくまとまっていて使い勝手が良い印象
    • FromRequestRejection でカスタムextractorを作る部分はやや独特かも
  • リクエスト、ハンドラ、レスポンスの仕組みがシンプルかつ柔軟
    • 基本的なリクエストパラメータの扱いが非常に簡単
    • 今までは rocket が一番リクエストパラメータの扱いが楽だと思っていたのですが、 axum はそれと同等以上だと思いました
    • 複雑なリクエスト、レスポンスの扱いも FromRequestIntoResponse を実装すればいけます
  • tokioの下で作られているので比較的安心して使い続けられる
  • コンパイルエラーのメッセージがやや分かりにくい
    • the trait bound `hyper::common::exec::Exec: hyper::common::exec::ConnStreamExec<RouteFuture<axum::handler::OnMethod<IntoService<fn(UrlParams<(u64,)>, std::option::Option<axum::extract::Json<Name>>) -> impl Future {user_message}, hyper::Body, _>, EmptyRouter>, EmptyRouter, hyper::Body>, http_body::combinators::box_body::BoxBody<hyper::body::Bytes, BoxStdError>>` is not satisfied みたいな長いエラーが何個も出てきて何が問題なのかパッと見では分からないことがありました
    • まぁこれはrustあるあるなので axum に限った話ではないです

まとめ

  • tokio, hyper, towerをフル活用した新しいWEBフレームワーク axum がリリースされた
  • 既存のWEBフレームワークと比べてクセが少なく使い勝手が良さそう & 学習コストが低そう
  • tokio + hyper をベースにしたWEBフレームワークの中では、 axum が デファクトスタンダードになっていく気がする

Discussion