paild tech blog

法人カードのクラウド型発行・管理サービスpaild

async対応版Rocket、v0.5を試してみる

お手伝いの @helloyuki です。

最近、asyncに完全対応したRocketがついにリリースされ、v0.5から使えるようになったというニュースが舞い込んできました。RocketはRustのエコシステムをかなり前から支えてきたクレートではあったものの、いくつかの事情によりしばらくなかなか技術選定されない状況が続いていました。Rocketがある意味で地団駄を踏んでいる間にasync/awaitは安定化し、actix-webが人気を誇ったのちにaxumの人気がじわじわと上がり始め、近年ではダウンロード数で見ればaxumが一番多いという状況になっているようです。

しかしRocketは使いやすさ、とっつきやすさの観点でaxumに負けず劣らず魅力的なクレートであると筆者は考えています。というわけで今回は、Rocketの歴史的な経緯を踏まえつつv0.5を触って楽しんでみようという記事です。

今回分のリポジトリは下記です。

github.com

また、記事の実装時点でのコミットは下記になっています(mainブランチの方は実装がこの時点より進んでいます)。

github.com

Rocketの特徴

まずRocketの公式サイトは下記にあります。

rocket.rs

サイト上のコードをある程度眺めていただくと感じられるかもしれませんが、HTTPリクエストのハンドリングとそのレスポンスの返却の部分については、axumやactix-webといったクレートとそこまで大きな違いはないかもしれません。Rocketもやはり、マクロを使ってリクエストのハンドリングを実装しますし、リクエスト→レスポンスの流れは関数を使って記述します。

一方で、Rocketが他のクレートと大きく異なる点は、いわゆる「Batteries-Included」なクレートであるという点です。どういうことかというと、たとえばaxumを利用することを考えてみましょう。axumはtowerとtokioとhyperの上で動作する非常に軽量なクレートで、実質的にHTTPリクエスト&レスポンスのハンドラと、所定のパスにリクエストが来たら、特定のハンドラを呼び出す内容を記述するルーターのふたつしか主には機能を持ちません。したがってそれ以外に必要になる機能は一度自分で実装するなどし、たとえばaxumのミドルウェア機能ないしはステート機能を使ってaxumで扱うという方法をとる必要があるわけです。データベースへの接続にsqlxを使用したとすると、接続にまつわる設定やコネクションプールの呼び出しなどは自前で実装し切る必要があります。

RocketはWebアプリケーションを実装するにあたって必要な機能は一通りRocketの管理下に含まれているため、いわゆる「自前実装を挟みつつ頑張る」という労力が不要になります。たとえばデータベースへの接続なら、設定ファイルに接続情報を書いて、マクロを任意の構造体に対して付与するだけです。axumでこれをやろうとすると、まず設定情報の読み込みを自分で実装し、その設定情報を元にコネクションプールを用意する処理を実装するといった流れになります。もちろんいうまでもありませんがこれには一長一短あるとは思いますが、手軽にWebアプリケーションを用意したいとなった場合、Rocketは選択肢としてかなり有力になると言えるのではないでしょうか。

Rocketにはこれまで何が起きていたのか

ここまで整っているクレートにも関わらず、近年Rustを始められた方はRocketはあまり馴染みがないかもしれません。Rocketはこれまでは、残念ながら下記の要因から本番環境での利用を嫌厭されがちであったと考えられますし、現に私も下記が原因で近年は選択肢から除外していました。

  • nightlyビルドを要求していた。
  • async/awaitへの対応が遅れていた。

Rocketは長らくnightlyビルドを要求していました。これは数多くのunstable機能を使って実装されていたためで、2020年にRust 1.45がやってくるまではstableではビルドできませんでした。この記事では詳しく解説しませんが、どのような機能が邪魔(obstacle)になっていたかは下記のIssueに記録されています。

github.com

次に、async/awaitへの対応がしばらく遅れていました*1。Rust 1.39にてasync/awaitの安定化が達成され、多くのHTTPサーバーを構築するためのクレートがこれに追従するか、ないしは追従せずに消えていくかしました。Rocketは対応に時間がかかっていました。Tracking Issueを確認すると、2019年の8月に立てられていて、そこからasync/await対応がスタートしました。

github.com

Rocketはその後2年間の開発期間を経て、v0.5.0-rc1からasync対応版が利用可能になりました。その後、rc2が2022年にリリースされ、その当時は2022年の5月末にはv0.5をリリースできると書かれていました。しかしそこからかなり年月が経ち、メンテナの動向がどうなっているかが不明でした。Redditでチラッとみた限りではメンテナの方のプライベートでの都合から少し伸びることが伺えました。その後1年半を経てようやくv0.5がリリースされ、晴れてasync/await対応版のRocketが利用可能になった、というわけでした。

