Don't Repeat Yourself

Don't Repeat Yourself (DRY) is a principle of software development aimed at reducing repetition of all kinds. -- wikipedia

async/await 時代の新しい HTTP サーバーフレームワーク tide を試す

Rust Advent Calendar 2019 25日目の記事です。

tide は現在開発途中の、 Rust の async/await に対応した HTTP サーバーを構築するフレームワークです。not ready for production yet なので本番にこれを使用するのは難しいかもしれませんが、いろいろな例を見てみた感じとても使いやすそうで、注目に値するフレームワークの一つです。

記事を少し読んでみたのですが、どうやら 2018 年に Rust の Network Service Working Group が開発に着手したフレームワークのようですね。現在のステータスを追いかけていないので詳しくはわかりませんが、Rust チームの方々が何かしら関わっているフレームワークということで、少し安心感がもてるかなと私は思っています。async/await が今年無事安定化されたので、一層開発が進んでくれると嬉しい…そんなフレームワークです。

GitHubリポジトリはこちら。

github.com

また、開発者の方の Twitter はこちら。時々 tide に関する最新情報が流れてくるので、tide がどういう状況かを逐次キャッチアップしたい方はフォローしておくとよいと思います🙂

twitter.com

今回はそんな 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 上に乗って動いているということでもあります。

github.com

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 という関数が使えます。RubySinatra や Go の echo などのご存知の方は、ああいった namespaceGroup 関数のようなものが使えるイメージです。たとえば、/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 のように、ミドルウェアを注入する機能も存在しています。今日は入門にしては記事が長くなってしまうのでここにとどめておきますが、詳しいサンプルは下記のリポジトリにあります。

github.com

まとめ

  • 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