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/

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

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

小夜