0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Rust製フルスタックフレームワーク Topcoat でリアルタイム対戦ゲームを作ってみた

0
Last updated at Posted at 2026-10-02

Rust の Web フレームワークというと axum や actix-web が思い浮かびますが、今回は Topcoat という比較的新しいフルスタックフレームワークを使って、Wikipedia のリンクを辿るレースゲームを作りました。

サーバーサイドレンダリングもアセット配信も WebSocket も Topcoat 側に任せて、Node のツールチェインを一切入れずに完成させています。その過程で「これは良いな」と思った点と「ここは引っかかった」という点が、それぞれはっきり出てきたので共有します。

作ったもの

同じ Wi-Fi につながった人たちで遊ぶ「Wikipedia リンク辿りレース」です。スタート記事からゴール記事まで、本文中のリンクだけを使って最短時間でたどり着くのを競います。

  • ホストが自分の PC でサーバーを起動し、表示された URL を参加者に伝える
  • 参加者はブラウザで開いて名前を入れるだけ。アプリのインストールも開発環境も不要
  • ホストは専用の管理画面から、コース・制限時間・ページ内検索の可否を決めて進行する
  • 記事はサーバーが Wikipedia から取ってきて、リンクを書き換えてから配信する

規模感はこのくらいです。

行数 ファイル数
Rust 2,893 23
JavaScript 1,391 17
CSS 1,687 19

依存クレートは全部で 237、リリースビルドのバイナリは 11MB でした。

Topcoat とは

tokio-rs/topcoat で開発されているフルスタックフレームワークです。ルーティング、サーバーサイドレンダリング、アセットのバンドル、Cookie、セッション、WebSocket、メール送信あたりまで、feature フラグで切り替えながら一式入っています。

今回使ったのはこれだけです。

[dependencies]
topcoat = { version = "0.8.0", features = ["websocket"] }

デフォルト feature に asset・cookie・router・view・discover などが含まれているので、追加したのは websocket だけで済みました。

実際どう書いていくか

ルーティングは関数に属性を付けるだけ

ルートテーブルを別の場所で管理しません。ハンドラ関数に #[page] か #[route] を付けて、ルーター構築時に .discover() を呼ぶと、属性付きの関数が自動で集まります。

#[page("/play")]
async fn play(cx: &Cx) -> Result<impl View> {
    // 識別子がなければ復帰する先もない。
    if identity::player_id(cx).is_none() {
        Err(redirect("/"))?;
    }
    Ok(view! { /* ... */ })
}

#[route(GET "/api/article")]
async fn article(cx: &Cx) -> Result<Html<String>> {
    // ...
}

main.rs 側はこれだけです。

let router = Router::builder()
    .discover()
    .cookies()
    .assets(AssetBundle::load()?)
    .app_context(config)
    .app_context(hub)
    .build();

let listener = TcpListener::bind(config::bind_address(port)).await?;
topcoat::serve(listener, router).await?;

ページを増やすときに「ルーター定義にも追記する」を忘れて 404 に悩む、という事故が起きません。

ビューは view! マクロ

HTML に似た構文ですが、テキストは必ずクォートで囲むのが特徴です。囲まれていないものは Rust の式やコンポーネント呼び出しとして扱われます。

Ok(view! {
    document(
        title: "参加",
        <main class="entry">
            <h1 class="entry-title">"Wiki Racing"</h1>
            <p class="entry-lead">
                "Wikipedia のリンクだけを辿って、ゴールの記事に先に着いた人が勝ちです。"
            </p>
            <form method="post" action="/join" class="entry-form">
                <input
                    id="name"
                    name="name"
                    value=(existing)
                    maxlength="24"
                    required=""
                    autofocus=""
                >
                <button type="submit" class="btn btn-primary">"参加する"</button>
            </form>
        </main>
    )
})

コンポーネントは #[component] を付けた async 関数で、view! の中から名前付き引数で呼びます。上の document(title: ..., ...) がそれです。定義側はこうなっています。

#[component]
pub async fn document(title: &str, #[default] child: Child<'_>) -> Result<impl View> {
    Ok(view! {
        <!DOCTYPE html>
        <html lang="ja">
            <head>
                <title>(title) " - Wiki Racing"</title>
                <link rel="stylesheet" href=(STYLESHEET)>
            </head>
            <body>(child)</body>
        </html>
    })
}

#[default] child: Child<'_> を書くと、呼び出し側で最後に並べた要素が子要素として入ってきます。

if / for / match / let もマクロ内に書けます。ただ今回のアプリは動的な描画がほぼ全部 WebSocket 経由のクライアント側なので、サーバー側のビューは静的な骨組みが中心でした。そのため制御構文の使い込みはあまりできていません。

