Rustの新しいWEBフレームワークaxumを触ってみた
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
を実装した -
axum
がhyper::Server
をre-exportするようになった -
extract::UrlParams
とextract::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でルーティングができる
- 宣言的なリクエストのパース
- シンプルで予測可能なエラーハンドリング
- 最小限のボイラープレートでレスポンスを作れる
-
tower
とtower-http
のエコシステムをフル活用できる
サンプルを見た感じ、クセが少なく「こういうので良いんだよ、こういうので」という印象を持ったので早速触ってみました。
コード全体は https://github.com/techno-tanoC/axum_sample/tree/91a5b870c9bbc149ea533a3ec78154b8c202de46 のexamplesにあります。
Hello World!
何はともあれ公式に載っているHello Worldを試してみます。
[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"] }
hyper
と tokio
のfeaturesはサボって full
にしています。
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
を追加しておきます。
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の Deserialize
と Serialize
を実装していれば良いようなので、非常に楽ですね。
$ 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}
ちゃんと動いています。
パスパラメータ
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を作ってみました。
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
型で包めばオプショナルなリクエストパラメータを扱うこともできます。
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
を追加。
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
内でバリデーションや柔軟なエラーレスポンスの構築ができる気がします( 未検証 )
状態
layer
と AddExtensionLayer
を使うことで状態を持つことができるようです。
依存ライブラリに tower-http
を追加。
アクセスする度にカウンタが増えていくAPIを作ってみました。
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 str
や axum::response::Json
以外にも axum::response::IntoResponse
を実装したものであればなんでもレスポンスとして返せます。
デフォルトでは文字列系の型、バイト列系の型、ステータスコード、ヘッダー、 Json
、 Html
、(StatusCode, HeaderMap, IntoResponse)
辺りに実装されているようです。
今回は (StatusCode, HeaderMap, &'static str)
を返してみました。
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
良い感じ。
その他の機能
今回は動かしていませんが、公式のサンプルにあった機能を見てみました。
- Form: https://github.com/tokio-rs/axum/blob/main/examples/form.rs
-
Deserialize
を実装していればaxum::extract::Form
で受け取れるようです。
-
- multipart form: https://github.com/tokio-rs/axum/blob/main/examples/multipart_form.rs
-
axum::extract::Multipart
で受け取れる -
ContentLengthLimit
を使うことでデータの大きさを制限して受け取れそう -
ContentLengthLimit
は他のリクエストパラメータと組み合わせて使えそう
-
- ファイル配信: https://github.com/tokio-rs/axum/blob/main/examples/static_file_server.rs
-
tower_http
のServeDir
を使うことができる
-
- Templating: https://github.com/tokio-rs/axum/blob/main/examples/templates.rs
- exampleではaskamaを使っている
-
IntoResponse
を実装すれば他のテンプレートエンジンも使えそう
- websocket: https://github.com/tokio-rs/axum/blob/main/examples/websocket.rs
所感
- 全体的に記述が直感的
- 公式ドキュメントやexamplesを見れば使い方がおおよそ分かり、学習コストが低いように感じました。(
rocket
やwarp
などの類似のフレームワークを触ったことがあるからかもしれませんが ) -
rocket
やwarp
と比べてもクセが少ないように感じます - 個人的によく使う機能( リクエストパラメータ、JSON、ヘッダ、状態管理辺り )がよくまとまっていて使い勝手が良い印象
-
FromRequest
とRejection
でカスタムextractorを作る部分はやや独特かも
- 公式ドキュメントやexamplesを見れば使い方がおおよそ分かり、学習コストが低いように感じました。(
- リクエスト、ハンドラ、レスポンスの仕組みがシンプルかつ柔軟
- 基本的なリクエストパラメータの扱いが非常に簡単
- 今までは
rocket
が一番リクエストパラメータの扱いが楽だと思っていたのですが、axum
はそれと同等以上だと思いました - 複雑なリクエスト、レスポンスの扱いも
FromRequest
、IntoResponse
を実装すればいけます
- 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