rustのwebフレームワークgothamがtokio1.0対応したので使ってみた

せっかく読んでくれる方を落胆させないよう予め期待値調整をしておきたい。

自分自身はrustを書き始めたのはつい一ヶ月前ほどで、正月に自転車本を読んで勉強した程度の知識しかない。

最近うれしいことに、HTTPインタフェースをもったサービスをrustで構築する機会があった。 本記事ではそこで得られた知見(と言うほどのものでは無いかもしれないが)を残しておく。 多プログラミング言語に比べてrustはまだまだ日本語の情報が限られているため誰かの役に立つかもしれない。

ただし以下は本記事のスコープ外としたい。経験年数わずか一ヶ月の人間が語るには説得力に欠けるように思う。

  • gothamにおけるtokio 1.0対応のための変更
  • tokio1.0が関連ライブラリに与える影響

本記事の想定読者は、

  • 最近rustを使い始め」ており、
  • 「rustでwebサーバーを作る」モチベーションがあって、
  • さらに「gotham」の利用を検討している人

あたりになるだろうか。

ちなみにtokio1.0がリリースされたのは昨年12月で、gothamでもつい3週間前ほどにtokio1.0対応を完了している。 PRの内容を見ればすぐに分かるが、gothamではそれほど大きな変更は発生していないように伺える*1

gothamを使う理由

じつのところ当初はgothamの利用は考えておらず、WebサーバーのHyperの上に最低限のスタックを自前実装するつもりでいた。

作成するrustサービスはリソースがごく限られている環境での動作を想定していたため、Ruby on Railsのようなフルスタックなフレームワークは避けたかったからだ。 また記事タイトルにも入れたtokioのrelated-projectsでもHyperが一番上に挙げられていたことも大きい。

Hyperは"Low-level"と自ら謳っているほど最低限の機能しか持たないwebフレームワークWebサーバーだ。 例えばurl(path)とリクエストハンドラを紐付ける機構すら存在せず、必要に応じでルーティング処理を自前実装するか、あるいはルーターライブラリを導入する必要がある。

hyper.rs

しばらくはrustの練習も兼ねて自前実装を重ねていたが、ヤクの毛刈りあるいは車輪の再開発に没頭していることに途中で気がつき、結局はgothamの導入を決心した。

gothamはHyperの上で動くWebフレームワークだ。rustであれば当然期待する静的型付けはもちろん、tokioをベースとしたグリーンスレッドによる非同期処理を前提としている。

特筆すべきは、安定性をfeatureとして挙げていることかもしれない。 gothamはrustのbetaやnightlyバージョンでのテスト/ビルドもCIに取り入れており、日々活発な開発が進むrustに対して安定的に追従してくれるのはとてもありがたい。

Quick Tour

gothamのオフィシャルサイトにあるQuick Tourが良くできている。Hello WorldからSharing Stateまでタブを左から順にこなせば*2gothamがどのようなフレームワークなのか理解できる。

gotham.rs

各セクションの概要に触れておくと、

  • Hello world
    • リクエストハンドラを1つの関数として実装する最もシンプルな内容
  • Routing
  • Extractors
    • リクエストパラメータを使う方法の説明
    • 各リクエストハンドラはstate呼ばれるオブジェクトしか受け取れない点がポイント
  • Middleware
    • リクエストハンドラの前後に処理を挟む方法を学ぶ
    • 後続のSharingStateでもmiddlewareを用いており、じつはこの節がとても重要
  • SharingState
    • 共有メモリを使ってステートフルにリクエストを扱う方法を学ぶ

ご覧の通りQuick Tourではgothamの機能にざっくりと触れる程度の内容にとどまっている。

例えばQuick Tourでは共有メモリを用いたステートフル処理の実装に触れているが、実際のユースケースではむしろ外部のDBMSやKey-Valueデータストア等と連携させる場合が多い。

そこで以下のセクションでは、Quick Tourではカバーされていないが、しかしWebサービスとしては最低限必要となるであろう機能をどのように実現するかを説明する。

外部データの参照・変更

じつはgothamのexamplesディレクトリにはORMライブラリdieselの利用例が存在している。データベースと接続して何らかの処理を行いたい場合はこちらの例を参照すると便利だ。

github.com

dieselの例や他のexampleを見てみると分かるが、gothamではmiddlewareを通じて各リクエストハンドラに対して外部データへの参照を渡すのが一般的なようだ。例えばKey-Valuストアとの接続を渡す場合にもmiddlewareを使うことになるだろう。

以下ではdieselの例を使い、外部データ(以下の例でのRepo構造体)がリクエストハンドラまでどのようにして渡されるのかを確認していく。

まずはエントリポイントであるmain関数から見ていく。 gotham::start(addr, router(Repo::new(DATABASE_URL)));の行でrouter関数に対してRepo構造体を渡している。

/// Start a server and use a `Router` to dispatch requests
fn main() {
    let addr = "127.0.0.1:7878";

    println!("Listening for requests at http://{}", addr);
    gotham::start(addr, router(Repo::new(DATABASE_URL)));
}

Repo構造体はgotham_middleware_diesel::Repo<SqliteConnection> のエイリアス(別名)になっていて、これ通してrepo.run(|conn| { do_something(); })のようにORMの機能を利用できる。

pub type Repo = gotham_middleware_diesel::Repo<SqliteConnection>;

router関数ではmiddlewareを挟み込んだり、それぞれのパスに対応するリクエストハンドラを紐付けている。 引数として受け取ったrepoが、各リクエストハンドラ get_products_handler create_product_handler ではなく、DieselMiddlewareに渡されている点に注意されたい。

