durable-objects Skill:按官方約定寫聊天室、協作和有狀態 RPC

前言

在 Cloudflare Workers 上做無狀態接口很直接:一個 fetch 處理請求,數據丟給 KV、D1 或 R2。一旦業務變成「同一間聊天室裏的人必須看到同一份消息」「兩個用戶不能訂同一時段」「對局裏的分數和回合順序不能各算各的」,這套寫法就會開始打架。普通 Worker 實例之間沒有共享內存,外部存儲又常常是最終一致;要自己做鎖、排隊和會話粘滯,成本不低。

Durable Objects(下面簡稱 DO)就是 Cloudflare 給這類問題準備的原語:每個實例有全局唯一名字、一份跟着實例走的存儲,以及可以掛 WebSocket、鬧鐘和 RPC 的執行上下文。難處在於約定多、接口還在變。getByName() 是 2025 年 8 月才進文檔的;新命名空間已經要求走 SQLite 後端;兼容日期 2024-04-03 之後官方更推薦 RPC,而不是在 DO 上繼續寫 fetch()。AI 編程助手如果只靠訓練數據,很容易寫出「一個全局 DO 扛全部流量」「關鍵狀態只放內存」「每次請求都包一層 blockConcurrencyWhile()」這類反模式。

Cloudflare 官方在 cloudflare/skills 倉庫裏維護了一條專項 Skill,名字就叫 durable-objects。它的作用不是再抄一遍產品介紹,而是在創建、審查、測試 DO 時,把分片方式、存儲、併發、RPC、鬧鐘和 Wrangler 配置約束進 Agent 的工作流,並且要求先查現行文檔,不要死記預訓練知識

這是什麼

durable-objects 是 Cloudflare 官方出品的 Agent Skill,目錄在:

https://github.com/cloudflare/skills/tree/main/skills/durable-objects

YAML 頭裏的定位很直接:創建與審查 Cloudflare Durable Objects;在做有狀態協調(聊天室、多人遊戲、預訂系統)、實現 RPC、SQLite 存儲、鬧鐘、WebSocket,或按最佳實踐審查已有 DO 代碼時使用。覆蓋 Workers 集成、Wrangler 配置,以及用 Vitest 測試。同時寫明:偏向從 Cloudflare 文檔檢索,而不是依賴模型的預訓練知識

它解決的是兩件很具體的事:

  1. Agent 知道「邊緣上要有狀態」,但不知道該按「一個協調單元一個 DO」來建模,還是誤用成全局單例。
  2. DO 的正確寫法分散在配置、存儲、併發和測試裏,訓練數據裏的 idFromName() + fetch() 骨架已經不是官方首選。

同一倉庫裏還有總索引 Skill cloudflare,以及更偏部署的 wrangler。任務已經收斂到「寫 DO / 審 DO / 測 DO」時,應走 durable-objects 這一條。

Skill 目錄同樣是入口加按需參考:

skills/durable-objects/
├── SKILL.md
└── references/
    ├── rules.md      # 分片、存儲、併發、RPC、鬧鐘、WebSocket
    ├── testing.md    # Vitest、單元/集成測試、鬧鐘測試
    └── workers.md    # Worker 調用側、類型、wrangler、可觀測性

正文要求實現功能前先拉官方頁面,而不是把參考文件當最終 API 手冊:

資源 地址
文檔 https://developers.cloudflare.com/durable-objects/
API https://developers.cloudflare.com/durable-objects/api/
最佳實踐 https://developers.cloudflare.com/durable-objects/best-practices/
示例 https://developers.cloudflare.com/durable-objects/examples/

核心約定

Skill 把「什麼時候用 DO」寫成一張對照表,和官方產品頁是對齊的:每個 DO 有全局唯一名字,存儲和計算在一起,因此可以在多個客戶端之間做協調,而不必自建序列化和鎖。

適合用 DO 的需求:

需求 Skill 給的例子
協調 聊天室、多人遊戲、協作文檔
強一致 庫存、預訂、回合制對局
按實體存儲 多租戶 SaaS、按用戶拆數據
長連接 WebSocket、即時通知
按實體定時 訂閱續期、對局超時

明確不要用 DO 的情況:

  • 無狀態請求處理(用普通 Workers)
  • 需要儘量鋪開到全球、而不是粘在同一個實例上
  • 高扇出、彼此獨立的請求

