Introduction
actix-web、axum、Loco、Rocket、Poem、Warpなど、
RustのWebフレームワークはいろいろとありますが、
一暼すると、ある程度似た作りに見えます。
しかし、ここで紹介するPavexは、
他のフレームワークとはアプローチが異なっており、
pavex_cliと呼ばれるトランスパイラにBluePrint(アプリの設計図)を渡すことで
APIサーバ用のコードを生成します。
このフレームワークはZero To Production In Rustの著者である、
Luca Palmieri氏が作成したフレームワークです。
以前から気になっており、先日closed betaが開始されました。
本稿ではPavexの基本情報について解説し、
ドキュメントにそってデモを動かしてみます。
Pavex?
Pavexって何?とかなんで新しくFWを作るの?といった疑問に対する回答は
ここらへんに詳細があります。
↑によると、新たにフレームワークをつくる理由は
「RustのWEBアプリ開発体験をもっと向上させるため」
とのことです。
既存がRust用フレームワークは、
多くの人が簡単に使いやすいAPIを開発できるようにしている。
しかしその一方、高性能でコンパイル段階で誤りが検出できるような
インターフェイスを提供もしたいと考えている。
「簡単に使いやすい」と「コンパイル段階での安全性」を
両立させるのはなかなか難しい問題です。
↑の記事ではaxumを例にして誤使用したときの
エラーの難解さとその回避方法について述べています。
たしかにメッセージはわかりにくいですが、
debug_handlerマクロを使うことでメッセージが改善されます。
axumの#[debug_handler]は、axum-macros featureで使うことができます。
これによりaxumがエラー検出時にその処理を差し替えて、
わかりやすくエラーを伝えることができます。
その結果エラーのコンテキストが豊富になり、原因の特定がしやすくなるため、
デバッグが簡素化されます。
Pavexはこのアプローチを先へ進めようとしています。
ユーザー向けの複雑さを取り除き、Webアプリ用に設計された
pavex_cliにアプリの仕様を渡します。
そして、pavex_cliがWebサーバ用ソースコードを生成します。
問題があれば、わかりやすいメッセージでエラーを教えてくれます。
Pavex Features
↑でいったように、Pavexはアプリの仕様を記述したBlueprintを入力として受け取ります。
route情報の登録と、DIを利用したコンストラクタの登録をしています。
//src/blueprint.rs
pub fn blueprint() -> Blueprint {
let mut bp = Blueprint::new();
register_common_constructors(&mut bp);
add_telemetry_middleware(&mut bp);
bp.route(GET, "/api/hello/:message", f!(crate::routes::hello::say));
bp
}
/// Common constructors used by all routes.
fn register_common_constructors(bp: &mut Blueprint) {
// Route parameters
bp.constructor(
f!(pavex::request::route::RouteParams::extract),
Lifecycle::RequestScoped,
)
.error_handler(f!(
pavex::request::route::errors::ExtractRouteParamsError::into_response
));
・・・
}
DIするとき、そのインスタンスのライフサイクルを決めることができます。
上記RouteParamsはRequestScopedなので、リクエストごとにインスタンスが作成されます。
他にSingletonとTransientがあり、用途に応じて設定します。
このへんはSpringなどのDIコンテナ使用した経験があれば
イメージしやすいかと思います。
routes/hello.rsでは下記のようにsay関数が定義されています。
RouteParamsがDIされているので、
say関数でRoute parameterを使うことができます。
use pavex::response::Response;
use pavex::request::route::RouteParams;
#[RouteParams]
pub struct HelloParams {
pub message: String,
}
pub fn say(params: RouteParams<HelloParams>) -> Response {
let HelloParams { message }= params.0;
Response::ok()
.set_typed_body(format!("{message}!!!"))
.box_body()
}
これをpavex_cliに渡すことで、APIサーバ用のSDKクレートを生成します。
実際に起動するのは、生成されたSDKコードです。
↑で記述したBlueprintはコンパイル時たけ存在し、実行時には使われません。
pavex_cliがこのBlueprintを実行可能なAPI SDKにトランスパイルします。
このように、Pavexがいい具合に中間コードを生成するので、
綿密な静的解析を行うことができます。
そうすることでコンパイル時にわかりやすいエラーメッセージを提示することができます。
あとは必要に応じて独自の要件に対応できるよう、柔軟に設計されています。
フレームワークのコンポーネント実装を置き換えたり、
好きなライブラリと組み合わせたりもできます。
ざっとPavexの概要について説明しましたが、
さらなる詳細はここやここを参考にしてください。
Environment
- MacBook Pro (13-inch, M1, 2020)
- OS : MacOS 13.5.2
- Rust : 1.74.0
- Docker : 24.0.7
Setup
では、Pavexのセットアップをしましょう。
cargo-pxやnightlyのツールなど、必要なものをインストールしましょう。
% cargo install --locked cargo-px --version="~0.1"
% rustup toolchain install nightly
% rustup component add --toolchain nightly rust-docs-json
cargo-pxはPavexと同じ作者によるツールです。
build.rsを拡張するような機能をもっており、Pavexでコード生成する際に使用します。
nightly用のツールはインストールしていますが、
Pavexはがnightlyを使用してアプリをコンパイルするわけではありません。
実行するコードは、stable toolcahinでコンパイルされます。
nightlyに依存しているのはPavexのコード生成と
コンパイル時のリフレクションに関連する部分です。
自分の環境のバージョンは下記です。
% rustup --version && \
cargo --version && \
cargo px --version
rustup 1.26.0 (5af9b9484 2023-04-05)
info: This is the version for the rustup toolchain manager, not the rustc compiler.
info: The currently active `rustc` version is `rustc 1.74.0 (79e9716c9 2023-11-13)`
cargo 1.74.0 (ecb9851af 2023-10-18)
cargo 1.74.0 (ecb9851af 2023-10-18)
Docs Server
クイックスタート、チュートリアルなどのガイド用ドキュメントが見たい場合、
下記手順でドキュメントを見ることができます。
% git clone https://github.com/LukeMathWalker/pavex
% cd pavex/docs
% docker build -t pavex-docs .
% cd ../
% docker run --rm -it -p 8001:8000 -v ${PWD}:/docs pavex-docs
localhost:8001でWebサーバが起動するので、
(現時点では未完の部分もありますが)ドキュメントを確認できます。
なお、ドキュメントを更新したい場合は下記コマンドを実行しましょう。
% cd /path/your/pavex
% cd libs && cargo doc --no-deps --package pavex
Try
では、↑のドキュメントのQuickstartに沿って(多少端折る)demoアプリを作成してみます。
pavex_cliをインストールしてpavexコマンドを使えるようにしましょう。
% cargo install --locked \
--git "https://github.com/LukeMathWalker/pavex.git" \
--branch "main" \
pavex_cli
% pavex --version
pavex_cli 0.1.0 (422359f)
pavex newコマンドでdemoプロジェクトの雛形を作成します。
% pavex new demo && cd demo
buildコマンドでビルド。
% cargo px build
checkコマンドで、ローカルパッケージとその依存関係すべてに
エラーがないかチェックします。
% cargo px check
testsディレクトリにあるテストモジュールを実行します。
cargo px test
runコマンドでWebサーバが起動します。
cargo px run
デフォルトでは8000番ポートで起動します。
変えたい人はdemo_server/configuration/dev.ymlで変更しましょう。
demoプロジェクには、デフォルトでHealth checkのAPI(GET /api/ping)があります。
curlでアクセスしてみます。
% curl -v http://localhost:8000/api/ping
* Trying ::1:8000...
* Connection failed
* connect to ::1 port 8000 failed: Connection refused
* Trying 127.0.0.1:8000...
* Connected to localhost (127.0.0.1) port 8000 (#0)
> GET /api/ping HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/7.71.1
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< content-length: 0
< date: Thu, 07 Dec 2023 08:54:16 GMT
<
* Connection #0 to host localhost left intact
アクセス成功しました。
このプロジェクトのコードをいじってみましょう。
crateの追加
あとで使用するので、thiserror crateをインストールしておきます。
% cd /path/your/demo
% cd demo
% cargo add thiserror
インストールする場所に注意。
Brueprint
Brueprintでrouteとコンストラクタを追加します。
demo/src/bluerint.rsを下記のように修正しましょう。
pub fn blueprint() -> Blueprint {
let mut bp = Blueprint::new();
register_common_constructors(&mut bp);
//追加
bp.constructor(
f!(crate::user_agent::UserAgent::extract),
Lifecycle::RequestScoped,
).error_handler(f!(crate::user_agent::invalid_user_agent));
add_telemetry_middleware(&mut bp);
bp.route(GET, "/api/ping", f!(crate::routes::status::ping));
//追加
bp.route(GET, "/api/greet/:name", f!(crate::routes::greet::greet));
bp
}
コンストラクタは独自にDIするUserAgentに関するモジュールです。
bp.routeにパスとhandler関数を指定します。
handlerを作成
では、新しいhandlerを追加してみましょう。
まずはdemo/src/lib.rsに下記コードを追加します。
・
・
pub mod user_agent;
demo/src/routes/mod.rsにも下記コードを追加します。
・
・
pub mod greet; //追加
さきほどコンストラクタで指定した、UserAgentを作成します。
UserAgentが不正(UTF-8でない)場合と
見つからない場合はエラーにします。
//src/user_agent.rs
use std::fmt;
use pavex::http::header::{ToStrError, USER_AGENT};
use pavex::request::RequestHead;
use pavex::response::Response;
use thiserror::Error;
pub enum UserAgent {
Unknown,
Known(String),
}
impl fmt::Display for UserAgent {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
UserAgent::Unknown => write!(f, "Unknown"),
UserAgent::Known(value) => write!(f, "{}", value),
}
}
}
#[derive(Debug, Error)]
pub enum MyError {
#[error("{0}")]
InvalidHeaderValue(String),
#[error("{0}")]
NotFound(String),
}
impl UserAgent {
pub fn extract(request_head: &RequestHead) -> Result<Self, MyError> {
match request_head.headers.get(USER_AGENT) {
Some(user_agent) => {
user_agent.to_str()
.map(|s| UserAgent::Known(s.into()))
.map_err(|e| MyError::InvalidHeaderValue(format!("{}{}", "User-Agent:" ,e)))
},
None => return Err(MyError::NotFound("User-Agent is Required".into())),
}}
}
pub fn invalid_user_agent(e: &MyError) -> Response {
match e {
MyError::InvalidHeaderValue(_) => {
Response::bad_request()
.set_typed_body(e.to_string())
.box_body()
},
MyError::NotFound(_) => {
Response::not_found()
.set_typed_body(e.to_string())
.box_body()
}
}
}
demo/src/routes/greet.rsも作成。
パスで指定されたnameとUserAgentを取得して返します。
use pavex::response::Response;
use pavex::request::route::RouteParams;
use crate::user_agent::UserAgent;
#[RouteParams]
pub struct GreetParams {
pub name: String,
}
pub fn greet(params: RouteParams<GreetParams>, user_agent: UserAgent) -> Response {
let GreetParams { name } = params.0;
println!("{:?}",user_agent.to_string());
Response::ok()
.set_typed_body(format!("Hello, {name}!({user_agent})"))
.box_body()
}
動かしてみます。
% cargo px run
User-Agentを渡せば200OK。
% curl -H "User-Agent: MyUserAgent" -v http://localhost:8000/api/greet/taro
・
・
Hello, taro!(MyUserAgent)%
User-Agentなしだと404が返ってきます。
% curl -H "User-Agent:" -v http://localhost:8000/api/greet/taro
・
・
User-Agent is Required%
テストを追加する
テストも追加してみましょう。
demo_server/tests/integration/main.rsにモジュールを追加します。
mod ping;
mod helpers;
mod greet; //追加
テスト用モジュールを作成します。
greetの成功パターン、UserAgentが見つからないパターン、
UserAgentがUTF8でないパターンのテストケースを作成しています。
//demo_server/tests/integration/greet.rs
use crate::helpers::TestApi;
use pavex::http::StatusCode;
#[tokio::test]
async fn greet_happy_path() {
let api = TestApi::spawn().await;
let name = "Ursula";
let response = api
.api_client
.get(&format!("{}/api/greet/{name}", &api.api_address))
.header("User-Agent", "Test runner")
.send()
.await
.expect("Failed to execute request.");
assert_eq!(response.status().as_u16(), StatusCode::OK.as_u16());
assert_eq!(response.text().await.unwrap(), "Hello, Ursula!(Test runner)");
}
#[tokio::test]
async fn non_utf8_user_agent_is_rejected() {
let api = TestApi::spawn().await;
let name = "Ursula";
let response = api
.api_client
.get(&format!("{}/api/greet/{name}", &api.api_address))
.header("User-Agent", b"hello\xfa".as_slice())
.send()
.await
.expect("Failed to execute request.");
assert_eq!(response.status().as_u16(), StatusCode::BAD_REQUEST.as_u16());
assert_eq!(
response.text().await.unwrap(),
"User-Agent:failed to convert header to a str"
);
}
#[tokio::test]
async fn notfound_user_agent() {
let api = TestApi::spawn().await;
let name = "Ursula";
let response = api
.api_client
.get(&format!("{}/api/greet/{name}", &api.api_address))
.send()
.await
.expect("Failed to execute request.");
assert_eq!(response.status().as_u16(), StatusCode::NOT_FOUND.as_u16());
assert_eq!(
response.text().await.unwrap(),
"User-Agent is Required"
);
}
testコマンドでテストします。
% cargo px test
running 4 tests
test ping::ping_works ... ok
test greet::notfound_user_agent ... ok
test greet::non_utf8_user_agent_is_rejected ... ok
test greet::greet_happy_path ... ok
test result: ok. 4 passed; ・・・・
無事パスしました。
Summary
今回はRust用の最新フレームワーク、Pavexについて紹介しました。
既存のフレームワークと比較すると、
Pavexは他のそれと一線を画しています。
とても興味深いプロダクトなので、
これからもチェックしていきたいところです。