boolean 属性が required="" なのは意図的

最初に戸惑ったのがこれです。required や hidden を値なしで書くと通らず、required="" と書く必要があります。はじめは「マクロの都合かな」と思ったのですが、ドキュメントを読むと理由が書いてありました。

リテラルの disabled="" はマクロが事前レンダリング済みの静的な部分に畳み込めるのに対し、disabled=(true) は毎回のレンダリングで評価される Rust 式になる。だから値がその場で確定しているならリテラル形式を使えという話です。

そして式を渡したときの挙動がよくできています。

<button
    aria-current="page"
    disabled=(is_disabled)   // false なら属性ごと消える
    title=(maybe_title)      // None なら属性ごと消える
>

false や None を渡すと属性そのものが出力されません。HTML の boolean 属性は「存在するかどうか」が意味を持つので、disabled="false" という無意味な出力にならないのは正しい設計だと思います。aria-expanded のような列挙型属性は文字列を渡す、という区別もちゃんと説明されていました。

共有状態は app_context

DB プールや HTTP クライアントのような、リクエストより長生きする値は app_context に入れます。Rust の型が鍵になります。

// 登録
Router::builder().app_context(config).app_context(hub).build();

// 取得
let hub: &Arc<Hub> = app_context(cx);

ミドルウェアではなく関数を書く

Topcoat には docs/functions_not_middlewares.md という文書があって、認証やリクエスト由来のデータの扱い方について明確な方針が示されています。要するに「ミドルウェアや extractor ではなく、&Cx を取る小さな関数を書け」ということです。

ミドルウェアは認証処理を、それを必要とするコードから引き離してしまう。extractor はハンドラのシグネチャに要求が現れる点では良いが、下位のコンポーネント全部にユーザーを渡して回ることになる。だから &Cx を取る関数にして、必要な場所から直接呼べ、という理屈です。

この方針に従って書いた認証まわりがこれです。

/// このリクエストの持ち主。識別子は `HttpOnly` クッキーだけから取り、
/// メッセージ本文の自己申告からは決して取らない。
pub fn player_id(cx: &Cx) -> Option<PlayerId> {
    cookies(cx)
        .get(PLAYER_COOKIE)
        .and_then(|cookie| PlayerId::parse(cookie.value()))
}

/// このリクエストがゲームを進行させてよいかどうか。クッキーに入れるのは真偽値
/// ではなくトークンそのもの。`admin=true` を送るだけで通ってしまわないように。
pub fn is_admin(cx: &Cx) -> bool {
    let config: &Config = app_context(cx);
    cookies(cx)
        .get(ADMIN_COOKIE)
        .is_some_and(|cookie| config.admin_token_matches(cookie.value()))
}

呼び出し側では、Result に生える拡張メソッドでそのままエラーレスポンスに落とせます。

#[route(GET "/api/article")]
async fn article(cx: &Cx) -> Result<Html<String>> {
    let player = identity::player_id(cx).ok_or_unauthorized()?;
    let hub: &Arc<Hub> = app_context(cx);

    // 観戦者や、進行中のラウンドの外にいる人には、返すべき記事がない。
    let current = hub.location_of(player).map_err(|_| not_found())?;
    let article = hub.wiki().article(&current).await.map_err(|_| not_found())?;

    Ok(Html(article.html.clone()))
}

ok_or_unauthorized() や not_found() が用意されているので、認可の分岐が 1 行で書けます。ここは書いていて一番気持ちが良かった部分です。

アセットは Rust コードから宣言する

ここが他のフレームワークとだいぶ違います。CSS や JS のパスをRust のコード中で asset! マクロで宣言し、その宣言がコンパイル済みバイナリに埋め込まれます。CLI がバイナリを走査して、宣言されたファイルを集めてバンドルディレクトリを作る、という流れです。

const ENTRY: Asset = asset!("../../assets/js/main.js");

view! の中で Asset を書くと、内容ハッシュ付きの URL としてレンダリングされます。

<script type="module" src="/_topcoat/assets/main-1a2b3c4d5e6f7a8b.js"></script>

ハッシュが付くので長期キャッシュが安全です。手順としてはビルドとバンドルの 2 段構えになります。

cargo build --release
topcoat asset bundle --release
./target/release/wiki-racing

開発中は topcoat dev を使えば、ビルド・バンドル・再起動・ブラウザのリロードまで面倒を見てくれます。

WebSocket は素直

フレームワーク固有の抽象をあまり被せてこないので、futures-util の知識がそのまま使えます。