このことからもわかるように、言語に後からasync/awaitのようなものを組み込むと、まずプログラミング言語それ自体の開発がそれなりに必要になり時間がかかります。加えて、エコシステム側の対応もRocketの事例からわかるように既存のものを修正しながら対応させていくとなると年単位で時間がかかってしまうのだなということもわかります。とくにRocketのように、tokioやhyperといった別のサードパーティライブラリに依存しつつその上にフレームワークを用意しているような場合、サードパーティ側の都合にも相当引っ張られることになります。たとえば必要な機能が提供されるまでサードパーティ側の対応を待つ必要があるなどです。この辺りはRustのエコシステム上で広く使われるクレートを作る際には辛いポイントだなと筆者も思います。*2

v0.5を使う

今回作るアプリケーションのお題

Todoリストを簡単に作ることにします。

使用するクレートの追加

Batteries-Includedとはいったものの、細かい機能のクレートはどうしても必要になります。

# Cargo.toml
[dependencies]
anyhow = "1.0.76"
uuid = { version = "1.6.1", features = ["serde", "v4"] }
chrono = "0.4.31"
rocket = { version = "0.5.0", features = ["json"] }
rocket_db_pools = { version = "0.1.0", features = ["sqlx_postgres"] }
sqlx = { version = "0.7.3", features = [
  "macros",
  "uuid",
], default-features = false }
thiserror = "1.0.51"
validator = { version = "0.16.1", features = ["derive"] }

uuidはUUID生成のために、chronorocket_db_poolssqlxはデータベース接続でどうしても必要になるため使用しています。validatorクレートはバリデーションチェックの例を示すために使用しています。sqlxは本来はrocket_db_poolsクレートのre-export版を使えば不要なのですが、今回マクロ機能を使用したいためあえて依存を追加しています。これについては後ほど解説します。anyhowthiserrorはエラーハンドリング用です。

Routing

他のクレートと同様にRocketにもRoutingという概念があります。これは、任意のパスにやってきたHTTPリクエストに応じて呼び出される関数ひとつひとつのことを指します。

そして、だいたいこの手のサービスではまず簡単にヘルスチェックを実装しておくと便利なので、ヘルスチェック用のエンドポイントを実装してしまいます。

ヘルスチェックのエンドポイントは/hcというパスに対して実装します。次のように実装します。Rocketでは、パス情報はマクロを使って書きます。ただ注意が必要なのは、この後も説明するようにmountという関数を使ってパスのルート(/hc)の情報を記述するため、Routing側のパス情報は/としています。返すのはステータスコード200 OKといったんしておきましょう。

use rocket::{get, http::Status};

#[get("/")]
pub fn health_check() -> Status {
    Status::Ok
}

main関数側で次のように書くとサーバーを起動できます。そして!async fnでmain関数がはじまっていますね!そうです。#[rocket::main]を使ってRocketを起動すると非同期処理版Rocketを立ち上げられます。

ホストとポートのバインド情報が何も記述されていませんが、何も記述せずに実行すると127.0.0.1:8000に対して起動します。Rocketは起動のログがしっかり出るので、どのポートに起動したかは一目瞭然でわかるようになっています。

use rocket::routes;

#[rocket::main]
async fn main() -> Result<(), rocket::Error> {
    let _ = rocket::build()
        .mount("/hc", routes![health_check])
        .launch()
        .await?;
    Ok(())
}

Rocket起動時に出力されるログ

しかし、たとえばRocketのアップデートによる変更で何かあると面倒なので、ちゃんと記述したいという場合には次のようにrocket::customrocket::Configを使って記述すると指定したホストとポートで起動できます。

use rocket::{routes, Config};

#[rocket::main]
async fn main() -> Result<(), rocket::Error> {
    let config = Config {
        address: "[::1]".parse().unwrap(),
        port: 8000,
        ..Default::default()
    };
    let _ = rocket::custom(&config)
        .mount("/hc", routes![health_check])
        .launch()
        .await?;
    Ok(())
}

JSON

次にJSONを扱うエンドポイントを用意します。とりあえずTodoリストをすべて取得できるエンドポイントを/todosに用意します。ただし、全件取得するのではなく最大件数を制限して取得できるクエリlimitと、完了ステータス(done)の状況によってTodoをフィルタリングできるクエリdoneも同時に実装します。両者は指定してもしなくてもよく、両方指定した場合は/todos?limit=50&done=falseのようになります。

