DO 實戰:多人協作聊天室從零打造 | Cloudflare 完整教學
上一篇《DO RPC 與跨物件呼叫》,我們讓 Durable Object 學會彼此型別安全地呼叫協作。這一篇是 DO 的收尾實戰:我們要把前面學到的所有積木——WebSocket Hibernation 廣播、SQLite Storage 存訊息、Alarms 排程清理、以及單執行緒一致性——全部串起來,用「每個房間一個 DO」的架構,從零逐段 build 出一個真正能用的即時協作聊天室。你會看到房間怎麼路由、連線怎麼管理與廣播、訊息怎麼持久化與回放、以及 **presence(在線名單)**怎麼同步。
前言
即時協作(Real-time Collaboration) 指的是多個使用者同時對同一份「共享狀態」進行讀寫,而且彼此的改動要即時反映到所有人畫面上——聊天室裡別人發的訊息、協作文件裡別人打的字、多人遊戲裡對手的走位,都是這類場景。它的技術難點永遠是同一個:這份共享狀態該由誰來當「唯一權威」,並保證所有人看到的順序一致?
打個比方。多人協作就像一群人圍著同一張白板開會。如果每個人手上各拿一塊白板(無狀態 Workers、各自的資料庫副本),你寫你的、我寫我的,誰也不知道對方寫了什麼,最後拼不回一致的內容。而 Durable Object 的做法是:整個房間只有一塊白板,而且只有一位「主持人」能碰它——所有人的訊息都送給這位主持人,由他一則一則(單執行緒序列化)寫上白板,再即時廣播給在場每一個人。這位主持人就是那個「每房間一個」的 DO:它同時是權威狀態源(SQLite 存訊息)、連線協調者(持有所有人的 WebSocket)、也是排程管家(用 Alarm 定期清理白板),三種角色合而為一。
這正是 DO 收尾實戰的價值:前面幾篇我們分開學了 SQLite、Alarms、Hibernation、RPC,這一篇要把它們組合成一個完整系統,你會親眼看到這些能力如何在一個 DO 裡自然協作。
這篇聚焦「DO 實戰:多人協作聊天室」這一個主題。讀完你會掌握:
- 每房間一個 DO 的架構拆解——為什麼用
idFromName(roomId)路由,以及 Hibernation、SQLite、Alarms 三者如何在一個 DO 裡分工 - 連線管理與廣播——用
acceptWebSocket啟用休眠、用ctx.getWebSockets()廣播,而非自己維護 Set - 狀態同步與歷史回放——訊息寫進房間自己的 SQLite,新人進房載入最近 N 則
- Presence 在線名單——如何用 attachment + 廣播同步「誰在線上」,以及用 Alarm 清理過期資料
架構拆解
在寫任何程式碼之前,先把整個聊天室的架構在腦中畫出來。核心只有一句話:一個 roomId 對應一個 ChatRoom DO 實例,這個 DO 一手包辦一個房間的所有事。
瀏覽器 A ─┐
瀏覽器 B ─┼─ WebSocket ─▶ Worker(無狀態路由層)
瀏覽器 C ─┘ │
env.CHAT_ROOM.getByName(roomId)
│
▼
┌──────────────────────────────────┐
│ ChatRoom DO (room:general) │
│ ─ 持有 A/B/C 的 WebSocket 連線 │ ← Hibernation
│ ─ 內嵌 SQLite:messages 表 │ ← Storage
│ ─ Alarm:每天清理舊訊息 │ ← Alarms
│ ─ 單執行緒:訊息逐則序列化廣播 │ ← 一致性
└──────────────────────────────────┘
這張圖裡有四個關鍵設計,分別對應前面幾篇學過的能力:
1. 房間路由(idFromName):把負載天然分散
Worker 本身是無狀態的路由層,它唯一的工作是看使用者要進哪個房間,然後把連線轉交給對應的 DO。env.CHAT_ROOM.getByName("room:general") 保證「相同房間名 → 相同 DO 實例」,不同房間則落在不同實例上——room:general 和 room:random 是兩個完全隔離、各自單執行緒的物件。這就是為什麼絕不能用一個全域 DO 管所有房間:那樣所有流量會擠進同一條單執行緒,撞上約 1,000 QPS 的軟性上限。每房間一個 DO,負載才會被 Cloudflare 全球網路天然攤開。
2. WebSocket Hibernation(第 026 篇):扛長連線又省錢
聊天室的連線大部分時間是閒著的(沒人打字)。如果用傳統 ws.accept(),DO 會一直被計費、一直佔記憶體;改用《WebSocket Hibernation》教過的 ctx.acceptWebSocket(ws),DO 在空閒時可以休眠(暫停計費),連線卻不斷開,有訊息進來才重新喚醒。代價是:休眠後 in-memory 狀態全清空,所以連線清單要靠 ctx.getWebSockets()、連線資料要靠 attachment。
3. SQLite Storage(第 024 篇):訊息的權威儲存
每則訊息都要持久化,不然使用者重整頁面、或新人進房就看不到歷史。我們把訊息寫進這個房間 DO 自己的 SQLite(《Durable Objects SQLite 儲存》教過的 ctx.storage.sql),計算與儲存共置,讀寫延遲接近零,而且資料天然隔離——room:general 的訊息不會混進 room:random。
4. Alarms(第 025 篇):自動清理管家
訊息會無限成長,單個 DO 的 SQLite 上限是 10 GB。我們用《Durable Objects Alarms 排程》教過的 ctx.storage.setAlarm() 排一個每日鬧鐘,自動刪掉過期(如 30 天前)的訊息,讓房間永遠輕量、載入永遠快。
四者合在一個 DO 裡,角色分工清清楚楚:Hibernation 管連線、SQLite 管資料、Alarm 管清理、單執行緒管一致性。接下來我們就逐段把它 build 出來。
逐段實作
1. wrangler 設定
一個 SQLite-backed 的 DO,綁定與 migration 如下。compatibility_date 要夠新(RPC 與現代 API 需要 >= 2024-04-03),並用 new_sqlite_classes 宣告 SQLite 後端:
// wrangler.jsonc
{
"name": "chat-app",
"main": "src/index.ts",
"compatibility_date": "2024-04-03",
"durable_objects": {
"bindings": [
{ "name": "CHAT_ROOM", "class_name": "ChatRoom" }
]
},
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["ChatRoom"] }
]
}
2. Worker 路由層:把連線轉交給對應房間
Worker 是無狀態的,只做兩件事:升級 WebSocket 連線與提供歷史訊息 API。重點在 getByName(roomId)——它把不同房間路由到不同 DO 實例:
// src/index.ts
import { DurableObject } from "cloudflare:workers";
export interface Env {
// ★ 泛型參數讓 stub 帶有 ChatRoom 的 RPC 方法型別
CHAT_ROOM: DurableObjectNamespace<ChatRoom>;
}
export default {
async fetch(req: Request, env: Env): Promise<Response> {
const url = new URL(req.url);
// 路由:/room/:roomId/ws → WebSocket 升級
const wsMatch = url.pathname.match(/^\/room\/([^/]+)\/ws$/);
if (wsMatch) {
const roomId = wsMatch[1];
// ★ 房間路由:相同 roomId 永遠指向同一個 DO 實例
const stub = env.CHAT_ROOM.getByName(`room:${roomId}`);
// 把原始 Request(含 Upgrade header)直接轉交給 DO
return stub.fetch(req);
}
// 路由:/room/:roomId/history → 用 RPC 取得歷史訊息
const histMatch = url.pathname.match(/^\/room\/([^/]+)\/history$/);
if (histMatch) {
const stub = env.CHAT_ROOM.getByName(`room:${histMatch[1]}`);
const before = Number(url.searchParams.get("before") ?? 0) || undefined;
const messages = await stub.getHistory(50, before); // 型別安全的 RPC 呼叫
return Response.json(messages);
}
return new Response("Not Found", { status: 404 });
},
} satisfies ExportedHandler<Env>;
注意 WebSocket 升級這條路徑我們用 stub.fetch(req) 轉發原始 Request——這是少數必須用 fetch 而非 RPC 的場景,因為 WebSocket 升級需要原生 HTTP 語義(Upgrade header 與 101 回應)。而歷史訊息這條純資料查詢,則用上一篇學的 RPC(stub.getHistory(...))乾淨呼叫。
3. ChatRoom DO 骨架:建 schema
DO 類別在 constructor 裡用 blockConcurrencyWhile 建好 SQLite schema。這是唯一該用 blockConcurrencyWhile 的地方(初始化),別在每個請求上用它:
export class ChatRoom extends DurableObject<Env> {
private sql = this.ctx.storage.sql;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
// ★ 只在初始化時 block:建表在第一個請求進來前完成
ctx.blockConcurrencyWhile(async () => {
this.sql.exec(`
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
who TEXT NOT NULL,
text TEXT NOT NULL,
ts INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
`);
});
// ★ ping/pong 心跳自動回應,不需喚醒物件(省計費)
ctx.setWebSocketAutoResponse(
new WebSocketRequestResponsePair("ping", "pong")
);
}
}
setWebSocketAutoResponse 是個小而美的最佳化:前端每 30 秒送一個 "ping" 保活,runtime 直接回 "pong",完全不需要喚醒休眠中的 DO,聊天室閒置時就能真正做到零計費。
4. 接受連線 + presence 加入廣播
WebSocket 升級進來時,我們用 acceptWebSocket 啟用 Hibernation,把使用者資料塞進 attachment,並廣播「有人加入」給房間裡其他人:
export class ChatRoom extends DurableObject<Env> {
// ...(承上)
async fetch(req: Request): Promise<Response> {
if (req.headers.get("Upgrade") !== "websocket") {
return new Response("Expected WebSocket", { status: 426 });
}
const url = new URL(req.url);
const userId = url.searchParams.get("userId") ?? crypto.randomUUID();
const username = url.searchParams.get("username") ?? "訪客";
const { 0: client, 1: server } = new WebSocketPair();
// ★ 關鍵:用 acceptWebSocket(而非 server.accept())啟用 Hibernation
// 第二參數是 tags,之後可用 getWebSockets(tag) 篩選
this.ctx.acceptWebSocket(server, [userId]);
// ★ 連線資料存進 attachment,跨 Hibernation 存活(上限 16 KB)
server.serializeAttachment({ userId, username });
// ★ 廣播「加入」給其他人,並把目前在線名單發給新連上的人
this.broadcast({ type: "join", userId, username }, server);
server.send(JSON.stringify({ type: "presence", users: this.listPresence() }));
return new Response(null, { status: 101, webSocket: client });
}
// ★ 目前在線名單:一律從 runtime 維護的連線清單推導,不自己存 Set
private listPresence(): { userId: string; username: string }[] {
return this.ctx.getWebSockets().map((ws) => {
const meta = ws.deserializeAttachment() as { userId: string; username: string };
return { userId: meta.userId, username: meta.username };
});
}
}
這裡就藏著本篇最重要的一條原則:presence(在線名單)不是一個你手動維護的變數,而是「當下所有 WebSocket 連線」的投影。listPresence() 每次都從 ctx.getWebSockets() 現算,所以它永遠正確——就算 DO 剛從休眠喚醒、in-memory 狀態全空,連線清單依然由 runtime 保管得好好的。
5. 收訊息:存 SQLite + 廣播
webSocketMessage 是 Hibernation 的訊息 handler(收到訊息時 DO 會被喚醒觸發它)。我們在這裡做三件事:讀 attachment 拿發話人 → 寫進 SQLite → 廣播給全房間:
export class ChatRoom extends DurableObject<Env> {
// ...(承上)
async webSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): Promise<void> {
// Hibernation 後 in-memory 狀態已清空,一律從 attachment 讀回身分
const { username } = ws.deserializeAttachment() as { username: string };
let data: { type: string; text?: string };
try {
data = JSON.parse(typeof raw === "string" ? raw : new TextDecoder().decode(raw));
} catch {
ws.send(JSON.stringify({ type: "error", message: "格式錯誤" }));
return;
}
if (data.type === "message" && data.text) {
if (data.text.length > 500) {
ws.send(JSON.stringify({ type: "error", message: "訊息過長(上限 500 字)" }));
return;
}
// ★ 先持久化:INSERT ... RETURNING 直接拿回自增 id 與時間
const row = this.sql
.exec<{ id: number; ts: number }>(
"INSERT INTO messages (who, text) VALUES (?, ?) RETURNING id, ts",
username,
data.text
)
.one();
// ★ 再廣播:Output Gate 保證 SQLite 寫入完成後,訊息才對外送出
this.broadcast({
type: "message",
id: row.id,
who: username,
text: data.text,
ts: row.ts,
});
// ★ 確保有一個每日清理鬧鐘(冪等:已有就不重設)
await this.ensureCleanupAlarm();
}
}
// ★ 廣播核心:一律走 ctx.getWebSockets(),不自己維護 Set
private broadcast(payload: unknown, exclude?: WebSocket): void {
const msg = JSON.stringify(payload);
for (const ws of this.ctx.getWebSockets()) {
if (ws === exclude) continue;
try {
ws.send(msg);
} catch {
// 連線已死,忽略(webSocketClose 會處理後續)
}
}
}
}
留意「先寫 SQLite、再廣播」的順序,以及 DO 的 Output Gate 保證:儲存寫入完成前,對外的 WebSocket 訊息不會真的送出。這意味著使用者一旦收到某則訊息,它保證已經落進資料庫——不會出現「別人看到了、重整卻消失」的鬼故事。
6. 離線:presence 移除廣播
連線關閉或出錯時,Hibernation 會觸發 webSocketClose / webSocketError。此時該連線已從 getWebSockets() 移除,我們只需廣播「有人離開」:
export class ChatRoom extends DurableObject<Env> {
// ...(承上)
async webSocketClose(ws: WebSocket, code: number, reason: string): Promise<void> {
const meta = ws.deserializeAttachment() as { userId: string; username: string } | null;
if (meta) {
this.broadcast({ type: "leave", userId: meta.userId, username: meta.username });
}
}
async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
console.error("WebSocket error:", error);
try { ws.close(1011, "internal error"); } catch {}
}
}
7. 歷史回放:分頁載入,不撈全部
getHistory 是給 Worker 呼叫的 RPC 方法,支援用游標(before)往上翻頁。永遠只撈最近 N 筆,絕不 SELECT *:
export class ChatRoom extends DurableObject<Env> {
// ...(承上)
// ★ RPC:回傳最近 limit 筆(可帶 before 游標往上翻舊訊息)
async getHistory(
limit = 50,
before?: number
): Promise<{ id: number; who: string; text: string; ts: number }[]> {
const rows = before
? this.sql.exec<{ id: number; who: string; text: string; ts: number }>(
"SELECT id, who, text, ts FROM messages WHERE id < ? ORDER BY id DESC LIMIT ?",
before,
limit
)
: this.sql.exec<{ id: number; who: string; text: string; ts: number }>(
"SELECT id, who, text, ts FROM messages ORDER BY id DESC LIMIT ?",
limit
);
// DESC 撈出後 reverse 成正序(舊→新)給前端顯示
return rows.toArray().reverse();
}
}
8. Alarm 清理:每天刪掉舊訊息
最後補上清理管家。ensureCleanupAlarm 冪等地確保有一個鬧鐘;alarm() 被觸發時刪掉過期訊息,並重排下一次,形成自我延續的每日循環:
export class ChatRoom extends DurableObject<Env> {
// ...(承上)
private static readonly RETENTION_MS = 30 * 24 * 60 * 60 * 1000; // 保留 30 天
private static readonly DAY_MS = 24 * 60 * 60 * 1000;
// ★ 冪等:已有 alarm 就不重設(避免每則訊息都重排)
private async ensureCleanupAlarm(): Promise<void> {
const existing = await this.ctx.storage.getAlarm();
if (existing === null) {
await this.ctx.storage.setAlarm(Date.now() + ChatRoom.DAY_MS);
}
}
// ★ Alarm handler:清理 + 重排下一次
async alarm(): Promise<void> {
const cutoff = Date.now() - ChatRoom.RETENTION_MS;
this.sql.exec("DELETE FROM messages WHERE ts < ?", cutoff);
// 若房間還有連線或還有訊息,續排明天的清理;否則讓它自然沉睡
const hasSockets = this.ctx.getWebSockets().length > 0;
const remaining = this.sql
.exec<{ n: number }>("SELECT COUNT(*) AS n FROM messages")
.one().n;
if (hasSockets || remaining > 0) {
await this.ctx.storage.setAlarm(Date.now() + ChatRoom.DAY_MS);
}
}
}
到這裡,一個完整聊天室 DO 的所有零件都齊了:建 schema → 接受連線並同步 presence → 收訊息存 SQLite 並廣播 → 處理離線 → 歷史分頁回放 → Alarm 定期清理。前面四篇分開學的能力,在這一個 ChatRoom 類別裡自然地協作了起來。
常見錯誤與最佳實踐
坑一:用一個全域 DO 管理所有房間,單點過熱。
這是新手最容易犯的架構錯誤。DO 是單執行緒的,把所有房間、所有連線塞進一個實例,等於讓全站流量排隊經過同一條執行緒,很快撞上約 1,000 QPS 軟性上限與連線數天花板,而且一掛全掛。
// ❌ 錯誤:全域單一 DO,所有房間共用 → 瓶頸
const stub = env.CHAT_ROOM.getByName("global"); // 全站都撞同一個實例
// ✅ 正確:每房間一個 DO,負載天然分散
const stub = env.CHAT_ROOM.getByName(`room:${roomId}`);
坑二:自己維護一個 WebSocket 的 Set 來廣播,Hibernation 後全空。
啟用 Hibernation 後,DO 休眠會清空所有 in-memory 狀態,你手動塞的 this.sessions 會歸零,喚醒後廣播漏掉所有人。連線清單一定要交給 runtime。
// ❌ 錯誤:自己維護 Set,休眠後清空
private sessions = new Set<WebSocket>();
this.sessions.add(server); // 休眠後這個 Set 會是空的
for (const ws of this.sessions) ws.send(msg); // 廣播漏人
// ✅ 正確:一律用 runtime 維護的連線清單
for (const ws of this.ctx.getWebSockets()) ws.send(msg);
坑三:每次進房都 SELECT * 撈全部歷史訊息。
熱門房間可能累積數十萬則訊息,一次全撈會讀爆記憶體、拖慢首屏。永遠分頁,只撈最近 N 筆,往上翻用游標。
// ❌ 錯誤:撈全部,房間一大就爆
this.sql.exec("SELECT * FROM messages").toArray();
// ✅ 正確:只撈最近 N 筆,翻頁用 WHERE id < ?
this.sql.exec("SELECT ... FROM messages ORDER BY id DESC LIMIT ?", 50);
坑四:廣播前先廣播、後才寫 SQLite,順序顛倒。
若先 broadcast() 再寫入,一旦寫入失敗,別人已經看到了一則「其實沒存進資料庫」的訊息,重整就消失。務必先持久化、再廣播,搭配 DO 的 Output Gate,保證使用者看到即代表已落庫。
// ✅ 正確順序:先寫 SQLite,再廣播
const row = this.sql.exec("INSERT ... RETURNING id, ts", who, text).one();
this.broadcast({ type: "message", id: row.id, ... }); // Output Gate 護航
坑五:在 SQL cursor 迭代中途 await,快照隔離失效。
exec() 回傳的 cursor 必須在下一個 await 前完全消耗(.toArray() / .one() / for...of),否則會拋錯。要對每筆結果做非同步處理,先 toArray() 全取出再迴圈。
// ❌ 錯誤:迭代中途 await,快照失效
for (const row of this.sql.exec("SELECT ...")) { await doAsync(row); }
// ✅ 正確:先全取出,再 await
for (const row of this.sql.exec("SELECT ...").toArray()) { await doAsync(row); }
還要記得的幾個原則:
- presence 是連線的投影,不是獨立狀態:在線名單一律從
ctx.getWebSockets()現算,加上 attachment 讀身分,永遠正確、免同步。 - 心跳用 auto-response:
setWebSocketAutoResponse("ping", "pong")讓保活心跳不喚醒 DO,閒置房間真正零計費。 - Alarm 要冪等且自我延續:設鬧鐘前先
getAlarm()檢查,handler 結尾視情況重排下一次,別讓每則訊息都重設鬧鐘。 - 分群廣播用 tag:
acceptWebSocket(ws, [tag])打標籤,getWebSockets(tag)篩選,可實作子頻道、私訊、管理員專屬廣播。
最佳實踐小結:記牢——每房間一個 DO 分散負載;連線清單交給 ctx.getWebSockets(),狀態放 attachment 或 SQLite,不信任何 in-memory 變數;訊息先寫 SQLite、再廣播,靠 Output Gate 保證一致;歷史一律分頁載入;用冪等 Alarm 定期清理讓房間輕量。守住這幾條,你的聊天室就能扛住大量房間與連線,又輕巧省錢。
小結
上一篇《DO RPC 與跨物件呼叫》,我們讓 DO 學會彼此型別安全地呼叫;這一篇,我們把整條 DO 學習線收尾,用一個真實的即時協作聊天室,把前面所有積木串成一個完整系統:
- 每房間一個 DO 的架構——Worker 是無狀態路由層,
getByName(roomId)把每個房間路由到獨立的 DO 實例,負載天然分散到全球網路,徹底避開「全域單一 DO」的瓶頸。 - Hibernation 廣播——用
acceptWebSocket啟用休眠,用ctx.getWebSockets()廣播、用 attachment 保存連線身分,絕不自己維護 Set;presence 在線名單則是連線清單的即時投影,永遠正確。 - SQLite 存訊息——訊息寫進房間自己的 SQLite,
INSERT ... RETURNING拿回 id,新人進房用分頁getHistory回放最近 N 則,絕不SELECT *。 - Alarms 清理——用冪等、自我延續的每日 Alarm 刪掉過期訊息,搭配 Output Gate「先寫後播」保證一致,讓房間輕量又省錢。
至此,CF-3 儲存資料這條主線(KV、R2、D1、Durable Objects 的 SQLite、Alarms、Hibernation、RPC 到今天的實戰)正式收尾——你已經有能力在邊緣管理各種形態的狀態。接下來我們要轉入非同步與工作流這個新章節:當請求量爆增、或有大量彼此獨立的背景工作要跑時,同步處理會拖垮回應速度,這時就需要一個「削峰填谷」的緩衝機制。下一篇《Queues 訊息佇列入門》,我們就從 Cloudflare Queues 出發,學習如何把耗時工作丟進佇列、非同步批次消化,讓你的 Worker 又快又穩。
想先查閱官方對 Durable Objects WebSocket 與聊天室的完整說明,可以隨時參考 Cloudflare Durable Objects WebSocket 官方文件。DO 實戰收尾在此,我們下一篇《Queues 訊息佇列入門》見。