9月24日、Carl Lerche(Tokio公式ブログ)が「Topcoat is pushing the boundary of server applications with Rust」と題した記事を公開した。RustのフルスタックWebフレームワーク「Topcoat」の最新バージョン0.9と、同梱ORM「Toasty」の新機能について詳しく紹介されている。
Rustはシステムプログラミングやインフラ層での採用が急拡大している一方、Webアプリケーション開発における生産性はRuby on RailsやLaravelといった既存フレームワークに及ばないと長らく言われてきた。この課題に正面から向き合うのが、TokioコアメンバーのCarl Lerche氏とJulien氏が開発する「Topcoat」だ。AI処理やリアルタイム通信の需要が高まる中、「パフォーマンスと開発生産性を両立したい」という要求はより切実になっており、Topcoatはその答えの一つを示そうとしている。
2ヶ月前にアナウンスされたTopcoatは、ビュー・コンポーネント・メーラー・ORM(Toasty)などを標準搭載する「フルスタック」な構成を目指している。最大の特徴は、JavaScriptを一切書かずに、サーバーサイドのRustコードだけでリアルタイムなUIを実装できる点だ。SSR(サーバーサイドレンダリング)ベースでありながら、クライアントの操作に即座に反応するインタラクティブなUIを実現する設計は、Ruby on RailsのHotwireやElixirのLiveViewに触発されたアプローチである。
クライアント側のリアクティビティをRustで書く
TopcoatのリアクティブUIの核となるのが「シグナル」だ。signalはサーバー上で定義された状態変数で、クライアント側のイベントに反応して値が変わる。view!マクロ内の$(...)構文は、型チェック済みのRustコードをJavaScriptにトランスパイルするための特殊な構文で、ボタンのクリックといったサーバーへのラウンドトリップを必要としない操作はブラウザ上で完結する。
pub async fn page(cx: &Cx) -> Result<impl View> {
let count = signal(cx, || 0i32);
Ok(view! {
<button @click=$(|_e| count.increment())>"increment"</button>
<button @click=$(|_e| count.decrement())>"decrement"</button>
<div>$(count.get())</div>
})
}
シグナルだけでは対応できない「マークアップ構造そのものを変える」ケース——検索入力に応じてリスト項目を入れ替えるなど——に対応するのが、0.8で導入された「シャード(shard)」だ。シグナルの値が変化したタイミングでサーバーがそのコンポーネントだけを再レンダリングし、差分HTMLをブラウザに返す。フォーカスや入力状態の維持は「morphing(モーフィング)」と呼ばれる差分適用によって対処されており、DOM全体の置換は行われない。
async fn search(cx: &Cx) -> Result<impl View> {
let query = signal(cx, String::new);
Ok(view! {
<input @input=$(|e: Event| query.set(e.target.value))>
search_results(query: $(query.get()))
})
}
0.9の目玉:サーバーからUIをプッシュする live!
Topcoat 0.9の最大の新機能が「server-push」対応だ。live!マクロとemit!マクロを組み合わせると、サーバー側の状態変化をトリガーにUIを更新できる。
シンプルな例として、ローディング状態の逐次表示(ストリーミングSSR相当)がある:
pub async fn page() -> Result<impl View> {
Ok(live! {
emit! { <p>"Loading..."</p> }?;
let content = load_content().await;
emit! { <p>(content)</p> }
})
}
さらに0.9からはWebSocketによるサーバープッシュが可能になり、チャットUIのように任意のタイミングで更新を送り続けるユースケースにも対応する:
pub async fn chat_messages(cx: &Cx) -> Result<impl View> {
Ok(live! {
let chat = app_context::<Chat>(cx);
let mut changed = chat.subscribe();
loop {
let token = emit! {
<ul>
for message in chat.messages() {
<li>(message)</li>
}
</ul>
}?;
if !connected(cx) { break Ok(token); }
changed.recv().await.ok();
}
})
}
Reactでは<Suspense>コンポーネントやSocket.IOといった複数の仕組みを組み合わせる必要があるところを、Rustのコードとマクロで一貫して記述できる点がこの設計の肝だ。
Toasty:RustらしいORMの進化
Topcoatに同梱されるORM「Toasty」にも複数の更新が加わった。
update!マクロは、フィールドのセットとインクリメントを1回のDBクエリで実行できる:
toasty::update!(user {
name: "Alicia",
login_count.increment(),
})
.exec(&mut db)
.await?;
これはSET login_count = login_count + 1相当のSQLを生成する。レコードをロードしてRust側でインクリメントして保存するという手順が不要になり、競合状態(race condition)も防げる。
ドキュメントフィールドのサポートも追加された。PostgreSQLのJSONB型をRustの構造体にマッピングし、ネストしたフィールドでのフィルタリングも型安全に記述できる:
let users = User::filter(
User::fields().settings().theme().eq("dark"),
)
.exec(&mut db)
.await?;
生成されるSQLはWHERE users.settings->>'theme' = $1となる。
ポリモーフィックなリレーションでは、RustのenumとアノテーションだけでOwnerがUserかTeamかによって参照先テーブルが変わる関係を表現できる。Rustの型システムを活かした設計で、従来のORM的な実装と比べてコードが簡潔にまとまる。
現状と展望
Carl氏は記事の末尾で「ソフトウェアエンジニアリングの世界は3ヶ月ごとに変わる。ただ、RustはAI時代においても最良の汎用言語であり、生産性と品質・パフォーマンスは両立できると考えている」と述べている。現時点でTopcoatはバージョン0.9であり、プロダクション利用よりも早期採用者によるフィードバックを集める段階にある。
試すにはgetting started guideを参照のこと。議論はTokioのDiscordの#topcoatチャンネルで行われている。
詳細はTopcoat is pushing the boundary of server applications with Rustを参照していただきたい。