#[route(GET "/ws")]
async fn socket(cx: &Cx, upgrade: WebSocketUpgrade) -> Result<Response> {
    let player = identity::player_id(cx).ok_or_unauthorized()?;
    let admin = identity::is_admin(cx);
    let hub: Arc<Hub> = Arc::clone(app_context::<Arc<Hub>>(cx));

    upgrade
        .max_message_size(MAX_MESSAGE_BYTES)
        .max_frame_size(MAX_MESSAGE_BYTES)
        .on_upgrade(move |socket| async move {
            crate::net::run(hub, socket, player, admin).await;
        })
}

受け取った WebSocket は split() して、読み取りと書き込みを別タスクに分けられます。ping への応答はフレームワーク側がやってくれました。

よかったところ

1 バイナリ + バンドルで配れる

今回の要件は「ホストのノート PC でサーバーを立てて、同じ Wi-Fi の人がブラウザで入る」でした。参加者側に用意させるものがブラウザだけで済んだのは、フレームワークがサーバーサイドレンダリングとアセット配信を両方抱えているおかげです。

Node のツールチェインが要らないのも大きい。CSS も JS も手書きで、バンドラも PostCSS も入れていません。Rust のツールチェインだけで完結します。

フレームワークがドメイン層に侵入してこない

topcoat を import しているのは 23 ファイル中 6 ファイルだけでした。

ディレクトリ topcoat 依存
src/game/ ゲームのルール 0 / 5
src/wiki/ Wikipedia 連携 0 / 4
src/net/ WebSocket 1 / 5
src/app/ ページと API 5 / 6

ゲームのルールを持つ game/ は、I/O も時刻参照もせず、フレームワークの型も一切出てきません。Web の都合がドメインに染み出さない形を保てました。

&Cx 関数という方針が明文化されている

これが地味に一番ありがたかった点です。「認証はこう書け」という方針がドキュメントで示されていて、しかもその理由(ミドルウェアだと要求が離れる、extractor だと引き回しになる)まで説明されている。設計の判断を都度悩まずに済みました。

#[memoize] を付ければリクエスト内で結果が共有されるので、同じ問いをコンポーネントツリーのどこからでも投げられる、という組み立ても筋が通っています。

必要なところのドキュメントは分厚い

同梱の docs/ が充実しています。router.md が 18KB、cookie.md が 15KB、view! の構文リファレンスが 21KB・615 行。API の一覧ではなく、設計の意図や「やるべきでないこと」まで書いてあるタイプの文書です。

微妙だったところ

ドキュメントの置き場所が分かれている

topcoat::view モジュールのドキュメントは 967 バイトしかなく、各マクロへのリンク一覧だけです。本命の view! 構文リファレンス(615 行)は topcoat-view-macro クレート側に置かれています。

docs.rs 上ではモジュールページとマクロページの関係なので辿れはするのですが、私は最初これに気付かず「構文の説明が無い」と勘違いして、boolean 属性の書き方を試行錯誤で当てていました。先に読んでいれば数分で済んだ話です。

app_context の型ミスは実行時 panic

app_context は TypeId で引くので、登録した型と要求する型が完全に一致していないといけません。Arc<Hub> で登録して &Hub で取ろうとして panic しました。

.app_context(hub)                       // Arc<Hub> として登録される
let hub: &Hub = app_context(cx);         // panic
let hub: &Arc<Hub> = app_context(cx);    // こちらが正解

仕様としてドキュメントに明記されていて、起動時のバグとして扱えという説明もあります。ただ、アプリ全体を通して型の間違いが実行時まで露見しない場所がここだけだったので、Rust を書いている感覚とのギャップは残りました。オプショナルな値には try_app_context があります。

バイナリとアセットバンドルがズレると panic

バンドルはコンパイル済みバイナリを走査して作られるため、バイナリを作り直したらバンドルも作り直す必要があります。ズレた状態でページが Asset をレンダリングすると panic します。

さらに profile ごとに別のバンドルを持つので、--release で動かすなら topcoat asset bundle --release が必要です。OUT_DIR 経由のアセット(私は build.rs で CSS を 1 枚に連結しているのでこれに該当します)は、パスにビルドごとのハッシュが入るため結び付きがもっと固くなります。

topcoat dev を使う分には自動なので問題になりません。ハマるのは今回のようにリリースバイナリを手で作って配るときです。結局、手順を覚えなくて済むようシェルスクリプトに固めました。

cargo build --release
topcoat asset bundle --release
exec ./target/release/wiki-racing

内容ハッシュと ES モジュールの相性が悪い

