将请求路由到命名计数器

CloudflareBeginner
立即练习

简介

普通的 Cloudflare Worker 可以处理许多请求,但某个请求不能假定下一个请求仍会到达同一个正在运行的 JavaScript 实例。这种无状态设计非常适合彼此独立的任务。当多个请求必须共同维护一个变化中的值时,例如支持队列中的等待人数,这种设计就不太方便了。

Durable Object 为应用提供了一个可寻址的协调单元。在本实验中,每个计数器名称都会选择一个不同的对象。对 support 的请求会反复到达同一个逻辑计数器,而对 billing 的请求会到达另一个拥有独立状态的计数器。Cloudflare 可以移动或重启底层运行时,但稳定的对象标识和基于 SQLite 的状态仍然构成应用的契约。

你将连接以下四个概念:

  1. 类(class) 定义单个计数器对象可以执行的操作。
  2. 命名空间(namespace) 是由该类提供支持的所有对象组成的集合。
  3. 绑定(binding) 让入口 Worker 可以访问这个命名空间。
  4. getByName() 将同一个经过验证的名称转换为同一个对象引用,而 RPC 方法 会调用该对象中的代码。

你将构建应用,在本地验证基于名称的路由,将应用部署到自己的 Cloudflare 学习账号,把终端中的证据与 Dashboard 对照起来,并在完成后删除类命名空间和 Worker。

开始本课程前,请完成将 LabEx 连接到你的 Cloudflare 账号该实验会介绍 LabEx VM 终端、Wrangler 设备授权、账号确认和账号 ID 配置。你应该已经了解小型 JavaScript Worker 如何处理 HTTP 请求。本实验不要求你事先了解 Durable Objects。

目前,官方文档已说明 Workers Free 支持基于 SQLite 的 Durable Objects。本实验只会创建一个临时的类命名空间、少量小型对象以及数量受限的请求,不需要 Workers Paid。初始化过程会在 /home/labex/project/named-counters 中安装 Node.js 22.22.0 和项目本地的 Wrangler 4.132.0;它不会登录、创建云端资源、部署代码或完成学习者需要实现的部分。

授权 VM 并命名应用

在本步骤中,你会将这个全新的 LabEx VM 连接到 Cloudflare 学习账号,并创建唯一的应用配置。在浏览器中登录 Dashboard,并不会自动授权新 VM 中执行的命令。

进入准备好的项目目录,并确认固定的 Wrangler 版本:

cd /home/labex/project/named-counters
npx wrangler --version

预期输出为 4.132.0。启动 Wrangler 的设备授权流程:

npx wrangler login --device --browser=false

Wrangler 会输出一个 URL 和一段较短的设备代码。在浏览器中打开该 URL,输入代码,确认选中的账号是你的专用学习账号,并在授权前检查请求的权限。由于 Wrangler 必须在你返回终端后继续工作,权限列表中可能会出现后台访问权限。不要通过终端发送密码或令牌。

浏览器报告成功后,返回终端并等待 Wrangler 完成。请求结构化的账号信息:

npx wrangler whoami --json

确认 loggedIn: true,然后识别目标账号,即使只显示了一个账号也要执行此确认。账号名称用于人工核对;账号 ID 是稳定的配置值,不需要在终端中打印出来。

保存结构化结果,只显示不敏感的账号名称,并选择 LabEx Learning 对应的 ID:

WHOAMI="$(npx wrangler whoami --json)"
printf '%s\n' "$WHOAMI" | jq '{loggedIn, authType, accounts: [.accounts[] | {name}]}'
ACCOUNT_ID="$(printf '%s\n' "$WHOAMI" | jq -r '.accounts[] | select(.name == "LabEx Learning") | .id')"
test -n "$ACCOUNT_ID"

$(...) 会将命令输出捕获到 Shell 变量中。jq 先只显示账号名称供你确认,然后私下选出对应的 ID。只有选中的值非空时,test -n 才会成功。如果你的专用学习账号使用其他显示名称,请先确认该名称,再将选择表达式中的 LabEx Learning 替换为实际名称。