#[get("/?<limit>&<done>")]
pub fn todo_list(limit: Option<usize>, done: Option<bool>) -> Json<TodoListResponse> {
    // A dummy data to represent fetching values from database.
    // This will be replaced with database accessing steps in the next part.
    let source = vec![
        TodoResponse {
            id: Uuid::new_v4(),
            title: "First todo".to_string(),
            description: Some("This is the first todo".to_string()),
            done: false,
        },
        TodoResponse {
            id: Uuid::new_v4(),
            title: "Second todo".to_string(),
            description: Some("This is the second todo".to_string()),
            done: false,
        },
        TodoResponse {
            id: Uuid::new_v4(),
            title: "Third todo".to_string(),
            description: Some("This is the third todo".to_string()),
            done: false,
        },
    ];
    // Here should be done within database.
    let filtered = source[..limit.unwrap_or(source.len())]
        .into_iter()
        .cloned()
        .collect::<Vec<_>>();
    let filtered = filtered
        .into_iter()
        .filter(|todo| {
            if let Some(done) = done {
                todo.done == done
            } else {
                true
            }
        })
        .collect::<Vec<_>>();

    Json(TodoListResponse { items: filtered })
}

rocket::serde::json::Jsonという型で返したい型をラップすると、その型をJSON形式で返すことができます。中にはTodoListResponse型を持たせていますがこれは、次のように定義してあります。

use rocket::serde::Serialize;

#[derive(Serialize, Clone)]
#[serde(crate = "rocket::serde")]
pub struct TodoResponse {
    pub id: Uuid,
    pub title: String,
    pub description: Option<String>,
    pub done: bool,
}

#[derive(Serialize)]
#[serde(crate = "rocket::serde")]
pub struct TodoListResponse {
    pub items: Vec<TodoResponse>,
}

普通にserdeを使っていることに変わりはないのですが、Rocket側でre-exportされているものを使用しています。それに伴って、serdeにRocket側のserdeを使用するよう情報を与えています。serdeはもともとシンプルにシリアライズとデシリアライズを実装できるように作られているため、Rocketだからこそという大きな変化はないように思います。

最後に作ったRoutingをサーバーにmountしておきましょう。

#[rocket::main]
async fn main() -> Result<(), rocket::Error> {
    let _ = rocket::build()
        .mount("/hc", routes![health_check])
        .mount("/todos", routes![todo_list])
        .launch()
        .await?;
    Ok(())
}

Fairings: データベース接続

今回はPostgres上にテーブルを用意して、sqlxでアクセスして読み込みと書き込みをできるようにするところまで実装します。私が試した限りでは、これをRocketで行うためには、次のステップで実装を足していくことになりそうです。

  1. コネクションプールを保持するための構造体を用意する。
  2. 設定ファイルに情報を書く。
  3. 1で作った構造体をサーバーにattachし、アプリケーションを起動する。

1. コネクションプールを保持するための構造体を用意する

これだけです。

use rocket_db_pools::{sqlx, Database};

#[derive(Database)]
#[database("app")]
pub struct DB(sqlx::PgPool);

2. 設定ファイルに情報を書く

RocketにはRocket.tomlという設定ファイルがあり、ここにデータベース接続情報を記しておくと裏で勝手に読みに行き、上述した構造体に設定情報を流し込みます。今回は次のように記述しました。

[default.databases.app]
url = "postgres://postgres:postgres@localhost:5432/postgres"

補足ですが、データベースそれ自体はDockerを使ってローカルマシン上に立ち上げることを想定しています。

3. 1で作った構造体をサーバーにattachし、アプリケーションを起動する。

DB::init()を呼び出してattachするだけです。

use async_rocket_example::{
    db::DB,
    routing::{health_check, todo_list},
};
use rocket::routes;
use rocket_db_pools::Database;

#[rocket::main]
async fn main() -> Result<(), rocket::Error> {
    let _ = rocket::build()
        .mount("/hc", routes![health_check])
        .mount("/todos", routes![todo_list])
        .attach(DB::init())
        .launch()
        .await?;
    Ok(())
}

Rocketでは、attach関数を使っていわゆる「ミドルウェア」をサーバーに付与することができます。RocketではミドルウェアをFairingsと呼びます。ロケットのフェアリングと呼ばれる摩擦熱などからロケット本体を守る機構に由来した名前だと思います。