これが一番手間のかかった点です。クライアント JS が 1,000 行近くなったので ES モジュールに分割したのですが、バンドラが配信名に内容ハッシュを付けるため import './dom.js' が 404 になります。

Topcoat 0.8.0 には import map の生成機構がありません。ドキュメントの type="module" の例も、CDN から単一バンドルを読む形だけです。複数ファイルを相対 import する構成は想定の外にあります。

仕方がないので、ハッシュなしの名前を鍵にした import map を自分で組み立てました。

const MODULES: &[(&str, Asset)] = &[
    ("admin.js", asset!("../../assets/js/admin.js")),
    ("article.js", asset!("../../assets/js/article.js")),
    // ... 全 15 モジュール
];

fn import_map(cx: &Cx) -> String {
    let assets = app_context::<AssetConfig>(cx);
    let entry = assets.resolve(ENTRY);
    let base = entry.rsplit_once('/').map_or("", |(base, _)| base);

    let mut json = String::from(r#"{"imports":{"#);
    for (index, (name, module)) in MODULES.iter().enumerate() {
        if index > 0 {
            json.push(',');
        }
        let _ = write!(json, "\"{base}/{name}\":\"{}\"", assets.resolve(*module));
    }
    json.push_str("}}");
    json
}

これを <script type="importmap"> として出すと、ソースには普通の相対 import を書いたままで動きます。20 行程度で済んだとはいえ、モジュールを追加するたびに MODULES への追記が必要なので、フレームワーク側で面倒を見てほしい部分です。

アセット宣言がデッドコード削除で消える

仕様として面白かった(そして怖かった)のがこれです。バンドラはバイナリを走査するので、Asset ハンドルを使っているコードパスが無いと、宣言ごと最適化で消えてバンドルからも漏れます。

ドキュメントに明記されているので事故ではないのですが、「使っていない定数を消したらアセットが配信されなくなる」という依存関係は直感に反します。

組み込みランタイムはリアルタイム用途には向かない

Topcoat には signals / event handlers / procedures / shards といったクライアントランタイムがあり、サーバー側の Rust から宣言的に書けます。ただこれはリクエスト・レスポンス型(HTTP POST ベース)で、張りっぱなしのソケットではありません。

live! / emit! を使えば、開いたままの HTTP レスポンスにサーバー側から HTML を流し込めます。実際に試して、チャンクが逐次届くことも確認しました。ただし領域まるごとの差し替えで、差分適用や自動再接続はありません。

今回は「全員の進捗をサーバー主導で配る」という要件だったので、WebSocket + 自前のクライアント JS という構成になりました。ランタイム機能で JS を置き換えられるかも検討しましたが、タイマーやダイアログ、記事内リンクのクリック捕捉といったブラウザ API 寄りの処理は結局 JS に残り、しかも残った JS は Rust の文字列リテラルの中に入って型チェックから外れます。移行はやめました。

結果として、クライアント側は素の ES モジュールで書き、型は JSDoc と // @ts-check で付けています。jsconfig.json を置けばエディタが検査してくれるので、ここも node_modules なしで済みました。

まだ 0.8.0

検索して出てくる記事やサンプルはほとんどありません。同梱ドキュメントとクレートのソースを読むのが基本の調べ方になります。ドキュメントの質が高いので詰まりはしませんでしたが、「とりあえず検索して解決」が効かないことは前提にしておく必要があります。

topcoat fmt が target/ 以下のファイルまで整形しようとしてエラーを出す、といった粗さも残っています。

まとめ

同じ用途なら、また Topcoat を選びます。

「サーバーが状態を全部持ち、1 つのバイナリとして配り、参加者はブラウザだけ」という形に対して、フレームワークの設計がきれいに噛み合いました。ルーティングが関数に貼り付くので迷子にならず、&Cx 関数という方針のおかげで認証まわりの設計を悩まずに済み、ドメイン層にフレームワークが染み出しもしませんでした。Node のツールチェインを一切入れずに済んだのも、配布を考えると大きな利点です。

一方で、クライアント側に凝ったことをするなら覚悟が必要です。アセットの内容ハッシュと ES モジュールの組み合わせは自分で橋を架けることになるし、組み込みランタイムは WebSocket 前提のリアルタイム同期には噛み合いません。SPA 的なものを載せるつもりなら、素直に別の構成を検討したほうが早いと思います。

0.8.0 という段階を踏まえても、ドキュメントの厚みと設計方針の明快さには好感が持てました。Rust でサーバーサイドレンダリング中心の Web アプリを書くなら、選択肢として十分面白いと思います。


ソースは chikadahiroki/wiki-racing に置いてあります。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?