第16章: Durable Objects によるリアルタイム共同編集(実践チュートリアル)
一般的なサーバーレス関数(Workers)はリクエストごとに起動・終了するステートレス(状態を持たない)な設計ですが、Durable Objects (DO) を使うことで、世界中に1つだけの一意なインスタンス(アクター)を立ち上げ、インメモリ状態の保持、高パフォーマンスなリアルタイム通信、組み込みSQLiteによる永続化 を実現できます。
本章では、WebSocketハイバネーションAPI(待機時のCPU/メモリ消費0)を活用した 「リアルタイムチャット&メッセージ履歴永続化サーバー」 をゼロから構築します。
本チュートリアルで作成するもの
- Durable Object (アクター):
- 部屋(ルーム)ごとに1つのインスタンスが自動生成
- 接続クライアントのWebSocket管理とメッセージのリアルタイム・ブロードキャスト
- 組み込みSQLite(
ctx.storage.sql)への過去ログ自動永続化
- HTML/JSクライアント:
- ブラウザからWebSocketで接続し、チャット送受信ができる検証用UI
- CLI操作:
wrangler.jsoncでのマイグレーションタグ管理とローカル/本番デプロイ
【アーキテクチャ図】[ ユーザーA (Browser) ] ──WebSocket──┐ ├──> [ Durable Object (ChatRoom) ][ ユーザーB (Browser) ] ──WebSocket──┘ ├── メッセージ即時ブロードキャスト └── 組み込みSQLiteに履歴保存Step 1: プロジェクトの作成と設定
ターミナルで新しいプロジェクトを作成(または既存プロジェクトに設定)します。
mkdir do-chat-server && cd do-chat-servernpm init -ynpm install --save-dev wrangler typescript @cloudflare/workers-typeswrangler.jsonc を作成し、Durable Objects のバインディングと初期マイグレーションタグを設定します。
{ "$schema": "node_modules/wrangler/config-schema.json", "name": "do-chat-server", "main": "src/index.ts", "compatibility_date": "2024-09-01", "compatibility_flags": [ "nodejs_compat" ], "durable_objects": { "bindings": [ { "name": "CHAT_ROOM", "class_name": "ChatRoom" } ] }, // Durable Objects の初回作成時は migrations が必須 "migrations": [ { "tag": "v1", "new_classes": ["ChatRoom"] } ]}型定義を自動生成します。
npx wrangler typesStep 2: Durable Object 実装コード(src/index.ts)
src/index.ts を作成し、以下の完全コードを記述します。
import { DurableObject } from 'cloudflare:workers';
// メッセージの型定義type ChatMessage = { id?: number; user: string; text: string; createdAt?: string;};
// 1. Durable Object クラス(ルームごとに1つ存在)export class ChatRoom extends DurableObject { constructor(ctx: DurableObjectState, env: Env) { super(ctx, env);
// 組み込みSQLiteの初期化(メッセージ履歴テーブル) this.ctx.storage.sql.exec(` CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, user TEXT NOT NULL, text TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now', 'utc')) ); `); }
// ルーターからのリクエスト受付 async fetch(request: Request): Promise<Response> { const url = new URL(request.url);
// WebSocket接続のアップグレード要求 if (request.headers.get('Upgrade') === 'websocket') { const pair = new WebSocketPair(); const [client, server] = Object.values(pair);
// WebSocketハイバネーションを受理(待機中のWorkerコストをゼロにする) this.ctx.acceptWebSocket(server);
// 接続開始時に、過去のメッセージ履歴(最新10件)をクライアントへ送信 const cursor = this.ctx.storage.sql.exec<ChatMessage>( 'SELECT user, text, created_at as createdAt FROM messages ORDER BY id DESC LIMIT 10' ); const history = Array.from(cursor).reverse(); server.send(JSON.stringify({ type: 'history', data: history }));
return new Response(null, { status: 101, webSocket: client }); }
return new Response('WebSocket endpoint only', { status: 400 }); }
// クライアントからメッセージを受信したときのハンドラー async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) { try { const payload = JSON.parse(message.toString()); const { user, text } = payload;
if (!user || !text) return;
// 1. SQLiteデータベースへ保存 this.ctx.storage.sql.exec( 'INSERT INTO messages (user, text) VALUES (?, ?)', user, text );
// 2. 接続中の全クライアントへリアルタイム転送(ブロードキャスト) const broadcastData = JSON.stringify({ type: 'message', data: { user, text, createdAt: new Date().toISOString() }, });
for (const client of this.ctx.getWebSockets()) { client.send(broadcastData); } } catch (e) { console.error('WebSocketメッセージ解析エラー:', e); } }
async webSocketClose(ws: WebSocket, code: number, reason: string) { ws.close(code, 'Closed by DO'); }}
// 2. メインのHTTPエントリーポイント(ルーティング)export default { async fetch(request: Request, env: Env): Promise<Response> { const url = new URL(request.url);
// チャット接続エンドポイント: /ws?room=general if (url.pathname === '/ws') { const roomName = url.searchParams.get('room') || 'general';
// 部屋名から一意のDurable Object IDを取得(同じ名前なら世界中どこからでも同じインスタンスに接続) const doId = env.CHAT_ROOM.idFromName(roomName); const stub = env.CHAT_ROOM.get(doId);
return stub.fetch(request); }
// クライアント用HTMLの配信(動作テスト用UI) return new Response(htmlClient, { headers: { 'Content-Type': 'text/html; charset=utf-8' }, }); }};
// 動作確認用HTMLconst htmlClient = `<!DOCTYPE html><html><head> <meta charset="utf-8"> <title>Durable Objects リアルタイムチャット</title> <style> body { font-family: sans-serif; max-width: 600px; margin: 30px auto; padding: 0 15px; } #chat-box { border: 1px solid #ccc; height: 350px; overflow-y: scroll; padding: 10px; margin-bottom: 10px; border-radius: 6px; } .msg { margin-bottom: 8px; } .user { font-weight: bold; color: #f38020; } .time { font-size: 0.8em; color: #888; margin-left: 6px; } input, button { padding: 8px 12px; font-size: 1rem; } </style></head><body> <h2>⚡️ Durable Objects チャットルーム</h2> <div id="chat-box"></div> <input type="text" id="user" placeholder="お名前" value="User_${Math.floor(Math.random()*1000)}" style="width: 120px;"> <input type="text" id="text" placeholder="メッセージを入力..." style="width: 320px;"> <button onclick="send()">送信</button>
<script> const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; const ws = new WebSocket(protocol + '//' + location.host + '/ws?room=general'); const box = document.getElementById('chat-box');
ws.onmessage = (e) => { const res = JSON.parse(e.data); if (res.type === 'history') { res.data.forEach(appendMessage); } else if (res.type === 'message') { appendMessage(res.data); } };
function appendMessage(m) { const div = document.createElement('div'); div.className = 'msg'; div.innerHTML = '<span class="user">' + m.user + ':</span> ' + m.text + '<span class="time">' + (m.createdAt || '') + '</span>'; box.appendChild(div); box.scrollTop = box.scrollHeight; }
function send() { const user = document.getElementById('user').value; const text = document.getElementById('text').value; if (!text) return; ws.send(JSON.stringify({ user, text })); document.getElementById('text').value = ''; } </script></body></html>`;Step 3: ローカルでの動作検証
ローカル開発サーバーを起動します。
npx wrangler dev- ブラウザを 2つのウィンドウ で開き、
http://localhost:8787にアクセスします。 - 片方のウィンドウでメッセージを入力して「送信」を押します。
- もう片方のウィンドウに 瞬時(数ミリ秒) でメッセージが表示されることを確認します。
- ページをリロードしても、組み込みSQLiteに過去ログが保存されているため履歴が表示されます。
Step 4: 本番デプロイ
npx wrangler deploy出力された https://do-chat-server.xxxx.workers.dev にアクセスすれば、世界中誰とでもリアルタイムにチャットが行えます。
まとめ
- アクターモデル:
idFromName('general')で世界に1つだけの常駐オブジェクトを特定。 - WebSocketハイバネーション: 何千人が接続していても、発言がない待機中のCPU課金はゼロ。
- SQLite統合: 外部DBへの通信ラグなしで、インスタンス内で超高速にメッセージ履歴を永続化。
💡 用語解説コラム
[!NOTE] アクターモデル (Actor Model)
並行コンピューティングの設計モデル。Durable Objectsのように「個別の状態(メモリ/DB)を持ち、メッセージ送受信でのみやり取りする自律した単位(アクター)」として振る舞います。
[!NOTE] WebSocketハイバネーション (Hibernation)
クライアントとWebSocket接続を維持したまま、通信がない待機中はWorkerプロセスのメモリとCPU消費をゼロにする技術。接続維持コストを劇的に下げます。