Rocketは設定ファイルへの設定の記述から、実際のデータベースまでの接続をかなり楽に行えるように設計されていることがわかります。他のクレートを使用した場合、設定ファイルの読み込みにどのクレートを使って、データベース接続にはsqlxを使おうと思っているものの、設定ファイルから設定情報を読み出してコネクションプールを生成する関数を作って、というかなり大掛かりなステップが必要になるでしょう。Rocketの場合はものの数分で繋ぐことができてしまいました。一番時間がかかったのは普段やり慣れていない、Dockerを使ったPostgresの起動だったと感じるくらいです。

Todoを作成するRoutingを用意する

⚠️ データベース上にテーブル定義などは存在する想定で記事を進めます。詳しい定義情報はリポジトリを参照してください。

さてデータベースに接続してデータを作成するエンドポイントを用意してみましょう。エンドポイントにはタイトルと詳細文を持ったJSONを送るものとします。リソースを作成できたら、ステータス201 Createdと作成したリソース情報を返すものを実装します。

次のようなRoutingを追加します。

#[post("/", format = "json", data = "<todo>")]
pub async fn create_todo(
    todo: Json<CreateOrModifyTodoRequest>,
    mut db: Connection<DB>,
) -> crate::errors::Result<Created<Json<TodoResponse>>> {
    todo.validate().map_err(Errors::ValidationError)?;
    let res = sqlx::query!(
        r#"
        INSERT INTO todos (title, description, done)
        VALUES ($1, $2, false)
        RETURNING id, title, description, done
        "#,
        todo.title,
        todo.description
    )
    .fetch_one(&mut **db)
    .await
    .map(|r| TodoResponse {
        id: r.id,
        title: r.title,
        description: r.description,
        done: r.done,
    })
    .map_err(Errors::SqlxError)?;
    Ok(Created::new("").body(Json(res)))
}

実装は単純で、エンドポイントが呼び出されるとsqlxを使って内容を書き込みします。CreateOrModifyTodoRequestは次のような定義になっています。

#[derive(Deserialize)]
#[serde(crate = "rocket::serde")]
pub struct CreateOrModifyTodoRequest {
    pub title: String,
    pub description: Option<String>,
}

ここでは元のsqlx::query!マクロを呼び出ししています。sqlx自体は実はrocket_db_pools::sqlxというre-export版が提供されていますが、これはmacrosのfeatureが切られていました。使いたい場合は、改めて依存を追加してこのように元の方を使うようにする必要があるようです。データベース接続自体はConnection<DB>型を経由して行うことができます。

特定のステータスコードと共にJSONをボディにもつレスポンスを返すために、Created::newというものを使用しています。newの中にURLを入れるとLocationヘッダに情報を埋めた状態で返してくれるようです。こうなっている理由ですが、今回はサーバーとしての機能しか使用しないため不要に見えますが、実はRocketはテンプレートエンジンと組み合わせていわゆるビュー側も制御できる機構を持っており、ビュー側を制御する際にこのLocationヘッダが役に立つからだと思います。最終的にこれにより、レスポンスの型はCreated<Json<TodoResponse>>となりました。

サーバーへの追加はカットしますが、ここまでを実装することでようやくJSONリクエストを送るとデータベースに値を登録できるようになりました。

Responder: エラーハンドリング

さて、先ほどとくに何の説明もなしにcrate::errors::Result型などを登場させてしまいました。これについて補足説明しておきます。そしてanyhowやthiserrorと組み合わせる際に少し工夫が必要だったので、これからRocketを使おうと考えている方には役に立つかもしれません。

まず、anyhow::ResultをそのままRocketのRoutingの返り値とするとコンパイルエラーになります。Rocketでは(まだ?)anyhow::Error型に対してはResponderというトレイトが実装されていないためです。anyhowをRocketのRoutingの返り値として利用できるようにするためには、Responderトレイトを実装する必要があります。Responderトレイトというのは、RocketのRoutingでレスポンスとして返すことのできる型に対して実装が必要なトレイトです。

anyhowをRocketで利用できるようにするために、次のように実装を用意してみました。そしてこれが先ほどのRoutingにあったcrate::errors::Result型の正体です。

pub type Result<T = ()> = std::result::Result<T, AnyhowError>;

#[derive(Debug)]
pub struct AnyhowError(pub anyhow::Error);

impl<E> From<E> for AnyhowError
where
    E: Into<anyhow::Error>,
{
    fn from(error: E) -> Self {
        AnyhowError(error.into())
    }
}

impl<'r> Responder<'r, 'static> for AnyhowError {
    fn respond_to(self, request: &Request<'_>) -> response::Result<'static> {
        response::Debug(self.0).respond_to(request)
    }
}