核心規則寫在 SKILL.md 裏,references/rules.md 再展開:

  1. 按協調原子建模:一間聊天室、一局遊戲、一個用戶對應一個 DO,不要一個全局 DO。
  2. getByName() 做確定性路由:同一輸入落到同一實例。Cloudflare 在 2025-08-21 的 changelog 里正式加入該方法,官方入門文檔現在也用它,不必先 idFromName()get()
  3. 用 SQLite 存儲:遷移裏配置 new_sqlite_classes。官方 changelog 也寫明:新 DO 命名空間必須走 SQLite 後端,不再允許新建 KV 後端命名空間。
  4. 只在構造函數里初始化blockConcurrencyWhile() 只用來建表 / 跑 schema,不要套在每次請求上。
  5. 用 RPC 方法,而不是 DO 上的 fetch():兼容日期 >= 2024-04-03。這和 Rules of Durable Objects 一致。
  6. 先持久化,再改內存緩存:實例被驅逐或崩潰時,內存會丟,SQLite 還在。
  7. 每個 DO 只有一個鬧鐘setAlarm() 會覆蓋已有鬧鐘。

對應的反模式,Skill 寫成 NEVER:

  • 單個全局 DO 處理全部請求(瓶頸)
  • 每次請求都 blockConcurrencyWhile()(吞吐被打掉;參考文件按約 5ms 一次估算,上限大約 200 次/秒)
  • 關鍵狀態只放內存
  • 相關的多次存儲寫入之間插入 await(打斷寫合併,不再是一次原子提交)
  • blockConcurrencyWhile() 裏做 fetch() 或其它外部 I/O

安裝與啓用

官方 README 寫明:這套 Skill 面向支持 Agent Skills 標準的助手,包括 Claude Code、Cursor、OpenCode、OpenAI Codex 和 Pi。安裝方式按工具分開,不要混用。durable-objects 在倉庫的 Skills 表裏,和 cloudflarewrangleragents-sdk 並列。

1、用 npx skills(跨工具通用)

安裝整個 Cloudflare Skills 集合:

npx skills add https://github.com/cloudflare/skills

只裝這一條時,officialskills.sh 上的頁面skills.sh 給出的命令是:

npx skills add https://github.com/cloudflare/skills --skill durable-objects

2、Claude Code(插件市場)

/plugin marketplace add cloudflare/skills
/plugin install cloudflare@cloudflare

3、Cursor

倉庫 README 的寫法是:從 Cursor Marketplace 安裝,或在 Settings > Rules > Add Rule > Remote Rule (Github) 裏填 cloudflare/skills。Cloudflare 的 Cursor 接入文檔 另外給出斜槓命令 /add-plugin cloudflare,效果是裝上整套 Cloudflare Skills(其中包含 durable-objects)並註冊 MCP。Marketplace 上也能搜到同名 Skill。

4、克隆後按目錄拷貝

工具 Skill 目錄
Claude Code ~/.claude/skills/
Cursor ~/.cursor/skills/
OpenCode ~/.config/opencode/skills/
OpenAI Codex ~/.codex/skills/
Pi ~/.pi/agent/skills/

拷進去的應是 skills/durable-objects/ 這一整個文件夾,保證 SKILL.mdreferences/ 的相對路徑還在。Agent 一般按描述自動啓用;提示詞裏出現聊天室、預訂、DO 綁定、鬧鐘或「幫我審查這段 Durable Object」時,就會對上這條 Skill。

典型用法

下面幾段都來自官方 SKILL.md 和三份參考文件,用來說明 Agent 加載之後應當怎麼寫,而不是另編一套教程。

1、先讓 Agent 按規則建模,再寫代碼

可以直接把觸發條件寫進提示詞:

請按 durable-objects Skill 幫我做一個按房間隔離的聊天后端。
一個房間一個 DO,用 getByName(roomId) 路由,SQLite 存消息,RPC 發消息。
不要用全局單例 DO,不要把關鍵狀態只放內存。
寫 wrangler 配置和測試前,先對照 Cloudflare Durable Objects 現行文檔。

Skill 自己列的觸發場景還包括:給已有 DO 做最佳實踐審查、配 wrangler.jsonc / wrangler.toml 的綁定和遷移、用 @cloudflare/vitest-pool-workers 寫測試、設計分片和父子 DO 關係。

2、Wrangler 綁定與 SQLite 類

Skill 入口給出的最小配置是:

