Rust Advent Calendar 2019 25日目の記事です。
tide は現在開発途中の、 Rust の async/await に対応した HTTP サーバーを構築するフレームワークです。not ready for production yet
なので本番にこれを使用するのは難しいかもしれませんが、いろいろな例を見てみた感じとても使いやすそうで、注目に値するフレームワークの一つです。
記事を少し読んでみたのですが、どうやら 2018 年に Rust の Network Service Working Group が開発に着手したフレームワークのようですね。現在のステータスを追いかけていないので詳しくはわかりませんが、Rust チームの方々が何かしら関わっているフレームワークということで、少し安心感がもてるかなと私は思っています。async/await が今年無事安定化されたので、一層開発が進んでくれると嬉しい…そんなフレームワークです。
また、開発者の方の Twitter はこちら。時々 tide に関する最新情報が流れてくるので、tide がどういう状況かを逐次キャッチアップしたい方はフォローしておくとよいと思います🙂
今回はそんな tide を少し触ってみたので、解説記事を書いておきたいと思います*1。
実行 OS は macOS version 10.14 です。また、テンプレ用のリポジトリも用意しました→GitHub - yuk1ty/tide-example: A build template for tide
Hello, World してみる
まずは GitHub のサンプルを写したらいけるだろうということで、README.md
のものをそのままローカルに落としてきて Hello, World してくれる API を用意してみましょう。Cargo.toml に tide の依存を追加します。その後、下記のように書きます。
#[async_std::main] async fn main() -> Result<(), std::io::Error> { let mut app = tide::new(); app.at("/").get(|_| async move { "Hello, World!" }); app.listen("127.0.0.1:8080").await?; Ok(()) }
ただ…このまま実行すると、次のようなコンパイルエラーに見舞われました。
$ cargo build Compiling tide-example v0.1.0 (/Users/xxx/dev/rust/tide-example) error[E0433]: failed to resolve: use of undeclared type or module `async_std` --> src/main.rs:1:3 | 1 | #[async_std::main] | ^^^^^^^^^ use of undeclared type or module `async_std` error[E0277]: `main` has invalid return type `impl std::future::Future` --> src/main.rs:2:20 | 2 | async fn main() -> Result<(), std::io::Error> { | ^^^^^^^^^^^^^^^^^^^^^^^^^^ `main` can only return types that implement `std::process::Termination` | = help: consider using `()`, or a `Result` error: aborting due to 2 previous errors
どうやら、#[async_std::main]
が存在しないと怒られてしまっています。それに伴って、async fn main()
が返す結果が impl std::future::Future
となってしまっており、エラーがもうひとつ発生しています。しかし、おそらく #[async_std::main]
が存在しないために起きている事象なはずですので、そちらを解決することに専念しましょう。
async-std とは?
#[async_std::main]
ですが、 Rust チームが鋭意開発中の非同期処理基盤 async-std に入っています。
async-std は、Go のランタイムのタスクスケジューリングアルゴリズムやブロッキング戦略を Rust に導入したライブラリです。非同期処理のランタイムには tokio をはじめいくつか種類がありますが、そのうちのひとつが async-std です*2。余談ですが Rust.Tokyo でキーノートをしてくれた Florian が開発に携わっていますね!
この crate に含まれる #[async_std::main]
アトリビュートを追加すると、async fn main() -> Result<...>
と宣言できるようになり、アプリケーションを非同期処理のランタイムに乗せられます。つまり、tide は async-std 上に乗って動いているということでもあります。
Cargo.toml に設定を追加する
なお、この #[async_std::main]
ですが、async-std の attribute feature を有効にしてライブラリとして追加する必要があるようです。したがって、使用したい場合には自身で下記の記述を追加する必要があります。
[dependencies.async-std] version = "1.4.0" features = ["attributes"]
HTTP サーバーが起動する
この状態で走らせると…
$ cargo run Finished dev [unoptimized + debuginfo] target(s) in 0.33s Running `target/debug/tide-example` Server is listening on: http://127.0.0.1:8080
無事にサーバーが起動しました!curl で GET リクエストを送ってみましょう。すると…
$ curl localhost:8080 Hello, World!
Hello, World! と返ってきます。これでようやく動作確認が完了しました。
ルーティングをちょこっと紹介
私が気になった機能をピックアップして試していきます。
/hc に GET を投げると 200 OK を返す
よく実装するヘルスチェック機構を実装しましょう。/hc
に対して GET リクエストを送ると 200 OK
を返させます。これには tide::Response
を利用します。
use tide::Response; #[async_std::main] async fn main() -> Result<(), std::io::Error> { let mut app = tide::new(); app.at("/hc").get(|_| async move { Response::new(200) }); app.listen("127.0.0.1:8080").await?; Ok(()) }
curl を投げて確認してみましょう。
$ curl --dump-header - localhost:8080/hc HTTP/1.1 200 OK transfer-encoding: chunked date: Tue, 24 Dec 2019 08:01:19 GMT
想定通り、200 OK が返ってきていますね。Response
の実装を少し読んでみましたが、Web アプリを作る際に欲しい機能は一通り用意されているようでした。
複数エンドポイントを作ってみる――罠。
後ほど使用するために、/json
というエンドポイントを用意してみましょう。
use tide::Response; #[async_std::main] async fn main() -> Result<(), std::io::Error> { let mut app = tide::new(); app.at("/hc").get(|_| async move { Response::new(200) }); app.at("json").get(|_| async move { "OK" }); app.listen("127.0.0.1:8080").await?; Ok(()) }
これで、curl を叩いてみます。
$ curl localhost:8080/json OK
大丈夫ですね!👏 ただ、ちょっとハマったポイントがありました。普通はやらないのかもしれませんが、app.at(...).get(...).at(...).get(...)
といった形で、実はメソッドチェーンが可能です。
use tide::Response; #[async_std::main] async fn main() -> Result<(), std::io::Error> { let mut app = tide::new(); app.at("/hc") .get(|_| async move { Response::new(200) }) .at("/json") .get(|_| async move { "OK" }); app.listen("127.0.0.1:8080").await?; Ok(()) }
よさそうに見えます。コンパイルも通りました。/hc
というエンドポイントと、/json
というエンドポイントが作られるだろうと期待しています。curl を叩いてみます。
$ curl --dump-header - localhost:8080/json HTTP/1.1 404 Not Found transfer-encoding: chunked date: Tue, 24 Dec 2019 09:04:21 GMT
えっ…消えました…。もしかして、と思って次のような curl を叩いてみました。
$ curl --dump-header - localhost:8080/hc/json HTTP/1.1 200 OK content-type: text/plain; charset=utf-8 transfer-encoding: chunked date: Tue, 24 Dec 2019 09:04:55 GMT OK
なるほど、つまりメソッドチェーンをすると、チェーンした分だけ配下にどんどんパスが切られていってしまうのでしょう。これはちょっと微妙なデザインだと思いました。メソッドチェーンをしたとしても、第一階層にパスが追加され続けるというデザインが直感的なように私には思えました。あるいは、メソッドチェーン自体を禁止される形式がよいのかもしれません。
nest
ちなみに、もしルートをグループ化して使用したい場合には nest
という関数が使えます。Ruby の Sinatra や Go の echo などのご存知の方は、ああいった namespace
や Group
関数のようなものが使えるイメージです。たとえば、/api/v1
という親ルートの中に、/hc
と /endpoint
という子ルートを用意したい場合には、次のように記述できます。
use tide::Response; #[async_std::main] async fn main() -> Result<(), std::io::Error> { let mut app = tide::new(); app.at("/api/v1").nest(|router| { router.at("/hc").get(|_| async move { Response::new(200) }); router .at("/endpoint") .get(|_| async move { "nested endpoint" }); }); app.listen("127.0.0.1:8080").await?; Ok(()) }
curl で確認してみると、しっかり指定したルートで登録されていました!
curl -i localhost:8080/api/v1/hc HTTP/1.1 200 OK transfer-encoding: chunked date: Tue, 24 Dec 2019 09:21:56 GMT
curl -i localhost:8080/api/v1/endpoint HTTP/1.1 200 OK content-type: text/plain; charset=utf-8 transfer-encoding: chunked date: Tue, 24 Dec 2019 09:23:25 GMT nested endpoint
JSON を含むリクエストを投げる
公式ドキュメントに載っているコードを拝借して、JSON をボディに含むリクエストを受け取り、結果を同様に JSON で返す処理を書き足してみます。ここで、Cargo.toml に serde への依存を追加しつつ…
use serde::{Deserialize, Serialize}; use tide::{Request, Response}; #[derive(Debug, Deserialize, Serialize)] struct Counter { count: usize, } #[async_std::main] async fn main() -> Result<(), std::io::Error> { let mut app = tide::new(); app.at("/hc").get(|_| async move { Response::new(200) }); app.at("/json").get(|mut req: Request<()>| { async move { let mut counter: Counter = req.body_json().await.unwrap(); println!("count is {}", counter.count); counter.count += 1; tide::Response::new(200).body_json(&counter).unwrap() } }); app.listen("127.0.0.1:8080").await?; Ok(()) }
curl を投げてみます。カウントが1のリクエストを投げたので、カウントが2になって JSON 形式で返ってきてくれれば成功です。
$ curl -i -H "Accept: application/json" -H "Content-type: application/json" -d '{ "count": 1 }' -X GET localhost:8080/json HTTP/1.1 200 OK content-type: application/json transfer-encoding: chunked date: Tue, 24 Dec 2019 09:11:42 GMT {"count":2}
成功しました。
ちょっと小話
Request-Response
tide のデザインの特徴として、Request-Response 方式を採用している点があげられています。関数のシグネチャは非常にシンプルな構成で、Request を引数に受け取り、Response (を含む Result 型) を返すという構成になっています。
async fn endpoint(req: Request) -> Result<Response>;
これまでは Request と Response のライフサイクルの管理は Context
を用いて行っていました。Context
を受け取り、その中からリクエストの実体を取り出す構成でしたが、バージョン 0.4.0 になって変更が加えられました。
State
State というのはミドルウェアがエンドポイント間で値をシェアするために使用されるものです。actix-web や Rocket でも確かあった機能だったかなと記憶しています。State 付きでサーバーを起動する際には、先ほどのように tide::new()
するのではなく、 tide::with_state()
する必要があります。サンプルコードを載せておきます。
struct State { name: String, } async fn main() -> Result<(), std::io::Error> { let state = State { name: "state_test".to_string(), }; let mut app = tide::with_state(state); app.at("/hc").get(|req: Request<State>| { async move { tide::Response::new(200) } }); }
重要なことは、
- app の型はもともと
Server<()>
だったものから、Server<State>
に変わっている。 - get の部分については、
Request<()>
だったものから、Request<State>
に変わっている。
点です。
Extension Traits
Request や Response といった構造体を型クラスを用いて拡張することもできます。ちょっとした処理を付け足したい際に便利ですね。たとえば、次のようにヘルスチェックすると「OK」と body に入れて返すエンドポイントを、Extension Traits を用いて実装してみます。
use tide::{Request, Response}; trait RequestExt { fn health_check(&self) -> String; } impl<State> RequestExt for Request<State> { fn health_check(&self) -> String { "OK".to_string() } } #[async_std::main] async fn main() -> Result<(), std::io::Error> { let mut app = tide::new(); app.at("/hcext") .get(|req: Request<()>| async move { req.health_check() }); app.listen("127.0.0.1:8080").await?; Ok(()) }
curl を投げると、上手に動作していることがわかります。
$ curl -i localhost:8080/hcext HTTP/1.1 200 OK content-type: text/plain; charset=utf-8 transfer-encoding: chunked date: Tue, 24 Dec 2019 09:32:16 GMT OK
ルーティング応用編
最後に、実アプリケーションであれば必須な機能2つを試してみましょう。パラメータとクエリパラメータです。
パラメータを取得する
たとえばよくやる手として、id = 1 の user というリソースから1つ、user を取得したいというユースケースがあります。これももちろん実装されていました。/users/:id
というエンドポイントを用意したくなったら、次のように実装すれば実現できます。
use tide::{Request, Response}; #[async_std::main] async fn main() -> Result<(), std::io::Error> { let mut app = tide::new(); app.at("/users/:id").get(|req: Request<()>| { async move { // 型注釈は必須の模様。つけないと、推論に失敗する。 let user_id: String = req.param("id").client_err().unwrap(); Response::new(200).body_string(format!("user_id: {}", user_id)) } }); app.listen("127.0.0.1:8080").await?; Ok(()) }
curl を投げてみましょう。
$ curl -i localhost:8080/users/1 HTTP/1.1 200 OK content-type: text/plain; charset=utf-8 transfer-encoding: chunked date: Tue, 24 Dec 2019 13:12:47 GMT user_id: 1
期待したとおり、:id
に含まれていた 1
という値を返してくれています。正常に動作しているとわかりました。
クエリパラメータを扱う
パラメータが使えるのならば、きっとクエリパラメータも使えてほしいはずです。あります。クエリの方は、パース先の構造体を用意しておいて (サンプルコードの QueryObj
)、serde の Deserialize を derive しておくと、あとは勝手に値をパースして構造体に埋めてくれる機能が備わっています。
今回期待する内容は、/users?id=1&name=helloyuki
というリクエストを投げると、id と name を返してくれるエンドポイントができていることです。
use serde::Deserialize; use tide::{Request, Response}; #[derive(Debug, Deserialize)] struct QueryObj { id: String, name: String, } #[async_std::main] async fn main() -> Result<(), std::io::Error> { let mut app = tide::new(); app.at("/users").get(|req: Request<()>| { async move { let user_id = req.query::<QueryObj>().unwrap(); Response::new(200).body_string(format!( "user_id: {}, user_name: {}", user_id.id, user_id.name )) } }); app.listen("127.0.0.1:8080").await?; Ok(()) }
curl を同様に投げてみましょう。
curl -i 'localhost:8080/users?id=1&name=helloyuki' HTTP/1.1 200 OK content-type: text/plain; charset=utf-8 transfer-encoding: chunked date: Tue, 24 Dec 2019 13:54:29 GMT user_id: 1, user_name: helloyuki
よさそうです!一通り、ルーティングに欲しい機能が備わっていましたね。
その他
その他、actix-web や Rocket のように、ミドルウェアを注入する機能も存在しています。今日は入門にしては記事が長くなってしまうのでここにとどめておきますが、詳しいサンプルは下記のリポジトリにあります。
まとめ
- tide という async-std ベースの HTTP サーバーフレームワークがあります。
- 今回はルーティングに限ってご紹介しましたが、ルーティングについては必要最低限の機能が揃っていそうです。
- まだまだ開発途中なので、プロダクションに使うのは難しいかもしれません。
- 今後の開発の進捗に期待!
- また、ビルド用のテンプレートを用意したリポジトリも作っておきましたので、ぜひ上のコードをコピペして遊んでみてください!github.com
- みなさま良いお年を!
*1:ちなみに、この記事は2019年12月25日時点での tide の概況についてのものであり、今後 tide のデザインは大きく変わっている可能性があります。直近でもまずまず大きな変更が加えられているなど、開発は活発であるもののまだまだ安定していない状態といった感じでしょうか。
*2:Rust の非同期処理基盤について詳しく知りたい方は、こちらの記事がおすすめです: https://tech-blog.optim.co.jp/entry/2019/11/08/163000