生成唯一的 Worker 名称。openssl rand -hex 6 会生成 12 个随机十六进制字符,$(...) 会将它们插入 Shell 变量:

RUN="labex-c10-o01-$(openssl rand -hex 6)"
printf '%s\n' "$RUN"

创建 wrangler.jsonc配置文件会告诉 Wrangler 要部署哪些代码,以及运行时应附加哪些 Cloudflare 能力。未加引号的 JSON 标记允许 $RUN$ACCOUNT_ID 展开,而反斜杠会保留 $schema 键的字面形式。

cat > wrangler.jsonc <<JSON
{
  "\$schema": "./node_modules/wrangler/config-schema.json",
  "name": "$RUN",
  "account_id": "$ACCOUNT_ID",
  "main": "src/index.js",
  "compatibility_date": "2026-09-18",
  "workers_dev": true,
  "preview_urls": false,
  "observability": {
    "enabled": true,
    "head_sampling_rate": 1
  },
  "durable_objects": {
    "bindings": [
      { "name": "COUNTERS", "class_name": "Counter" }
    ]
  },
  "exports": {
    "Counter": { "type": "durable-object", "storage": "sqlite" }
  }
}
JSON

这个文件描述应用,但此时还不会在 Cloudflare 中创建任何资源。observability 会保留请求日志和应用日志,供后面的 Dashboard 检查使用。Durable Object 相关字段会在下一步发挥作用。

连接命名空间、绑定和类

在本步骤中,你会把 Durable Objects 配置理解为请求如何到达某个有状态对象的映射,然后生成运行时类型,让代码可以访问该绑定。

Durable Object 类是单个对象的 JavaScript 模板。稍后编写的 Counter 类会定义递增和读取值等操作。

命名空间是由该类提供支持的所有对象组成的集合。一个命名空间可以包含 supportbilling 以及许多其他命名计数器。命名空间并不表示这些计数器共享同一个值;每个稳定的对象标识都拥有独立的存储。

绑定是入口 Worker 用来访问命名空间的名称。此配置会将名称 COUNTERS 绑定到 Counter 类。因此,代码会使用 env.COUNTERS

exports 条目声明该类当前的生命周期状态。它会告诉 Cloudflare,在首次部署时使用 SQLite 存储后端创建 Counter。SQLite 是新类推荐使用的后端,并且 Workers Free 可用。本实验中的小型表只会在每个对象内存储一个整数。

根据配置生成类型描述:

npx wrangler types

在生成的文件中搜索 COUNTERS

grep -n 'COUNTERS' worker-configuration.d.ts

该行应类似于:

COUNTERS: DurableObjectNamespace<import("./src/index").Counter>;

生成文件周围的具体文本可能会变化,但以下三点很重要:绑定名称是 COUNTERS,它是一个 DurableObjectNamespace,并且指向导出的 Counter 类。每当绑定发生变化,都要重新生成类型,避免配置与代码悄悄偏离。

构建命名计数器

在本步骤中,你会实现 Counter 类和入口 Worker,将经过验证的 URL 名称路由到一个对象。

每个 Durable Object 都有自己的私有存储。构造函数会创建一个名为 counter_state 的单行表,并且只在该行不存在时插入初始值。blockConcurrencyWhile() 会延迟对象请求,直到这段简短的初始化完成。它适合用于设置数据库结构;不要让它包裹每个请求或外部网络操作。

公开的 increment()getCount() 方法是 RPC 方法。RPC 是 remote procedure call(远程过程调用)的缩写,它允许 Worker 像调用异步 JavaScript 对象一样调用 Durable Object stub 上的方法。Cloudflare 会将调用传递给选中的对象。

创建 Worker 入口文件:

cat > src/index.js <<'JS'
import { DurableObject } from "cloudflare:workers";

