架構藍圖:即時聊天室
每個房間用一個 Durable Object 握住所有開啟的 WebSocket 連線,把訊息廣播給所有人;D1 負責保存歷史紀錄。
我們要做什麼?
一個即時聊天室:同一個房間裡的很多人,能「立刻」看到彼此的訊息。我們用 WebSocket(一條持續開啟、雙向的連線),讓伺服器可以主動把新訊息推下來,瀏覽器不必一直重複詢問。
關鍵在於:誰來握住這些開著的連線,並確保房間裡每個人都收到每則訊息?答案是 Durable Object(DO,可譯為「持久物件」)——一個單一、永遠都在的迷你伺服器。我們讓「每個房間」各擁有一個 Durable Object。前面放一個小小的 Worker 負責把每個瀏覽器路由到正確的房間,而 D1(Cloudflare 的 SQL 資料庫)負責保存訊息歷史。
把它想成…
每個聊天室就像一間真實的會議室,門口站著一位專屬主持人。主持人(也就是 Durable Object)手上有「室內所有人」的名單;有人發言時,主持人就把那句話複誦給整個房間聽,並記在房間的紀錄簿(D1)裡。不同的房間名稱 = 不同的主持人。誰在哪個房間,永遠不會搞混。
每個角色負責什麼?
總共有四個角色,每個角色只做一件清楚的事——把職責切乾淨,整個系統才好懂、好維護。
瀏覽器(用戶端)
對伺服器開一條 WebSocket,顯示收到的訊息,並把使用者輸入的內容送出去。它從不直接和其他瀏覽器溝通。
Worker(路由器)
一道無狀態的前門。它從網址讀出房間名稱,依名稱找到那間房的 Durable Object,再把連線轉送過去。這裡不放任何聊天邏輯。
房間 Durable Object
整個系統的核心。每個房間一個。它握住那間房所有開啟的 WebSocket,把每則訊息廣播給全部連線,是唯一的協調點(single coordination point)。
D1(歷史)
一個 SQL 資料庫,把每則訊息存起來,讓對話在重新整理、甚至房間物件重啟後依然存在。DO 每則訊息寫入一列(row)。
為什麼 Durable Object 天生就是「房間」?因為「相同的房間名稱」永遠會對應到「同一個」單一實例(instance),不管在地球哪個角落都一樣。這個單一實例就成了房間所有連線與狀態唯一的所在——不需要鎖(lock)、不需要訊息中介、也沒有競爭條件(race condition)。
訊息歷史資料表
即時訊息是在 Durable Object 的記憶體裡流動;永久的那一份則存在 D1。我們只需要一張資料表 messages,每則聊天訊息一列。room_id 把每則訊息綁回它所屬的房間。
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id TEXT NOT NULL,
user TEXT NOT NULL,
body TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_messages_room
ON messages (room_id, created_at);為什麼 created_at 是數字
我們把 created_at 存成整數——也就是 Date.now() 的毫秒數。數字排序乾淨,也方便在瀏覽器轉成任何時區。(room_id, created_at) 這個索引讓「載入這個房間最近 50 則訊息」變得很快。
訊息往返流程
下面是「一則訊息的一生」——從瀏覽器連上線,到 Durable Object 把它廣播給所有人、並存進 D1。
「廣播」= 一進,多出
一個用戶端送進「一則」訊息;Durable Object 就走訪每一條開著的連線,把它再送回給「所有人」——包含原本的發送者,這樣他自己的訊息也會用和別人一樣的方式顯示出來。
一步步動手做
建立專案與一個 D1 資料庫
先建立一個 Worker,再建立用來存歷史的 D1 資料庫。記下它印出的 database_id——下一步要貼進設定檔。
npm create cloudflare@latest -- chat-room cd chat-room npx wrangler d1 create chat-history設定 wrangler.jsonc
綁定 Durable Object 類別(ROOMS)、為它登記一個 SQLite migration(遷移),再綁定 D1 資料庫(DB)。migration 是在告訴 Cloudflare:ChatRoom 是一個新的、以 SQLite 儲存的類別。
{ "name": "chat-room", "main": "src/index.js", "compatibility_date": "2025-01-01", "durable_objects": { "bindings": [ { "name": "ROOMS", "class_name": "ChatRoom" } ] }, "migrations": [ { "tag": "v1", "new_sqlite_classes": ["ChatRoom"] } ], "d1_databases": [ { "binding": "DB", "database_name": "chat-history", "database_id": "<your-d1-database-id>" } ] }建立 messages 資料表
把前面「資料模型」那段的 schema.sql 套用到你的 D1 資料庫,讓 messages 資料表先存在,之後才寫得進去。
npx wrangler d1 execute chat-history --remote --file=./schema.sql寫 Worker(路由器)
Worker 只做三件事:確認這是 WebSocket 升級請求、用 env.ROOMS.idFromName(room) 依名稱找到房間的 Durable Object、把請求轉送過去。它也把 ChatRoom 重新匯出(re-export),好讓執行環境找得到這個類別。
import { ChatRoom } from "./chat-room.js"; export { ChatRoom }; export default { async fetch(request, env) { const url = new URL(request.url); const room = url.searchParams.get("room") || "lobby"; // Only accept WebSocket upgrade requests if (request.headers.get("Upgrade") !== "websocket") { return new Response("Expected a WebSocket upgrade", { status: 426 }); } // Resolve the ONE Durable Object for this room name const id = env.ROOMS.idFromName(room); const stub = env.ROOMS.get(id); // Forward the upgrade request to that room object return stub.fetch(request); }, };寫房間 Durable Object
DO 用 acceptWebSocket 完成 WebSocket 握手(採休眠模式)。每當有訊息進來,webSocketMessage 處理器就會執行:先寫一列到 D1,再把訊息廣播給 getWebSockets() 回傳的每一條連線。
import { DurableObject } from "cloudflare:workers"; export class ChatRoom extends DurableObject { constructor(ctx, env) { super(ctx, env); this.ctx = ctx; this.env = env; } // Completes the WebSocket handshake and registers the socket async fetch(request) { const url = new URL(request.url); const room = url.searchParams.get("room") || "lobby"; const pair = new WebSocketPair(); const client = pair[0]; const server = pair[1]; // Hibernation: let the runtime hold the socket so the DO can sleep this.ctx.acceptWebSocket(server); server.serializeAttachment({ room }); return new Response(null, { status: 101, webSocket: client }); } // Runs whenever ANY connected client sends a message async webSocketMessage(ws, raw) { const { user, body } = JSON.parse(raw); const { room } = ws.deserializeAttachment(); const created_at = Date.now(); // 1) Persist to D1 history await this.env.DB .prepare("INSERT INTO messages (room_id, user, body, created_at) VALUES (?, ?, ?, ?)") .bind(room, user, body, created_at) .run(); // 2) Broadcast to everyone connected to THIS room const payload = JSON.stringify({ user, body, created_at }); for (const socket of this.ctx.getWebSockets()) { socket.send(payload); } } async webSocketClose(ws, code, reason, wasClean) { ws.close(code, "room closing socket"); } }從瀏覽器連線
在前端,用 ?room=lobby 對 Worker 開一條 WebSocket,把每則進來的訊息畫到畫面上,並把使用者輸入的文字以 JSON 送出。正式環境請用 wss://(加密版)。
<script> const room = "lobby"; const ws = new WebSocket(`wss://chat-room.example.workers.dev/?room=${room}`); ws.addEventListener("open", () => console.log("connected to", room)); ws.addEventListener("message", (event) => { const msg = JSON.parse(event.data); addLine(`${msg.user}: ${msg.body}`); // render into your chat list }); // Call this when the user submits the chat box function send(user, text) { ws.send(JSON.stringify({ user, body: text })); } </script>部署
發佈到 Cloudflare 的網路。你用每一個不同的 ?room= 值連線,都會自動拿到屬於它自己的 Durable Object。
npx wrangler deploy
重點概念
WebSocket
一條會持續開啟、且「雙向」的連線。和一般「先請求、再回應」不同,伺服器可以隨時主動把資料推下來——非常適合聊天。
WebSocketPair
在伺服器端你會建立「一對」互相連動的連線:自己留著一端(server),把另一端(client)放進 101 回應交還給瀏覽器。
休眠(Hibernation)
用 acceptWebSocket() 後,由執行環境替你保管連線。一個閒置的房間物件可以從記憶體被回收,但連線依然活著——所以成千上萬條閒置連線幾乎不花錢。
廣播(Broadcast)
走訪 ctx.getWebSockets(),對每一條 send()。「房間裡所有連線」這份名單就在手上——這正是「一個房間一個物件」如此方便的原因:你要找的人全都在這裡。
idFromName()
把房間名稱轉成一個穩定的物件 ID。相同名稱永遠對應到同一個單一實例,哪裡都一樣——「路由到那間房」就是靠這個。
為什麼用 DO 當房間
一個房間需要「一個」地方來握住所有成員、並對訊息順序達成一致。Durable Object 正好就是這個:一個單一協調點,不需要鎖,也不需要額外的訊息中介。
陷阱與小提示
一個房間 = 一個瓶頸
一個房間的所有流量都匯集到它「單一」的物件(軟性上限約每秒 1,000 個請求)。對一個聊天室來說綽綽有餘,但別想把整個網站塞進同一個 DO——請依房間、文件或使用者把工作拆開。
容易踩的坑
- 忘了在 Worker 入口檔把 ChatRoom 類別重新匯出(re-export)——執行環境會找不到它。
- 正式環境用 ws:// 而非 wss://;在 HTTPS 頁面上瀏覽器會擋掉不安全的 WebSocket。
- 在 webSocketMessage 裡做太重的工作,卡住廣播——順序維持簡單:先存檔,再廣播。
- 盲目相信訊息內容——寫進 D1 前先驗證 user 與 body。
- 以為 DO 的記憶體會永遠存在——它會休眠,所以請把 D1 當成持久的歷史來源。
加入房間時顯示歷史
用戶端連上線時,先對 D1 跑 SELECT * FROM messages WHERE room_id = ? ORDER BY created_at DESC LIMIT 50,把這些歷史先送過去——新加入的人就能立刻看到最近的對話,而不是一個空房間。