1症状 ― 別々に見えて、実は同じ根
ライブ配信のコメントを記録・可視化する拡張を作っていて、別々の不具合として何度も潰していた症状がある。
- 開いた瞬間に重い・タブ全体が固まる ― 画面(ポップアップ)を開くと、その画面が数万件のコメントを全部読み込んで集計しようとして詰まる。
- 画面を開かないとデータが出ない ― データの生成が「画面を描いたとき」に紐づいていて、画面を閉じると止まる。
直しても直しても次が出る。モグラ叩きだった。だが一歩引くと、この2つは同じ1つの設計に起因していた。1つの画面(クライアント)に「データを読む・集計する・描く」を全部やらせ、しかも「開いたときだけ」動かしていた。だから「開くと重い」も「開かないと出ない」も、起きるべくして起きていた。
この気づきは、すれちがい通信アプリのソース(ご本人提供)を読んだことがきっかけだった。そのアプリは「データを作る側」と「見せる側」を完全に分けていて、画面を誰も開いていなくてもサーバ側の定期処理がデータを作り続けていた。
2原則 ― 作る人と見せる人を分ける
一言でいうと、「データを作る人と見せる人を分け、重い処理は前段で1回だけやり、画面は軽く受け取って貼るだけにする」。役割を4つに分ける。
| 役割 | 担当 | すること |
|---|---|---|
| 集める | 常に裏で動く側 | データを集める。表示クライアントの有無に依存しない |
| 集計する | 定期ジョブ / 常駐プロセス | 重い集計を1回だけ。誰も画面を開いていなくても進む |
| 置く | 共通の置き場 | 集計済みを保管。これが正本 |
| 見せる | 画面(クライアント) | 置いてあるものを貼るだけ。重い読み込み・計算を持たない |
なぜ強いか。重い処理を前段に逃がせば、画面は軽い受け取りだけになる(=開いた瞬間が軽い)。データ生成を裏側に置けば、画面を開いていなくても溜まり続ける(=開かなくても出る)。2つの悩みが同じ1つの配置で消える。
3図 ― 変更前と変更後
「今の作り(役割が画面に集中)」と「役割を分けた形」を1枚で対比する。
4storage ベース拡張への翻訳(効くもの・効かないもの)
元になったすれちがいアプリは「サーバ+RDB+cron」だが、この拡張は「サーバ DB なし・chrome.storage 中心」。思想はそのまま使えるが、実装テクは翻訳が要る。
この拡張に効くもの
- 常駐タスクがデータを作る ― 「画面を開いたとき」でなく「常に動く側」が生成する。これが「集める=常駐側」の実装形。
- 二段フィルタ ― 重い正確な処理の前に、安い近似で候補を激減させる。
- 受け手単位で集約 ― 警告/通知をイベントごとでなく受信者ごとに1つに束ねる(ノイズを防ぐ)。
- 動的キャップ ― 1回の処理量を流量に反比例させる。混んでいる時ほど軽く。
- 冪等な述語で都度計算 ― 達成・判定などの派生状態は保存せず、元データから読み出し時に導出。「更新忘れの不整合」を構造的に無くす。
効かない/過剰なもの
- DB トランザクション前提のテク(集合 UPDATE・CASE 原子更新) ―
chrome.storage.localは ACID を保証しないので、SQL 前提の冪等化はそのまま持ち込めない。考え方(最新の1枚で持つ・置換で書く)だけ流用する。 - リアルタイム性を全部捨てるバッチ化 ― コメントは即時で見たいので、全面バッチは合わない。「即時性を捨てられる所だけ」間引く。
大事な区別。「集計済みを最新の1枚で置き場に持ち、画面はそれを storage.onChanged で受けて貼るだけ」にするのが、storage ベースでの責務分離の核心。画面はポーリングも全件読み込みもしない。
5実例 ― 「ポップアップを開かなくても出る」
実際にこの拡張で起きていたこと。コメントの「流れ(タイムライン)」を見せる鏡データを、ポップアップが描画したときだけ作っていた。だからポップアップを開いている時しか「コメントが進む」が見えなかった。
直し方は素直だった。記録の心臓部(配信ページで常時動いている側)が、手元にある直近コメントから鏡を作って置くようにした。これで画面を開いていなくても鏡が更新され、別画面(プレビューや公開ページ)はそれを貼るだけで「コメントが進む」ようになる。
// 記録側(常に裏で動く)が、定期保存のついでに「鏡」を作って置くだけ
// ★既に手元にある配列を最新N件に間引くだけ=重い計算ゼロ・記録を妨げない
async function persistPanelSummaryIfDue() {
await storage.set(panelSummaryKey, summary); // 既存の軽い定期保存
void publishCommentMirrorFromRecorder(); // ← 相乗りで鏡も置く
}
async function publishCommentMirrorFromRecorder() {
const ring = recentCommentRing; // 記録済みの直近コメント(手元にある)
if (!ring.length) return;
const snap = buildMirror({ comments: ring, cap: 60 }); // 最新60件にslice
await storage.set(COMMENT_MIRROR_KEY, snap); // best-effort(失敗で記録を止めない)
}
ポイントは 「画面(見せる人)」でなく「記録(作る人)」が鏡を置くこと。これが原則「集める=常に裏で動く側」の、storage ベースでの実装形だ。次の段では、画面側の「開いた瞬間に全件読み込み」も外して、鏡から貼るだけにする(=開いた瞬間も軽くする)。
6落とし穴 ― やってはいけない直し方
この設計に寄せる過程で、実機で却下になった失敗がある。「重さ」を直そうとして、かえって壊した例ばかりだ。
- 画面の描画パス自体に手を入れる ― 「軽くするため」に表示更新の読み取り経路をキャッシュで包んだら、全カードがちらついた。読み取りを増やす/包むのでなく、減らすのが正解だった。
- 画面まるごとを毎回コピーする ― 「そっくり同じにするため」に画面の DOM 全体を毎回別ページへ複製したら、数千件規模で重くて却下。丸ごとでなく、最小の鏡データ(最新N件・必要な項目だけ)を置くのが軽い。
- 「作る人(記録の心臓部)」に集計ロジックを足す ― 記録側を複雑にすると、記録そのものの信頼性が落ちる。作る人は薄く保つ。重い集計が要るなら、記録とは別の常駐処理に分ける。
共通する教訓。「役割分担」は設計の問いであって、コードをそのまま貼る話ではない。各テクは「いつ効くか」を見て、自分の環境に翻訳し、1つずつ検証して入れる。一気に全部入れると、別の所を壊す。
7チェックリスト ― 次に設計するとき
新しいプログラムを設計するとき、この順で問う。
- 役割分担 ― 生成・集計・保管・表示を分けたか。重い処理は前段か。画面は貼るだけか。
- 即時性 ― 本当にリアルタイムが要るか。一部はバッチ/定期で足りないか。
- 引き算 ― 何を作らないか。公開する情報は最小・明示オプトインか。
- 局所化 ― 複雑なルールを1箇所(1つの純関数/層)に閉じ込めたか。
- 冪等性 ― リトライ/多重実行に強いか。最新の1枚で持っているか。
- 現実確認 ― ドキュメントの「実装済み」を鵜呑みにせず、実コードで前提を立てたか。
機能を足すより、役割をどこに置くか。設計は配置で決まる。「重い」「固まる」「開かないと出ない」――個別の不具合に見えるものの多くは、役割の配置ミスが根。配置を直せば、複数の不具合が同時に消える。
8いま作っているところ ― 頂いたソースが、こう活かされています
ここまでの設計思想は、机上の理論ではありません。星野ロミ氏ご本人から頂いたソースコード(すれちがい通信のアプリ)を土台に、いま別プロダクトとして形にしている最中です。
頂いたソースは「捨てて作り直す」のではなく、設計思想を土台に、責務ごとの純粋な部品へ再編成し、さらに発展させています。実際のコードには 移植元: surechigai-nico/server/src/cron/matcher.ts のような出自コメントを残し、リスペクトと履歴を辿れるようにしています。
| 頂いたソースの要素 | いまのプロダクトでの活かし方(+発展) |
|---|---|
| cron マッチング(matcher.ts) | DB・認証・通信に非依存の純粋TSモジュール(core/matching.ts)へ再編成。テスト可能な部品に。 |
| ティア制(500m / 3km / 10km / 50km) | そのまま継承しつつ、H3 地理インデックス(res 8/7/6/5)と k 値を足してより緻密に(core/tiers.ts)。 |
| 分身(おさんぽ)= ティア5 | 「タイムシフト」へ再設計(訪れたエリアの同セルで時間を超えてすれ違う)。 |
| 位置の 500m グリッド丸め(toGrid) | H3 res8 セル+自宅マスク(最頻セルは「ひみつの場所」に置換)へ発展(core/privacy.ts)。 |
| ブロック・通報・公開最小(opt-in) | core/moderation.ts として独立。交流は X に寄せ、アプリ内は一方向の合図に留める方針を継承。 |
| 三層モデル(users / locations / encounters) | feature-sliced(modules/encounter/{api,core,db})に整理。レポートで推奨された「縦スライス再編成」を実践。 |
この記事自体が、その実践の記録です。§1〜§7 の「役割分担」は、頂いたソースから学び、別プロダクト(この拡張・すれ違い通信)の両方でいま現在進行形で形にしている設計です。学びをくれた星野ロミ氏に感謝します。