export class Counter extends DurableObject {
  constructor(ctx, env) {
    super(ctx, env);
    ctx.blockConcurrencyWhile(async () => {
      this.ctx.storage.sql.exec(`
        CREATE TABLE IF NOT EXISTS counter_state (
          key INTEGER PRIMARY KEY CHECK (key = 1),
          value INTEGER NOT NULL
        )
      `);
      this.ctx.storage.sql.exec(
        "INSERT OR IGNORE INTO counter_state (key, value) VALUES (1, 0)"
      );
    });
  }

  increment() {
    return this.ctx.storage.sql
      .exec("UPDATE counter_state SET value = value + 1 WHERE key = 1 RETURNING value")
      .one().value;
  }

  getCount() {
    return this.ctx.storage.sql
      .exec("SELECT value FROM counter_state WHERE key = 1")
      .one().value;
  }
}

function json(data, status = 200) {
  return Response.json(data, { status });
}

function counterName(pathname) {
  const match = pathname.match(/^\/counters\/([^/]+)$/);
  if (!match) return { error: "not_found", status: 404 };

  let name;
  try {
    name = decodeURIComponent(match[1]);
  } catch {
    return { error: "invalid_counter_name", status: 400 };
  }

  if (!/^[a-z][a-z0-9-]{0,31}$/.test(name)) {
    return { error: "invalid_counter_name", status: 400 };
  }
  return { name };
}

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    if (request.method === "GET" && url.pathname === "/health") {
      return json({ status: "ok" });
    }

    const parsed = counterName(url.pathname);
    if (parsed.error) return json({ error: parsed.error }, parsed.status);
    if (request.method !== "GET" && request.method !== "POST") {
      return json({ error: "method_not_allowed" }, 405);
    }

    const name = parsed.name;
    const stub = env.COUNTERS.getByName(name);
    const count = request.method === "POST"
      ? await stub.increment()
      : await stub.getCount();

    console.log(JSON.stringify({
      event: request.method === "POST" ? "counter_incremented" : "counter_read",
      name,
      count
    }));
    return json({ name, count });
  }
};
JS

路由语句 getByName(name) 是标识边界。同一个经过验证的文本会确定性地选择同一个逻辑对象;不同文本会选择另一个对象。stub 只是一个引用。只有当 RPC 调用实际到达对象时,才会延迟创建该对象。

运行随附的确定性测试。这些测试使用小型命名空间 fixture,因此不会发起云端请求:

NODE_NO_WARNINGS=1 node --experimental-loader ./test/cloudflare-loader.mjs --test test/worker.test.mjs

这个小型 loader 只为 cloudflare:workers 基类提供本地替代实现,以便 Node 可以导入该模块;命名空间 fixture 仍会控制所有被测试的调用,不会联系任何 Cloudflare API。预期通过 3 个测试。然后让 Wrangler 构建 Worker,但不要部署:

npx wrangler deploy --dry-run

测试会验证 HTTP 路由契约,dry run 会验证 Wrangler 能够打包真正的 Durable Object 类。这两个操作都不会创建远程命名空间。

在本地验证稳定名称

在本步骤中,你会在本地 Workers 运行时中运行应用,并使用两个名称观察路由规则,然后再创建云端资源。

在后台以端口 8787 启动 Wrangler。> 会保存日志,2>&1 会将错误输出与普通输出合并,& 会立即返回终端提示符。$! 是刚刚启动的命令的进程 ID。

npx wrangler dev --port 8787 > .labex/dev.log 2>&1 &
echo $! > .labex/dev.pid

等待健康检查路由响应。循环每秒尝试一次,Worker 响应后立即停止:

for attempt in $(seq 1 30); do
  if curl --silent --fail http://127.0.0.1:8787/health; then
    break
  fi
  sleep 1
done

预期输出为 {"status":"ok"}。将 support 计数器递增两次:

