こんにちは!2ちゃんねる互換掲示板システム「 つゔぁいちゃんねる(2ch.biz) 」の開発録、第31回です。
前回(第30回)では、お問い合わせフォームやP2PシグナリングAPIを狙ったボット攻撃をハニーポットとIP規制で見事に撃退しました。
しかし、P2P暗号化チャットが実際に多くのユーザーに使われ始めると、中央サーバーを持たないアーキテクチャ特有の 「決定的な弱点」 が浮き彫りになりました。
それは、 「途中から入室したユーザーや、電波が悪くて一時切断されたユーザーが、過去の会話ログを一切見られない」 という問題です。
1. サーバーレスP2Pチャット最大の弱点:途中参加者の孤立
DiscordやLINEなどの一般的なクライアント-サーバー型チャットでは、サーバーのDBに全履歴が保存されているため、途中参加したユーザーも過去ログをスクロールして遡ることができます。
しかし、私たちのP2Pチャットは 「サーバーにログを1文字も残さない」 ゼロストレージ設計です。
そのため、以下のような悲劇が起きていました。
【これまでの途中参加者の体験】
1. ルーム内で Alice と Bob が盛り上がって30分間熱烈に議論している。
2. そこへ Carol が入室する。
3. Carol のブラウザ画面は「真っ白(発言ゼロ)」。
4. Alice:「それマジでウケるwww」
5. Carol:「(えっ…何の話…? 誰か教えて…)」
さらに、モバイル環境でトンネルに入って回線が数秒途切れただけでも、その間に交わされたメッセージが抜け落ちてしまい、話の文脈が追えなくなっていました。
この問題を解決するため、私たちは 「サーバーを介さず、ピア(端末)同士が直接DataChannel経由で過去ログを融通し合うP2P分散ログ同期プロトコル」 を考案・実装しました。
2. P2P分散ログ同期のアーキテクチャ
サーバーに頼らず、かつ悪意ある改ざんや通信のパンクを防ぎながら過去ログを共有するために、以下のプロトコルを策定しました。
3つの必須要件
-
差分同期(Incremental Sync) :
- 一時切断からの復帰時は「自分が持っている最新メッセージのタイムスタンプ」を添えて
history_reqを送ることで、不在だった間の差分ログだけを最小帯域で取得する。
- 一時切断からの復帰時は「自分が持っている最新メッセージのタイムスタンプ」を添えて
-
多重受信・重複排除(De-duplication) :
- 部屋に10人の既存参加者がいた場合、10人全員から一斉に
history_resが返ってくる可能性がある(ログストーム)。各メッセージのユニークID(UUID)をメモリ上のSetで照合し、重複描画を完全に防止する。
- 部屋に10人の既存参加者がいた場合、10人全員から一斉に
-
視覚的分離(UI Separator) :
- リアルタイムの発言と同期された過去ログが混ざって混乱しないよう、
--- 📜 過去ログ同期ここまで (HH:mm:ss) ---という境界線を動的に挿入する。
- リアルタイムの発言と同期された過去ログが混ざって混乱しないよう、
3. ルーム作成時の不変ポリシー:端末保存 vs 完全揮発
ログの同期を可能にするにあたり、「そもそも自分のブラウザにログを保存したくない」「ブラウザを閉じたら綺麗サッパリ消えてほしい」というプライバシー至上主義のユーザーも存在します。
そこで、部屋を開設するホストが以下の 不変端末保存ポリシー(persistHistory) を選択できるようにしました。
| ポリシー | 挙動 | ストレージ |
|---|---|---|
| 💾 端末保存を許可 | 再訪時に過去ログが復元され、新しく入ってきた参加者へもログを配布する | ブラウザの IndexedDB に暗号化ペイロードのまま保存 |
| ⚡ 完全揮発(禁止) | ブラウザを閉じたりタブを閉じた瞬間に全消去。一時切断からの復帰時のみメモリ上のログを同期 | メモリ配列のみ(ディスク書き込みゼロ) |
この設定はシグナリングサーバーを介して参加者全員に固定配信され、チャット上部のトピックバーにバッジ(💾 保存: 許可 / ⚡ 保存: 禁止 (揮発))として明示されます。
4. 個人の自由意志を尊重する「配布・受信トグル」
部屋のポリシーとは別に、参加者個人の通信量やプライバシーへの考え方に配慮し、トピックバーの「📜 ログ設定」からいつでも以下の項目を切り替え可能にしました。
-
📤 他の参加者に過去ログを配る(ON / OFF) -
📥 入室・復帰時に過去ログを受け取る(ON / OFF)
設定値は各端末の localStorage に保存されます。「ギガ(モバイル通信量)を節約したいのでログ配布はOFFにする」といった柔軟な運用が可能です。
5. 実装の全貌:IndexedDB と 重複排除レンダリング
(1) IndexedDB を用いたセキュアなクライアントストレージ (P2PHistoryStore)
/**
* P2Pチャット用 IndexedDB ストレージ
*/
class P2PHistoryStore {
constructor(dbName = '2ch_p2p_history') {
this.dbName = dbName;
this.db = null;
this.readyPromise = this.initDB();
}
async initDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(this.dbName, 1);
req.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains('messages')) {
const store = db.createObjectStore('messages', { keyPath: 'id' });
store.createIndex('roomId', 'roomId', { unique: false });
store.createIndex('timestamp', 'timestamp', { unique: false });
}
};
req.onsuccess = (e) => {
this.db = e.target.result;
resolve();
};
req.onerror = reject;
});
}
async saveMessage(roomId, msg) {
await this.readyPromise;
return new Promise((resolve, reject) => {
const tx = this.db.transaction('messages', 'readwrite');
const store = tx.objectStore('messages');
store.put({ ...msg, roomId });
tx.oncomplete = () => resolve();
tx.onerror = reject;
});
}
async getHistorySince(roomId, sinceTimestamp) {
await this.readyPromise;
return new Promise((resolve, reject) => {
const tx = this.db.transaction('messages', 'readonly');
const store = tx.objectStore('messages');
const index = store.index('roomId');
const req = index.getAll(IDBKeyRange.only(roomId));
req.onsuccess = () => {
const msgs = req.result
.filter(m => m.timestamp > sinceTimestamp)
.sort((a, b) => a.timestamp - b.timestamp);
resolve(msgs);
};
req.onerror = reject;
});
}
}
(2) ログ要求と重複排除ハンドラ
// 重複描画を防ぐ Set
const renderedMsgIds = new Set();
let hasRenderedHistorySeparator = false;
// ピア接続完了時:過去ログ要求をブロードキャスト
function requestHistorySync(peer) {
if (!userSettings.receiveHistory) return;
const lastTs = getLastMessageTimestamp();
const packet = {
type: 'history_req',
since: lastTs
};
sendDirectMessage(peer, packet);
}
// 過去ログ要求受信時:差分ログを返送
async function handleHistoryRequest(peer, packet) {
if (!userSettings.shareHistory) return;
const diffLogs = await historyStore.getHistorySince(currentRoomId, packet.since);
if (diffLogs && diffLogs.length > 0) {
sendDirectMessage(peer, {
type: 'history_res',
logs: diffLogs
});
}
}
// 過去ログ応答受信時:重複排除して画面に展開
function handleHistoryResponse(packet) {
const logs = packet.logs;
if (!logs || !Array.isArray(logs)) return;
let newLogsRendered = false;
// タイムスタンプ順にソートして重複チェック
logs.sort((a, b) => a.timestamp - b.timestamp).forEach(msg => {
if (renderedMsgIds.has(msg.id)) return; // 既に描画済みならスキップ
renderedMsgIds.add(msg.id);
renderHistoricalMessage(msg);
newLogsRendered = true;
});
// 初めて過去ログが描画された場合、現在時刻との境界セパレーターを挿入
if (newLogsRendered && !hasRenderedHistorySeparator) {
renderHistorySeparator();
hasRenderedHistorySeparator = true;
}
}
6. 実機での動作検証
複数のブラウザインスタンスを用いて、以下の過酷なシナリオをテストしました。
-
新規入室時のログ同期 :
- 部屋で20件の会話が行われた後、新規ユーザーが入室。
- DataChannel接続後わずか 35ミリ秒 で全20件のログが復元され、末尾に「
--- 📜 過去ログ同期ここまで (15:20:11) ---」が美しく表示されることを確認。
-
5人同時からのログストーム耐性 :
- 5人の参加者がいる部屋へ入室した際、5人全員から同時に
history_resが届いても、renderedMsgIdsによってログの重複が 0件 であることを実証。
- 5人の参加者がいる部屋へ入室した際、5人全員から同時に
-
電波切断復帰テスト :
- Chrome DevToolsのネットワークを「Offline」にして3件発言させた後、「No throttling」に戻したところ、抜けていた3件のみが差分同期されることを確認。
7. まとめと次回予告
中央サーバーに一切ログを置かないという制約のもとで、WebRTC DataChannelとIndexedDBを組み合わせた「P2P分散ログ同期」を完成させました。
これにより、途中参加者もスムーズに会話に合流できるようになり、P2Pチャットの実用性が飛躍的に向上しました。
しかし、テストを重ねる中で、もう1つの極めて深刻な問題が浮上しました。
それは、 「部屋を作ったホスト(部屋主)がブラウザを閉じると、全員が追い出されたり部屋が崩壊してしまう」 というP2P特有のリーダー喪失問題です。
部屋主が突然寝落ちしたり回線が切れたとき、部屋を解散させずに存続させるにはどうすればよいのか?
次回は、 【第032回】部屋主が落ちても解散させない!P2Pチャット管理権限の自動選出&ホストなすりつけ機能 をお届けします。お楽しみに!
本番サービス : https://2ch.biz/