ブラウザ版のサーバは、遊んでいる人のことを何も覚えていない
The browser version's server remembers nothing about the player
D15-4-T30-stateless-game-server・技術・日本語入力でプレイする仕組み
ブラウザ版の『霧走町 流氷に眠る』では、サーバはプレイヤーの進み具合を一つも持っていません。
セーブはブラウザが持ち、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
港で佐伯に、同じように入力して確かめられます。第一章はブラウザで無料で遊べます。プロフィールのリンクから。