curl --silent --request POST http://127.0.0.1:8787/counters/support | jq
curl --silent --request POST http://127.0.0.1:8787/counters/support | jq

响应会显示 support1 变为 2

{
  "name": "support",
  "count": 2
}

现在将 billing 递增一次:

curl --silent --request POST http://127.0.0.1:8787/counters/billing | jq

它的计数为 1,而不是 3。命名空间是一个集合,而每个名称会在该集合中选择一个彼此隔离的对象。

读取两个对象,但不要修改它们:

curl --silent http://127.0.0.1:8787/counters/support | jq
curl --silent http://127.0.0.1:8787/counters/billing | jq

计数仍然是 21。最后,验证无效输入会在 getByName() 选择对象之前被拒绝:

curl --silent --request POST --write-out '\nHTTP %{http_code}\n' \
  http://127.0.0.1:8787/counters/Not_Allowed

预期输出为 {"error":"invalid_counter_name"},HTTP 状态码为 400。下划线和大写字母不符合规定的命名规则。

部署并检查命名空间

在本步骤中,你会停止本地运行时,将同一个应用部署到 Cloudflare,并把 API 行为与 Dashboard 中显示的命名空间、绑定、指标和日志对应起来。

只停止你保存过进程 ID 的开发进程:

kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true

部署 Worker 及其声明的基于 SQLite 的 Counter 类:

npx wrangler deploy

Wrangler 会输出一个公开的 workers.dev URL 和类协调结果。将示例值替换为实际 URL,并保存该 URL:

WORKER_URL="https://YOUR_WORKER_URL"

边缘路由可能需要短暂时间才能就绪。只轮询健康检查路由,因为它不会访问 Durable Object:

for attempt in $(seq 1 30); do
  if curl --silent --fail "$WORKER_URL/health"; then
    break
  fi
  sleep 2
done

support 发起两次请求,对 billing 发起一次请求:

curl --silent --request POST "$WORKER_URL/counters/support" | jq
curl --silent --request POST "$WORKER_URL/counters/support" | jq
curl --silent --request POST "$WORKER_URL/counters/billing" | jq

读取计数值:

curl --silent "$WORKER_URL/counters/support" | jq
curl --silent "$WORKER_URL/counters/billing" | jq

远程应用必须显示与本地运行时相同的标识契约:support2billing1

在 Cloudflare Dashboard 中打开 Workers & Pages。你的唯一 Worker 会出现在应用列表中。下面截图中的 Worker 名称、时间戳和账号级使用量总计只是测试运行中的示例;请在自己的终端中查找生成的 labex-c10-o01-... 名称。

Workers 和 Pages 应用列表中的已部署实验 Worker

打开 Cloudflare Dashboard,然后转到 Workers & Pages → Overview → 你的 labex-c10-o01-... Worker → Settings → Bindings。找到名为 COUNTERS 的 Durable Object 绑定及其 Counter 类。Worker 知道绑定名称,而 Cloudflare 会将它连接到类导出中声明的命名空间。

绑定关系图应显示 Worker 通过 COUNTERS 连接到 Durable Object。截图中的 Worker 名称和命名空间名称属于特定运行示例;重要的是绑定名称及其连接关系。

连接到 Worker 的 COUNTERS Durable Object 绑定

接下来,从 Developer Platform 导航中打开 Durable Objects。选择由你的临时 Worker 所拥有的命名空间。确认它使用 SQLite 存储,并且类为 Counter。命名空间是类级别的集合;supportbilling 这些名称标识该集合中的对象。

命名空间概览会显示 Storage: SQL。其中的命名空间名称和 ID 属于本次临时测试运行,因此你的值会不同。

显示 SQL 存储的 Counter 命名空间概览

打开该命名空间的 Metrics 视图。最近的请求可能需要一段时间才会显示,因此暂时为空的图表不能说明请求失败。不要通过大量循环请求来强行生成图表。