fn router(repo: Repo) -> Router {
    // Add the diesel middleware to a new pipeline
    let (chain, pipeline) =
        single_pipeline(new_pipeline().add(DieselMiddleware::new(repo)).build());

    // Build the router
    build_router(chain, pipeline, |route| {
        route.get("/").to(get_products_handler);
        route.post("/").to(create_product_handler);
    })
}

DieselMiddlewareはリクエストハンドラが引数として受け取るstateに対してrepoを埋め込むmiddlewareだ。 middlewareの仕組みを用いることで、リクエストハンドラは高階関数などのテクニックに頼らずともstate経由で簡単に外部データを参照・変更できる。

自分は長らくTypeScript, JavaScriptを書いていたこともあり、rustにおいても高階関数やクロージャに頼って書こうとする傾向があった。rustは厳密な型検証を行うためTS, JSと比べて関数渡しが難しいのだが、gothamを使い始めた当初は頭の切り替えができずに苦労していた。

さて、DieselMiddlewareの具体的な処理はMIddlewareトレイトで要求されるcallメソッドに書いてある。 とくに難しいことはしておらず、素直にrepoをstateに埋め込んでからリクエストハンドラへと渡している。

impl<T> Middleware for DieselMiddleware<T>
where
    T: Connection + 'static,
{
    fn call<Chain>(self, mut state: State, chain: Chain) -> Pin<Box<HandlerFuture>>
    where
        Chain: FnOnce(State) -> Pin<Box<HandlerFuture>> + 'static,
        Self: Sized,
    {
        trace!("[{}] pre chain", request_id(&state));
        // repoをstateに埋め込む
        state.put(self.repo.clone());

        // リクエストハンドラへと渡す
        let f = chain(state).and_then(move |(state, response)| {
            {
                trace!("[{}] post chain", request_id(&state));
            }
            future::ok((state, response))
        });
        f.boxed()
    }
}

最後にリクエストハンドラがstateからrepoを取り出している部分を見ておく。

fn get_products_handler(state: State) -> Pin<Box<HandlerFuture>> {
    use crate::schema::products::dsl::*;

    let repo = Repo::borrow_from(&state).clone();  // ここでrepoを取り出す
    async move {
        let result = repo.run(move |conn| products.load::<Product>(&conn)).await;
        match result {
            Ok(users) => {
                let body = serde_json::to_string(&users).expect("Failed to serialize users.");
                let res = create_response(&state, StatusCode::OK, mime::APPLICATION_JSON, body);
                Ok((state, res))
            }
            Err(e) => Err((state, e.into())),
        }
    }
    .boxed()
}

ここまでgothamにおいてリクエストハンドラに外部データを渡す方法をみてきた。

middlewareはリクエストハンドラの前後に処理を挟める汎用性の高い枠組みだ。 middlewareを活用すれば、外部データの注入以外にも、ビジネスロジックの外側にある関心事に対してうまく対処できる。

CORS

CORS(Cross-Origin Resource Sharing)はその名の通り、origin(取得するデータの配信元)をまたいだ処理を実現するための仕組みだ。 身近な例で言えば、SPAを開発する際にAPIサーバー側でCORSを有効化する必要があったりする。

CORSを有効化するためにはpreflightやリクエストヘッダ、レスポンスヘッダへの適切なフィールド設定が必要になる。 CORSそのものの詳細な説明は参照記事に委ねたい。

developer.mozilla.org

さて、RubyならRails、NodeJSであればExpressといったようなメジャーなWebフレームワークではCORSが簡単に実現できる。 多くの場合はミドルウェアとしてCORS有効化の機構が提供されている。

当然、rust/gothamでも同様のミドルウェアの存在を期待するのだが意外に適当なものが見つからなかった。 正確に言えばひとつだけgotham-cors-middlewareなる存在を確認できたのだが、残念ながらgotham 0.2で開発が止まっていた。

github.com

前述の通りSPAの開発時などはとくにCORSが有効になっていないと不便に感じる。 例に漏れず自分の場合もCORSが必要になったため、gotham-cors-middlewareをforkしてgotham0.5に対応させた*3。gothamでサクッとCORS対応させたい方は是非使っていただきたい。

じつは本プラグインもmiddlewareとして実装されている。受信したリクエストのヘッダから特定の情報を抜き出し、適当に整形したあとでレスポンスのヘッダに埋め込んでいる。

おわり

rustを使い始めてまだわずかしか経っていないが、早くもrustの可能性に驚いている。

c/c++で行っていたようなメモリ管理等の低次のレイヤから、rubyやjsのような高度に抽象化されたレイヤにおける処理まで、一気通貫で書き記せるためとても気持ちがいい。 「それ、goでもできるよ」と一蹴されてしまいそうだが、ガベージコレクションやゼロコスト抽象の有無によってrustではgo以上の高速化が望める点も大きな強みに思う。 関数型のパラダイムを積極的に取り入れているのもgoとの差異だろう。

逆にrustを書いていて不満に感じるのはなんと言ってもコンパイルの遅さだ*4。 rustでは書く→試すのイテレーションを(時間的にも・心理的にも)細かく回すことができないため、軽量プログラミング言語に慣れている人間には相当なストレスになりそうだ。

*1:変更が”ない”ことを確証をもって示すのが難しいためスコープ外とした

*2:タブUIなので一見して順序関係があると認識できず迷った

*3:本家のopenなPRを確認してみるとgotham0.4対応のPRが2020年夏から放置されていることが判明した

*4:むしろgoのコンパイルが早すぎるのかもしれない