kaedeasset記事 · Notes霧走町 流氷に眠る

ブラウザ版のサーバは、遊んでいる人のことを何も覚えていない

The browser version's server remembers nothing about the player

 D15-4-T30-stateless-game-server・技術・日本語入力でプレイする仕組み

配る仕組みstateless (REST の stateless 制約)client-side session stateclaude自然言語処理

ブラウザ版の『霧走町 流氷に眠る』では、サーバはプレイヤーの進み具合を一つも持っていません。
セーブはブラウザが持ち、1 手ごとにそれを丸ごと送って、新しいセーブを受け取ります。サーバを止めて立て直しても、続きはそのまま遊べます。

今日から 4 回、ブラウザ版を配る仕組みを書きます。
T30 状態を持たないサーバ、T31 1 秒 2 手のレート制限、T32 静的ファイルの出し方、T33 更新は 1 コマンド。
判定をする C++ の核は UE のゲームと同じもので、サーバはそれを包んでいるだけです(T01・T27)。遊んでいる間に文章を作る所はありません。

この回の位置と、前後の回#

区切り: T30〜T33 配る仕組み(4 回のうち 1 回目)
先に読むとよい回: T27(C++ の核を三つの外側で共有する)・T23(同じ入力に同じ出力)・T24(セーブとデータの指紋)
次に読む回: T31(1 秒 2 手のレート制限)

名前 — stateless server#

正式名
stateless(REST の stateless 制約)
関係する呼び名
client-side session state(セッションの状態をクライアントに置く)
初出
Roy T. Fielding の博士論文 "Architectural Styles and the Design of Network-based Software Architectures"(University of California, Irvine, 2000)の第 5 章 REST、§5.1.3 Stateless。

一般の解説 — stateless server#

REST は、Web のような分散ハイパーメディアのための建築様式で、いくつかの制約を重ねて定義されています(client-server・stateless・cache・uniform interface・layered system・code on demand)。
その一つが stateless です。§5.1.3 の要点はこうです。

  • クライアントからサーバへの要求は、その要求を理解するのに要る情報を全部持つ
  • サーバに保存された文脈を使ってはいけない
  • セッションの状態は、全部クライアントが持つ

得られるものとして、Fielding は三つを挙げています。
visibility(見張る側が要求 1 つだけを見れば意味が分かる)、reliability(部分的な故障から戻しやすい)、scalability(要求の間に資源を持たなくてよいので、すぐ解放でき、実装も簡単になる)。

代償も書いてあります。
一連の要求で同じデータを繰り返し送るので、ネットワークの性能が落ちうる。
状態をクライアントに置くので、アプリの振る舞いの一貫性をサーバが制御しにくくなる(クライアントの作り方に依る)。

実務では、ログインのセッションをサーバのメモリや Redis に置く作りもよく使われます。状態をクライアントに持たせるときは、改ざんを防ぐために署名(署名つきの cookie や JWT)を付けるのが定番です。

うちの経路#

ブラウザ → HTTPS → Caddy → Node.js の server.mjs → adv-sim serve、の 4 段です。
server.mjs は依存パッケージを使わず、Node 18 以上で動きます。adv-sim serve を子プロセスとして 1 本だけ持ち、作品データとモデルの読み込みは起動時の 1 回で済ませます。

口は二つです。

POST /api/new                                   → {ok, turn, state, save, eval, view}
POST /api/turn {save, eval, kind, arg, sessionId?, browserId?} → 同上(kind は say / move / talk / look)

save はセーブ、eval は検査用の観測の状態です。どちらもサーバから見ると中身の分からない 1 本の文字列で、ブラウザが持ち回ります。
sessionId と browserId はブラウザが作る乱数で、ログを並べて束ねる以外に使いません。

サーバの中で、1 手はこう流れます(server.mjs の処理を、順番を変えずに縮めて書いたもの。行そのものの写しではありません)。