示例命名空间截图仍显示最近调用次数为 0,尽管运行时请求已经成功。这说明 Dashboard 指标会延迟显示,因此只能作为辅助信息,不能作为功能检查的权威依据。

返回 Worker 并打开 Observability → Logs。找到最近的 counter_incrementedcounter_read 事件。结构化日志包含生成的计数器名称和计数值,但不包含账号标识符或凭据。将它与上面某个受限请求对应起来。

展开一个匹配的事件。在测试运行中,验证器生成的名称最终计数为 2,事件图表显示请求成功且错误数为 0。你的生成名称和总数会不同。

包含名称和计数值的结构化 counter_read 事件

Dashboard 中的 Worker 名称、对象 ID、时间戳和请求数量等值都取决于你的运行结果。CLI/API/运行时检查仍然是权威证据;Dashboard 视图用于帮助你了解这些关系在何处显示。

删除命名空间并退出登录

在本步骤中,你会主动停用 Counter 类,删除其命名空间和存储数据,移除 Worker,最后撤销此 VM 的 Wrangler 会话。

仅删除 Worker 脚本,并不能明确表示应该删除已存储的 Durable Object 数据。exports 生命周期使用 deleted tombstone(已删除墓碑):一种短期配置条目,用于告诉 Cloudflare 永久删除某个类命名空间。此操作没有回收站,因此请确认类名称和 Worker 名称确实属于本实验。

创建一个不导出 Counter 的最小清理入口文件:

cat > src/cleanup.js <<'JS'
export default {
  fetch() {
    return Response.json({ status: "cleanup" }, { status: 410 });
  }
};
JS

创建清理配置。它保留相同的 Worker 名称和账号,移除绑定,并且只将 Counter 标记为已删除:

ACCOUNT_ID="$(node -e 'console.log(JSON.parse(require("fs").readFileSync("wrangler.jsonc", "utf8")).account_id)')"
cat > wrangler.cleanup.jsonc <<JSON
{
  "\$schema": "./node_modules/wrangler/config-schema.json",
  "name": "$RUN",
  "account_id": "$ACCOUNT_ID",
  "main": "src/cleanup.js",
  "compatibility_date": "2026-09-18",
  "workers_dev": true,
  "preview_urls": false,
  "exports": {
    "Counter": { "type": "durable-object", "state": "deleted" }
  }
}
JSON

部署墓碑配置:

npx wrangler deploy --config wrangler.cleanup.jsonc

仔细阅读 Wrangler 的协调输出。它应报告 Counter 已被删除。这会永久移除该类命名空间,以及 supportbilling 和独立验证器所存储的少量值。

现在删除剩余的无状态清理 Worker:

npx wrangler delete --config wrangler.cleanup.jsonc

确认只操作准确的 labex-c10-o01-... 应用。在 Dashboard 中检查该准确的 Worker 已不存在,并且它所拥有的命名空间也不再显示。历史指标或日志可能会暂时保留,但它们不是活动资源。

在删除授权前,运行已认证的删除检查:

python3 .labex/verify.py deleted

只有在它输出 PASS: deleted 后,才能退出登录:

npx wrangler logout
npx wrangler whoami --json

最终输出必须明确报告 loggedIn: false。网络错误不能证明你已经退出登录。

总结

你构建并运行了第一个 Durable Objects 应用。你了解到:类定义单个对象的行为,命名空间组织该类的对象,绑定将命名空间暴露给 Worker,而 getByName() 会确定性地选择一个逻辑对象。RPC 方法修改并读取基于 SQLite 的状态,相同名称共享计数,不同名称保持隔离,无效名称会在选择对象前被拒绝。

你还将运行时行为与 Cloudflare Dashboard 连接起来,然后使用声明式类墓碑删除命名空间及其数据,最后删除 Worker 并退出登录。下一实验将在这个标识模型上继续,将 SQLite 作为活动日志,并演示持久化存储为何不同于临时的内存状态。