anyhow::ErrorResponder型を実装するためにNew Typeで代わりに型を用意し、その型に対してResponderを実装させました。クレート外の型に対してトレイト実装することはできないというRustの制約からです。これによりanyhow::Resultは利用できませんが代わりに独自に用意したResult型を使用できるようになりました。そもそもanyhow::ResultResult<T, E = anyhow::Error>エイリアスであるため、この実装は実質anyhow::Resultを代替しているといってよいです。

次に、特定のエラー型では特定のステータスコードで返させたいという要件に対応するためにはどうすればよいかについてです。たとえば、バリデーションチェックをvalidatorクレートで行わせた場合、その結果は400 BadRequestで返させたいといったケースへの対応方法です。

これについてはまず、多くの場合thiserrorと組み合わせてエラーを一旦ラップしてしまうことが多いと思います。たとえば次のようにです。

#[derive(Error, Debug)]
pub enum Errors {
    #[error("Database error: {0}")]
    SqlxError(#[from] sqlx::Error),
    #[error("Validation error: {0}")]
    ValidationError(#[from] validator::ValidationErrors),
}

Rocketの標準機能には、実はenumのエラーのステータスコードをハンドリングする機能があります。しかし使ってみた限りではこの機能とthiserrorは非常に相性が悪く、いくつかコンパイルエラーに見舞われることになりました。

そこでResponderの出番というわけです。Errors型に対してResponderを実装し、メソッドの中でエラーごとにどのステータスコードにハンドリングするかを定義してしまえば良いです。

impl<'r> Responder<'r, 'static> for Errors {
    fn respond_to(self, request: &Request<'_>) -> response::Result<'static> {
        match self {
            Errors::SqlxError(_) => Status::InternalServerError.respond_to(request),
            Errors::ValidationError(_) => Status::BadRequest.respond_to(request),
        }
    }
}

注意したいこと

Rocketのエコシステムに関連するクレートは、現在も0.4系かそれ以前のものをターゲットとしているがために、ビルドにnightlyを求めてくることがあります。たとえばrocket_contribという、Rocket用のUuid型などを提供するクレートがそれでした。Uuid型の実装などは中身を見てもらうと比較的単純な実装なので、中身を参考に自前で実装し直すなどすれば回避できるとは思います。一方で、周辺エコシステムが追いついていないという点に注意が必要そうでした。

まとめ

ここまででRocketで開発をはじめるにあたって基本的に必要な情報は網羅できたかなと思います。リポジトリのサンプルコードには、ここで説明しなかった以外のエンドポイントも実装されています。たとえば、データベースから値を読み出すようにしたGET /todosの実装や、todoを完了状態に更新するエンドポイントを実装してあります。

github.com

また、Rocketにはまだまだ機能が入っており、この記事では紹介しきれなかったものも多々あります。軽い気持ちで記事を書き始めたら、思った以上に機能が多く網羅は難しいなと思いました。下記のドキュメントにかなり丁寧にまとまっているので、Rocketで開発する際は下記を見ながら進めていくとよいと思います。ドキュメンテーションの充実はまた、ほかのクレートはRocketを見習って欲しいなと思っているポイントでもあります。

rocket.rs

ところで、今回の実装例はRocketに入門してみたレベルのものでした。私も実際実装を進めていく中で、「これはうまく書けないんだろうか」と思ったものがいくつかあり、原稿を書き終わった後に公式ドキュメントを見直してさらにいくつも発見があったくらいでした。何か詰まった際には一度公式ドキュメントを確認すると、よい解決策が思い浮かぶかもしれません。

参考サイト

*1:正直なところ、別にHTTPサーバーを実装する際に必ずしもasync/awaitが必要になるとも限りません。ただ、使いたい機能を提供するクレートが非同期処理を前提として構築されている場合、HTTPサーバー側が非同期処理ランタイム上で動いていないといろいろと面倒な問題を解決する必要が出てきます。そしてasync/await対応を皮切りに、Rustのエコシステムにもそれへの対応を前提としたクレートが増えてきていました。となると、HTTPサーバーも非同期ランタイムに載ってくれていないと面倒ごとが増えて困るということが起きてくるわけです。

*2:ただし大変だからといってasync/awaitがダメな成果物だったということにはまったくなりません。むしろasync/awaitは成功したと言えます。なぜなら、async/await安定化以降のRustは主にWebバックエンドでの多くの利用者を獲得しましたし、結果的にAWS Lambda上で動いたり、そもそもWebバックエンド開発での選択肢のひとつにRustがのぼるようになってきているためです。ある機能の導入が成功したかどうかはさまざまな観点があり得ますが、苦労の末結果的にその言語の利用事例が増えたのだとすれば、間違いなくその機能の導入は成功したといえるでしょう。