raw = await readBody(req, LIMITS.bodyBytes);            // 64 KB を超えたら 413
b = JSON.parse(raw); why = validateTurn(b);              // kind・arg 200 字・save 必須など。だめなら 400
ask = { kind: b.kind, arg: b.arg ?? '', save: b.save, eval: b.eval ?? '' };
if (!rate.take(clientIp(req))) return send(res, 429, …); // T31
if (sim.queue >= LIMITS.queue) return send(res, 429, …);
const r = await sim.ask(ask);                            // 1 行書いて 1 行待つ
if (r === null) return send(res, 503, …);                // adv-sim が死んだ
if (!r.ok) return send(res, 200, { ok: false, … });      // ゲームの中の失敗は 200 のまま
log.append({ sessionId, browserId, ...r.log });          // ログはサーバの中にだけ
return send(res, 200, publicReply(r), cors);

子の adv-sim serve の側は、C++ で 1 つのセッションを使い回しますが、毎手セーブから作り直します(Tools/adv-sim/main.cpp の CmdServe)。

const std::string Save = Req.value("save", "");
if (Save.empty()) { Refuse("save is required(最初からは kind=new)"); continue; }
// 読めないセーブは現セッションを変えない(N7)。次の手は必ず loadState を通るので残りは漏れない。
if (!Session.loadState(Save, Err)) { … continue; }
Session.loadEvalState(Req.value("eval", ""));
…
const kaede::adv::AdvTurn Turn = kaede::adv::RunAgentInput(Session, In);
O["save"] = Session.saveState();
O["eval"] = Session.saveEvalState();

loadState は中で reset を通るので、前の人の手は次の人の手に漏れません。使い回しと、1 手ごとにプロセスを立てる作り(AI のプレイヤー役の口 agent)が毎手同じになることは、手帳の検査 N8 で確かめています(T23)。
adv-sim は要求を書いた順に 1 つずつ返します。並列にはしません。サーバは id で返事を待ち合わせ、30 秒返事が無ければ子を殺します。子が死ねば、待っている要求に 503 を返して自分も終了し、systemd が両方を立て直します。

ブラウザの側(site/app.js)は、返ってきた save と eval を次の手にそのまま載せるだけです。

res = await api('../api/turn', { sessionId: G.sessionId, browserId: G.browserId, kind, arg: arg ?? '', save: G.save, eval: G.eval });
…
G.save = j.save; G.eval = j.eval; G.st = j.state; G.view = j.view || null;

進行の判断はブラウザにありません。返ってきた台詞と画面の材料を描き、セーブを持ち回るだけです。セーブはブラウザの localStorage に毎手書き、手動のセーブとロードもそこを使います。
UE のゲームは同じ核をプロセスの中で直接呼ぶので、この往復はありません。ブラウザ版だけが、状態を文字列にして外に出しています。

画面が描かない欄は返さない#

serve の 1 行には、ブラウザの画面が描かない欄も入っています。AI のプレイヤー役と検査の道具が同じ欄を読むためで、核の側は変えていません。
10 月 29 日に、ブラウザへ返す前にそれを落とすようにしました(public-reply.mjs)。返すと、開発者ツールを開けば画面に出ていない情報が読めてしまうからです。

形は二段です。上の段は返す欄を名指しで並べる許可リスト、その中の段は落とす欄の表です。

export function publicReply(r) {
	return {
		ok: true,
		turn: without(r.turn, DROP.turn),
		state: without(r.state, DROP.state),
		save: r.save,
		eval: r.eval,
		view: without(r.view, DROP.view),
	};
}
// DROP は <画面が描かない欄の表>(ここでは中身を伏せる)

煙試験(smoke.mjs)は、HTTP の返事と、serve に直接送った返事に同じ外し方を当てたものとを毎手比べ、落とすはずの欄が 1 つでも残っていれば不一致にします。

今日の手元で数えると、serve の 1 行の欄は 10 個、ブラウザに届くのは 6 個でした。大きさは、佐伯に「ゆうべ、何時までどこにいたんですか」と入力した手で 17,550 バイトが 8,791 バイトになりました。

実測(今日・Windows の手元)#

