7.3 WebSocket 与 Durable Objects:即時通知與協作#
本節目標#
- 理解 WebSocket 握手(HTTP
Upgrade: websocket)的機制,以及它解決了什麼問題。 - 認識 Durable Objects(DO)提供 Workers 缺的兩個能力:單一實例強一致 與 多連線狀態。
- 在留言板實作:新留言推播到目前所有開著該頁面的瀏覽器(即時通知)。
- 以「每篇文章一個 DO」實現 rooms 概念,理解房間邊界。
歷史與理論:為什麼需要 WebSocket#
| 世代 | 年代 | 機制 | 缺點 |
|---|---|---|---|
| 輪詢 polling | 2000 | 前端每 3 秒 fetch | 空轉浪費、延遲 = 間隔 |
| 長輪詢 long-polling | 2005 | 伺服器等事件再回應 | 每次都要新建連線、proxy 緩衝 |
| Comet / SSE | 2007+ | HTTP 長連線單向流 | 單向;多首端連線數限制 |
| WebSocket | 2011(RFC 6455) | 一條 TCP 上雙向全雙工訊框 | 協議獨立、需升級握手 |
因果驅動:即時通訊(聊天、行情、協作)推動了「不要再重建連線」。WebSocket 先用 HTTP 完成升級握手:
GET /ws HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
HTTP/1.1 101 Switching Protocols
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Accept 值 = base64(sha1(Key + GUID("258EAFA5-E914-47DA-95CA-C5AB0DC85B11")))——證明伺服器懂該協定,而非裸 TCP。之後同條 TCP 改講 frame:文字/二進位訊框 + 雙向。
Durable Objects:給無狀態 Workers 補上的一塊#
Workers 為規模而生:isolate 無本地持久、請求任意散到 PoP。需要「全世界只有一個實例正在管這件事」的場景(聊天房、遊戲房、計數器),這正是 Durable Objects(2020 上線)出現的原因——DO 是個有 唯一 ID 的微型 stateful actor,同 ID 永遠只有一個活體在服務,可接 WebSocket、可帶 SQLite/KV 儲存。這在本質上是 Actor 模型(Aksel Bergmann 1973)在邊緣的複活:訊息佇列、內部序列化、私有狀態。
rooms 概念:不把「全站留言板」塞一個 DO(單點尖峰 + 不相關的人互相干擾),而是每篇文章一個 DO,以 :id 決定 DO 的命名空間:
| 情境 | DO 實例 |
|---|---|
| 文章 12 的留言板 | Room + :post-12 |
| 文章 47 的留言板 | Room + :post-47 |
| 全站線上人數 | Room + :global(低頻) |
實作:留言板 + 新留言即時推播#
// wrangler.toml
[[durable_objects.bindings]]
name = "ROOM"
class_name = "Room"
[[migrations]]
tag = "v1"
new_classes = ["Room"]
// src/index.ts
export { Room } from "./room";
export default {
async fetch(req: Request, env: Env, ctx: ExecutionContext) {
const url = new URL(req.url);
if (req.header("Upgrade") === "websocket") {
const postId = url.searchParams.get("post")!;
// 同一 postId → 同一 DO 實例;這就是「房間」
const roomId = env.ROOM.idFromName(`post-${postId}`);
const room = env.ROOM.get(roomId);
return room.fetch(req);
}
return new Response("加入 /ws?post=12 連線");
},
};
// src/room.ts
export class Room implements DurableObject {
private sessions: Map<WebSocket, string> = new Map(); // ws → 訪客名
async fetch(req: Request): Promise<Response> {
// 1. 幫這個請求蓋一個成對的 WebSocket
const pair = new WebSocketPair();
// 2. 接受升級、記WindowState
this.sessions.set(pair[1], "guest-" + Math.floor(Math.random() * 1000));
const { 0: client, 1: server } = pair;
server.accept();
server.addEventListener("message", async (e) => {
const { text } = JSON.parse(e.data as string);
const stored = await this.state.storage.put(`${Date.now()}`, text); // 持久化
await this.broadcast(JSON.stringify({ who: this.sessions.get(server), text }));
server.send(JSON.stringify({ ack: stored })); // 只回自己
});
server.addEventListener("close", () => this.sessions.delete(server));
return new Response(null, { status: 101, webSocket: client });
}
async broadcast(message: string) {
for (const ws of this.sessions.keys()) {
try { ws.send(message); } catch { this.sessions.delete(ws); }
}
}
}
客戶端
const ws = new WebSocket("wss://your-worker.example.workers.dev/ws?post=12");
ws.onmessage = (ev) => { /* 往留言區 append */ };
ws.send(JSON.stringify({ text: "這篇文章寫得不錯。" }));
新留言的另一路徑(pull 分流):留言板主功能仍是 HTTP POST /api/posts/12/comments(寫進 D1,7.2 的章節路徑)。要讓這條路徑也觸發房間推播:
app.post("/api/posts/:id/comments", async (c) => {
const id = c.req.param("id");
await c.env.DB.prepare("INSERT INTO comments(post,body) VALUES (?,?)").bind(id, body).run();
const room = c.env.ROOM.get(c.env.ROOM.idFromName(`post-${id}`));
await room.fetch("https://do/notify", { method: "POST", body }); // DO 內轉播給所有 ws
return c.json({ ok: true }, 201);
});
小結與練習#
WebSocket 把「問了才有答案」翻轉成「事件一來自己聽」;Durable Objects 把「無狀態的邊緣」補上一塊有狀態的邊界:每個房間全世界只有一個實例。房間邊界(每篇文章一個 DO)同時解決規模與隔離。
練習
- 給
Room加上countClients():收到"ping"型訊息時回覆目前sessions.size,做成全站線上人數顯示。 - 為
post-12房間做離線補看:重連時先撈 D1fetched_at > since的留言。 - 做個小实验:兩個瀏覽器分頁開同一篇,只關掉一個,另一邊會收到
close事件推播?(實作後描述結果。)若不會,補上廣播close訊息的broadcast(JSON.stringify({who, left:true}))。