DO 實戰:多人協作聊天室從零打造 | Cloudflare 完整教學

2026/08/28
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:generalroom: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 訊息佇列入門》見。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。

本週主打

AI 自動化入門包

你每天手動在做的那些煩事,其實 AI 可以自己跑。這份給你 10 個照著做就會的自動化工作流 + 50 個複製即用的提示詞,不用會寫程式。

看看這個產品 →