// wrangler.jsonc
{
  "durable_objects": {
    "bindings": [{ "name": "MY_DO", "class_name": "MyDurableObject" }]
  },
  "migrations": [{ "tag": "v1", "new_sqlite_classes": ["MyDurableObject"] }]
}

references/workers.md 裏更完整的一份會同時寫 compatibility_date(RPC 需要 >= 2024-04-03)和多個綁定。wrangler.toml 的等價寫法是 [[durable_objects.bindings]][[migrations]]

這裏有一處需要按 Skill 自己的「檢索優先」規則處理:官方 Getting started 現在用 exports 聲明 DO 類和 SQLite 存儲,並把舊的 migrations 數組標成 legacy。Skill 倉庫裏的示例仍是 new_sqlite_classes。寫新項目時,應讓 Agent 再拉一次現行文檔,而不是把參考文件裏的字段當唯一正確答案。

Worker 側要用綁定名拿到 stub。Skill 推薦的三種創建方式:

// 確定性路由,大多數場景優先用這個
const stub = env.MY_DO.getByName("room-123");

// 已有 ID 字符串
const id = env.MY_DO.idFromString(storedIdString);
const stub = env.MY_DO.get(id);

// 新的唯一 ID,映射關係要存到外部
const id = env.MY_DO.newUniqueId();
const stub = env.MY_DO.get(id);

對延遲敏感的場景,references/rules.md 還提到創建時可以帶 locationHint(如 wnamenamweurapac)。具體參數以 API 文檔爲準。

3、SQLite + RPC 的基本骨架

Skill 給的最小可運行模式如下。類從 cloudflare:workersDurableObject 繼承;構造函數里用 blockConcurrencyWhile() 建表;對外只暴露 RPC 方法。

import { DurableObject } from "cloudflare:workers";

export interface Env {
  MY_DO: DurableObjectNamespace<MyDurableObject>;
}

export class MyDurableObject extends DurableObject<Env> {
  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env);
    ctx.blockConcurrencyWhile(async () => {
      this.ctx.storage.sql.exec(`
        CREATE TABLE IF NOT EXISTS items (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          data TEXT NOT NULL
        )
      `);
    });
  }

  async addItem(data: string): Promise<number> {
    const result = this.ctx.storage.sql.exec<{ id: number }>(
      "INSERT INTO items (data) VALUES (?) RETURNING id",
      data
    );
    return result.one().id;
  }
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const stub = env.MY_DO.getByName("my-instance");
    const id = await stub.addItem("hello");
    return Response.json({ id });
  },
};

幾點是參考文件反覆強調的:

  • SQL API 是同步的:this.ctx.storage.sql.exec(...),讀多行用 .toArray(),讀一行用 .one()
  • KV 風格的 storage.put / storage.get 仍然可用,但是異步;新代碼優先 SQL。
  • 相關寫入不要在中間 await,讓運行時把它們合併成一次提交。例如轉賬的扣款、入賬、流水三條 sql.exec 應連着寫。
  • Worker 的 fetch 可以保留,用來做 HTTP 入口;DO 類上的業務方法用 RPC。官方入門教程裏的 sayHello() 也是同一模式。

聊天室場景在 rules.md 裏寫成「一個房間一個 stub」:

const stub = env.CHAT_ROOM.getByName(roomId);
const msg = await stub.sendMessage("user-123", "Hello!");

需要層級時,父 DO 只記引用,子 DO 管自己的狀態。例如 GameServer.createMatch() 往自己的表裏插入 matchId,再 this.env.GAME_MATCH.getByName(matchId) 去初始化子對象。

Schema 演進不要用 PRAGMA user_version,DO 的 SQLite 不支持。參考文件給出的做法是自建 _sql_schema_migrations 表,在構造函數的 blockConcurrencyWhile() 裏按版本往前遷。生產項目可以再看 durable-utils 或 Cloudflare Actors 裏的同類工具,這是 Skill 原文點名的參考實現,不是這條 Skill 自帶的代碼。

4、鬧鐘、WebSocket 和測試

每個 DO 只能掛一個鬧鐘,適合「這個房間 / 這個租戶到期了再醒過來」:

await this.ctx.storage.setAlarm(Date.now() + 60_000);

async alarm(): Promise<void> {
  // 處理到期任務;若還有後續,再 setAlarm
}

await this.ctx.storage.deleteAlarm();

失敗會自動重試,所以 handler 要做成冪等。WebSocket 走 Hibernation API:this.ctx.acceptWebSocket(...),再實現 webSocketMessage / webSocketClose,廣播時遍歷 getWebSockets()