server.mjs を手元で立てて、第一章の最初の 3 手を流しました(画面とログは作業用の場所。作品データは読むだけ)。

手        要求     返事     往復     セーブの長さ
new       45 B    7,308 B  16.1 ms  1,580 字
移動 港   3,272 B  3,581 B   4.1 ms  1,027 字
佐伯へ    1,638 B  3,206 B   6.1 ms  1,027 字
発話      1,683 B  8,791 B  36.1 ms  1,982 字

セーブの文字数より要求のバイト数が大きいのは、セーブ(JSON の文字列)を JSON の値として送るので、引用符などがエスケープされるからです。
移動と話しかけは数 ms で、発話の 36 ms の多くは埋め込みの推論だと見ています(内訳は測っていません。T01 では serve に直接送って中央値 21.9 ms でした)。

次に、状態を持たないことを確かめました。
セーブを手元に持ったまま、server.mjs と子の adv-sim を止めて立て直し(1.1 秒)、同じセーブで「番屋には誰がいましたか」と送る。これをもう 1 回繰り返し、2 回の返事を比べました。
2 回とも 200 で、返事は文字列として 1 バイトも違いませんでした。
サーバのプロセスが変わっても、続きは同じように進む、ということです。

名前どおりでない所 — stateless server#

採ったのは stateless の制約だけで、REST ではありません。

  • 資源(resource)を URL で名指しする設計ではなく、「1 手を送る」口が 1 つあるだけです。形としては RPC に近い
  • 返事は no-store で、キャッシュの制約は使っていません。同じ要求でも、毎回 adv-sim に聞きます
  • ハイパーメディア(返事の中のリンクで次の操作を辿る)もありません

サーバに何も無いわけでもありません。IP ごとのレート制限の桶(T31)と、プレイの記録の追記はサーバが持ちます。ただ、どちらも次の手の返事を変えません。消えても、ゲームの続きには何の影響も無い、という線で分けています。

弱点と、採らなかった案 — stateless server#

Fielding が書いた二つの代償が、そのまま出ています。

一つ目、毎手セーブを丸ごと送ります。今日の第一章の頭で 1〜3 KB で、手帳が増えるほど大きくなります。そのため本文の上限を 64 KB にしています。
二つ目、状態をブラウザが持つので、セーブを書き換えられても、サーバは気づきません。セーブには作品の内容の指紋と入力処理の版が入っていて、版の違うものは拒みます(T24)が、改ざんの検知はしていません。

二つ目は、受け入れています。一人で遊ぶ推理ゲームで、書き換えて得をする相手がいないからです。
先の章を読まれないための守りは、セーブではなくデータの側で取っています。ブラウザ版のサーバに置く作品データは、配る前に第一章までに切り落としてあります(T33)。セーブをどう書き換えても、サーバに無い章の台本は返せません。

ほかにも弱点があります。

  • ブラウザのデータを消すと、セーブも消えます。ログインもアカウントも無いので、戻す手はありません
  • サーバの記録は、ブラウザが送ってきた手をそのまま書きます。書き換えたセーブで遊んだ記録も、ふつうの記録として残ります

採らなかった案:

  • セーブをサーバに置く(セッション)。VPS はメモリ 1 GB で、置き場と消す規則と、再起動で消えないための保存が要る。いまは何も置かないので、何も消えない
  • セーブに署名を付ける(HMAC など)。鍵を VPS に置いて回す運用が増えるのに、守る物が無い
  • 1 人に 1 プロセスを割り当てる。起動に 0.5〜1.5 秒かかり、1 GB に何人も載らない。状態を持たない 1 本を使い回す方が軽い

この作りのおかげで、更新の配置はサーバを止めて置き換えるだけになりました(T33)。遊んでいる人は、次の手を送り直すだけで続けられます。

検索語#

REST stateless constraint Fielding dissertation 5.1.3
client-side session state
stateless server horizontal scaling

港で佐伯に、同じように入力して確かめられます。第一章はブラウザで無料で遊べます。プロフィールのリンクから。

#claude #自然言語処理