測試用 @cloudflare/vitest-pool-workers,在 Workers 運行時裏測,而不是拿普通 Node 測試去模擬 DO。Skill 的 testing.md 當前寫的安裝命令是:

npm i -D vitest@~3.2.0 @cloudflare/vitest-pool-workers

版本號以該參考文件和 npm 現行版本爲準,不要當成永久鎖定。最小用例如下,直接對 stub 調 RPC:

import { env } from "cloudflare:test";
import { describe, it, expect } from "vitest";

describe("MyDO", () => {
  it("should work", async () => {
    const stub = env.MY_DO.getByName("test");
    const result = await stub.addItem("test");
    expect(result).toBe(1);
  });
});

同一份參考還覆蓋了:用 SELF.fetch 做 HTTP 集成測試、用 runInDurableObject() 看實例內部存儲、用 runDurableObjectAlarm() 立刻觸發鬧鐘、用 listDurableObjectIds() 列出命名空間裏的 ID。每個測試的存儲是隔離的,前一個用例創建的 DO 不會漏到下一個。跑測試:

npx vitest        # watch
npx vitest run    # 單次

適用場景與注意事項

比較適合這條 Skill 的情況:

  1. 要在 Workers 上做聊天室、多人協作、預訂/庫存、按租戶 SQLite、或帶共享狀態的 WebSocket。
  2. 已有 DO 代碼,想按官方規則查分片、併發和持久化問題。
  3. 需要同時寫出 wrangler 綁定、RPC 方法和 Vitest。
  4. 助手經常把 DO 寫成「全局單例 + 內存狀態 + fetch 路由」,希望它改成現行約定。

不太適合、或需要換 Skill 的情況:

  1. 接口完全無狀態,用普通 Workers(或同倉的 cloudflare / workers-best-practices)即可。
  2. 任務是部署、綁定排錯、KV/R2/D1 資源管理,用 wrangler 更直接。
  3. 目標是用 Agents SDK 做有狀態 AI Agent,用同倉的 agents-sdk
  4. 這條 Skill 是給編碼助手的工作流,不會替你開通 Cloudflare 賬號,也不包含計費決策。

使用時幾條已經能交叉覈實的坑,值得寫進提示詞:

  • 不要一個全局 DOgetByName("global") 會把所有協調壓到單實例上。
  • blockConcurrencyWhile() 只用於初始化。參考文件寫明:不要在每次請求上用,也不要在持有它的時候做外部 I/O。
  • 相關寫入之間不要 await,否則寫合併被拆開,轉賬一類操作會丟原子性。
  • fetch() 等非存儲 I/O 會放開交錯。從存儲讀出「pending」、打外部 API、再寫回「completed」之間,別的請求可以插進來;需要樂觀鎖或 transaction()
  • 未捕獲異常可能幹掉當前 DO 實例。內存狀態丟失,SQLite 仍在。
  • 鬧鐘會覆蓋、失敗會重試,handler 必須冪等。
  • 配置字段以現行文檔爲準。Skill 示例仍是 migrations + new_sqlite_classes;入門文檔已推薦 exports"type": "durable-object""storage": "sqlite"
  • 身份怎麼拿到,也要再核一次rules.md 仍建議 DO 不知道自己的 ID,用顯式 init() 傳入;changelog 後來寫明,經 idFromName() / getByName() 訪問時,對象內部可以通過 ctx.id.name 拿到同一個名字。Skill 自己的規則是:和文檔衝突時信文檔。

小結

durable-objects 這條 Skill 的價值,不在於把 Durable Objects 文檔再複製一遍,而在於給 Agent 一條可執行的約束:按協調原子分片、用 getByName() 路由、用 SQLite 持久化、用 RPC 而不是 DO 上的 fetch()、只用 blockConcurrencyWhile() 做初始化,並用 Vitest 在 Workers 運行時裏驗證。Cloudflare 把有狀態邊緣計算收成一種原語之後,真正容易寫錯的是模型邊界和併發,而不是「能不能 new 一個類」。

官方目錄:https://github.com/cloudflare/skills/tree/main/skills/durable-objects

倉庫說明與各工具安裝方式:https://github.com/cloudflare/skills

產品文檔:https://developers.cloudflare.com/durable-objects/

羽毛球分组比赛记分
小程序二维码

欢迎使用《羽毛球分组比赛记分》微信小程序

小夜