持久化房间活动日志

CloudflareBeginner
立即练习

简介

正在运行的 JavaScript 对象可以将值保存在类属性中,但运行时重启、崩溃或从内存中移除不活跃对象后,这些值就会消失。活动日志不能承担这种风险:房间成员希望应用代码重新部署后,昨天的事件仍然可见。

在本实验中,每个经过验证的房间名称对应一个 Durable Object。该对象拥有一个私有 SQLite 数据库,用于保存房间活动事件。入口 Worker 通过 RPC 调用该对象,因此客户端不会直接访问存储。你将停止并重新启动本地运行时,然后重新部署云端 Worker 并建立新连接。在这两种情况下,之前写入的行都必须仍然可用。第二个房间用于证明存储属于单个对象身份,而不是整个命名空间。

你还将比较两种状态:

  • 内存状态保存在 JavaScript 属性中,只适合作为临时缓存。
  • 持久状态会在请求完成前写入对象的存储,并在运行时替换后继续存在。

直接进入本课程前,请先完成将 LabEx 连接到你的 Cloudflare 账户 每个新的 VM 都需要单独完成 Wrangler 授权。你应已在前一个实验中了解 Worker 请求处理程序、Durable Object 名称、绑定和 RPC。基础 SQL 键和有序查询会在相关步骤中解释。

Cloudflare 目前在 Workers Free 计划中支持由 SQLite 支持的 Durable Object。本实验只创建一个临时类命名空间和少量小型命名对象,并且只发送有界请求。安装过程会在 /home/labex/project/room-activity-log 中安装 Node.js 22.22.0 和项目本地的 Wrangler 4.132.0;它不会授权 Cloudflare、创建命名空间、部署 Worker 或写入学习者活动记录。

授权 VM 并配置房间命名空间

在本步骤中,你将为这个新的 VM 完成授权,选择学习账户,并声明一个由 SQLite 支持的 Durable Object 类。Dashboard 登录和 VM 授权是分开的,因为 VM 无法访问你的浏览器会话。

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

cd /home/labex/project/room-activity-log
npx wrangler --version

应显示 4.132.0。开始设备授权:

npx wrangler login --device --browser=false

在浏览器中打开显示的 URL,输入短代码,检查选中的账户和权限,然后完成授权。只有当浏览器和 Wrangler 都报告成功后,才返回终端。不要将密码或令牌粘贴到实验中。

读取结构化身份信息,并私下选择目标账户 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"

第一个 jq 表达式只显示安全的身份字段。第二个表达式将账户 ID 保存到 Shell 变量中,而不是将其打印出来。如果你的专用学习账户使用其他显示名称,请替换为你确认过的名称。

生成唯一的 Worker 名称:

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

创建配置文件。未加引号的 JSON 分隔符会展开 $RUN$ACCOUNT_ID\$schema 则会保留字面量 JSON 键名。

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": "ROOMS", "class_name": "RoomActivity" }
    ]
  },
  "exports": {
    "RoomActivity": { "type": "durable-object", "storage": "sqlite" }
  }
}
JSON

ROOMS 是 Worker 访问该命名空间的句柄。exports 条目告诉 Cloudflare,每个 RoomActivity 对象都使用自己的 SQLite 数据库。此文件还不会创建云端资源;部署将在后续步骤中完成。

将房间事件存储到 SQLite

在本步骤中,你将实现由房间对象拥有的表,以及两个 RPC 方法:一个用于追加事件,另一个用于返回按顺序排列的历史记录。

活动事件包含稳定的文本键、简短的类型、便于阅读的详细信息和服务器时间戳。PRIMARY KEY 约束可防止同一个房间中的两行使用相同的事件 ID。AUTOINCREMENT 会分配单调递增的 sequence,因此读取查询可以保留插入顺序,而不必依赖可能出现相同值的时间戳。

创建 Worker 入口文件:

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

export class RoomActivity extends DurableObject {
  constructor(ctx, env) {
    super(ctx, env);
    ctx.blockConcurrencyWhile(async () => {
      this.ctx.storage.sql.exec(`
        CREATE TABLE IF NOT EXISTS activity_events (
          sequence INTEGER PRIMARY KEY AUTOINCREMENT,
          event_id TEXT NOT NULL UNIQUE,
          event_type TEXT NOT NULL,
          detail TEXT NOT NULL,
          created_at INTEGER NOT NULL
        )
      `);
    });
  }

  appendEvent(event) {
    const createdAt = Date.now();
    return this.ctx.storage.sql.exec(
      `INSERT INTO activity_events (event_id, event_type, detail, created_at)
       VALUES (?, ?, ?, ?)
       RETURNING sequence, event_id AS eventId, event_type AS type, detail, created_at AS createdAt`,
      event.eventId,
      event.type,
      event.detail,
      createdAt
    ).one();
  }

  listEvents() {
    return this.ctx.storage.sql.exec(
      `SELECT sequence, event_id AS eventId, event_type AS type, detail, created_at AS createdAt
       FROM activity_events
       ORDER BY sequence`
    ).toArray();
  }
}

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

function roomRoute(pathname) {
  const match = pathname.match(/^\/rooms\/([^/]+)\/events$/);
  if (!match) return { error: "not_found", status: 404 };
  let room;
  try {
    room = decodeURIComponent(match[1]);
  } catch {
    return { error: "invalid_room_name", status: 400 };
  }
  if (!/^[a-z][a-z0-9-]{0,31}$/.test(room)) {
    return { error: "invalid_room_name", status: 400 };
  }
  return { room };
}

function validEvent(value) {
  return value &&
    /^[a-z][a-z0-9-]{2,31}$/.test(value.eventId) &&
    /^[a-z][a-z0-9_]{2,31}$/.test(value.type) &&
    typeof value.detail === "string" &&
    value.detail.length >= 1 && value.detail.length <= 160;
}

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 = roomRoute(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 room = parsed.room;
    let body;
    if (request.method === "POST") {
      try {
        body = await request.json();
      } catch {
        return json({ error: "invalid_json" }, 400);
      }
      if (!validEvent(body)) return json({ error: "invalid_event" }, 400);
    }

    const stub = env.ROOMS.getByName(room);
    try {
      if (request.method === "POST") {
        const event = await stub.appendEvent(body);
        console.log(JSON.stringify({ event: "room_activity_appended", room, eventId: event.eventId, sequence: event.sequence }));
        return json({ room, event }, 201);
      }
      const events = await stub.listEvents();
      console.log(JSON.stringify({ event: "room_activity_listed", room, count: events.length }));
      return json({ room, events });
    } catch (error) {
      if (String(error).includes("UNIQUE constraint failed")) {
        return json({ error: "duplicate_event_id" }, 409);
      }
      throw error;
    }
  }
};
JS

blockConcurrencyWhile() 只用于创建数据库架构。它会延迟请求,直到表存在,但不会包装普通流量或外部 I/O。重要的应用状态不会只保存在类属性中:appendEvent() 会先将行写入 SQLite,然后再返回该行。

运行提供的确定性 HTTP 路由测试和真实的 Wrangler 打包检查:

NODE_NO_WARNINGS=1 node --experimental-loader ./test/cloudflare-loader.mjs --test test/worker.test.mjs
npx wrangler deploy --dry-run

应看到两个测试通过,并看到 dry run 成功。这些检查不会进行远程部署。

证明重启后的本地持久性

在本步骤中,你将向 planning 房间写入两个事件,完全停止本地 Workers 运行时,使用相同的本地存储目录启动新的运行时,然后再次读取这些行。

Wrangler 通常会将本地绑定数据放在 .wrangler/state 下。本实验使用显式目录 .labex/local-state,以便清楚地看到持久化边界。该目录只代表本地开发数据,与 Cloudflare 存储分开。

启动第一个本地运行时:

npx wrangler dev --port 8787 --persist-to .labex/local-state > .labex/dev.log 2>&1 &
echo $! > .labex/dev.pid
for attempt in $(seq 1 30); do
  curl --silent --fail http://127.0.0.1:8787/health && break
  sleep 1
done

planning 追加两个事件。--data 用于发送 JSON 请求体,而 content-type 标头告诉 Worker 应如何解释该请求体。

curl --silent --request POST http://127.0.0.1:8787/rooms/planning/events \
  --header 'content-type: application/json' \
  --data '{"eventId":"evt-opening","type":"room_opened","detail":"Planning room opened"}' | jq
curl --silent --request POST http://127.0.0.1:8787/rooms/planning/events \
  --header 'content-type: application/json' \
  --data '{"eventId":"evt-notes","type":"note_added","detail":"Release notes drafted"}' | jq

读取该房间,并观察序号 12

curl --silent http://127.0.0.1:8787/rooms/planning/events | jq

现在终止该运行时,并等待其进程结束:

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

使用相同的持久化目录启动新的运行时进程:

npx wrangler dev --port 8787 --persist-to .labex/local-state > .labex/dev-restarted.log 2>&1 &
echo $! > .labex/dev.pid
for attempt in $(seq 1 30); do
  curl --silent --fail http://127.0.0.1:8787/health && break
  sleep 1
done

再次读取 planning,然后读取从未接收过事件的另一个房间:

curl --silent http://127.0.0.1:8787/rooms/planning/events | jq
curl --silent http://127.0.0.1:8787/rooms/support/events | jq

新的运行时会按顺序返回 planning 中的两个事件,而 support 会返回空的 events 数组。重启移除了所有 JavaScript 类实例,但没有移除 SQLite 行。第二个房间为空,说明每个命名对象都拥有自己的私有存储。

部署并写入云端活动记录

在本步骤中,你将停止本地进程,部署类命名空间,并写入一小段云端活动历史。

停止重新启动的本地运行时,避免后续请求与云端响应混淆:

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

部署 Worker,同时保存普通终端输出。tee /dev/tty 会让输出继续显示,而 $(...) 会将输出捕获到 Shell 变量中:

DEPLOY_OUTPUT="$(npx wrangler deploy 2>&1 | tee /dev/tty)"

第一次部署会协调 RoomActivity 导出,并创建由 SQLite 支持的命名空间。提取打印出的 workers.dev URL,不要假定其他学习者使用相同的子域名:

APP_URL="$(printf '%s\n' "$DEPLOY_OUTPUT" | grep -Eo 'https://[a-z0-9.-]+\.workers\.dev' | tail -1)"
test -n "$APP_URL"
printf '%s\n' "$APP_URL"

grep -Eo 只打印匹配的 URL 文本,tail -1 会在其他信息行包含链接时选择最后一个地址。

成功部署后,Worker 代码及其新的 Durable Object 命名空间可能需要几秒钟才能在所有边缘位置可访问。在发送写请求前,等待对仍为空的 support 对象执行读取,并返回预期 JSON:

for attempt in $(seq 1 30); do
  if curl --silent --fail "$APP_URL/rooms/support/events" |
    jq -e '.room == "support" and .events == []' >/dev/null; then
    break
  fi
  sleep 1
done
curl --silent --fail "$APP_URL/rooms/support/events" |
  jq -e '.room == "support" and .events == []'
sleep 5

最后一次读取会明确检查是否已就绪:如果 Durable Object 路由仍未返回有效 JSON,实验会在此停止,而不会将边缘错误页面传给后续命令。短暂的等待窗口还可以避免新协调的命名空间仍在边缘传播时创建第二个命名对象。

将相同的两个逻辑事件写入云端存储。由于本地和远程 Durable Object 数据库是有意分开的环境,因此云端房间最初为空。

curl --silent --request POST "$APP_URL/rooms/planning/events" \
  --header 'content-type: application/json' \
  --data '{"eventId":"evt-opening","type":"room_opened","detail":"Planning room opened"}' | jq
curl --silent --request POST "$APP_URL/rooms/planning/events" \
  --header 'content-type: application/json' \
  --data '{"eventId":"evt-notes","type":"note_added","detail":"Release notes drafted"}' | jq

读取 planning 和未修改的 support 房间:

curl --silent "$APP_URL/rooms/planning/events" | jq
curl --silent "$APP_URL/rooms/support/events" | jq

云端的 planning 对象包含两行,而 support 仍为空。这证明了云端身份和隔离性,接下来可以测试部署替换。

重新部署并检查持久状态

在本步骤中,你将使用相同的 Worker 名称和类声明重新部署,然后通过新的 HTTP 连接读取现有行,并将运行时证据与 Dashboard 联系起来。

再次部署未修改的应用:

npx wrangler deploy

代码部署可能会替换正在运行的 Durable Object 实例,因此会清除类属性。当相同的有效 RoomActivity 导出仍然存在时,部署不会替换命名空间。发起新的请求并读取 planning 历史记录:

curl --silent "$APP_URL/rooms/planning/events" | jq

evt-openingevt-notes 两行必须仍然按序出现。这正是临时内存数组与 SQLite 支持的持久状态之间的关键区别。

打开 Cloudflare Dashboard 并选择相同的账户。进入 Workers & Pages,找到确切的 labex-c10-o02-... Worker,并确认其 Durable Object 绑定名为 ROOMS,目标为 RoomActivity。然后打开 Durable Objects,选择该命名空间并查看其 Overview。命名空间名称用于标识已部署的 Worker 和类,而 Storage: SQL 则确认 wrangler.jsonc 选择的后端。

已部署的 Worker 绑定将 ROOMS 连接到 RoomActivity Durable Object 命名空间

截图展示的是测试运行结果。你生成的后缀会不同,但绑定类型、名称和目标类应与配置一致。

RoomActivity 命名空间概览显示其 SQL 存储后端

Dashboard 可能会在延迟后才汇总命名空间指标,因此 HTTP 响应仍然是两行数据成功保留的权威证据。Overview 用于帮助你确认位置和配置,不能替代运行时读取结果。

打开命名空间的 Logs 视图。成功的 RoomActivity.jsrpc 行表示 Cloudflare 已通过 RPC 调用该类。重复出现的对象 ID 表示对同一个对象的重复调用,其他 ID 则来自另一个房间和验证器运行时生成的唯一房间。这些 ID 是 Cloudflare 自动生成的示例,不是需要复制的房间名称。日志可以证明调用发生;按顺序排列的 HTTP 响应才能证明存储的活动内容。

成功的 RoomActivity RPC 调用会在命名空间日志中显示 Durable Object ID

再次运行独立的已部署检查。它会验证绑定和所属命名空间,读取保留的 planning 行,确认空的 support 房间,并创建一个单独且名称唯一的验证房间:

python3 .labex/verify.py deployed

删除命名空间并撤销 VM 访问权限

在本步骤中,你将删除 Durable Object 命名空间及其所有房间数据库,然后删除剩余的 Worker 并退出登录。

仅删除 Worker 不会显式停用 Durable Object 类。声明式生命周期使用已删除墓碑(deleted tombstone)。它会永久删除该类命名空间,并且没有回收站,因此继续操作前,请确认 $RUNlabex-c10-o02- 开头。

创建无状态的清理入口文件:

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

为完全相同的 Worker 和账户构建清理配置。该配置会移除绑定,并仅将 RoomActivity 标记为已删除:

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": {
    "RoomActivity": { "type": "durable-object", "state": "deleted" }
  }
}
JSON

部署删除墓碑,并检查协调输出:

npx wrangler deploy --config wrangler.cleanup.jsonc

输出应报告 RoomActivity 已删除。这会移除 planningsupport、验证器的临时房间,以及该实验所属命名空间中的所有 SQLite 行。删除剩余的无状态 Worker:

npx wrangler delete --config wrangler.cleanup.jsonc

确认删除的只是准确匹配的生成 Worker。在授权仍然有效时,运行已认证的不存在检查:

python3 .labex/verify.py deleted

只有在输出 PASS: deleted 后,才退出登录并检查结构化的退出登录状态:

npx wrangler logout
npx wrangler whoami --json

最终输出必须报告 loggedIn: false。网络故障不能证明资源已删除或已退出登录。

总结

你构建了一个房间活动服务,其中每个稳定的房间名称对应一个 Durable Object 和一个私有 SQLite 数据库。你创建了带键且有序的事件表,通过 RPC 暴露追加和列表操作,在选择对象前验证请求,并证明第二个房间不会继承其他房间的历史记录。

你还通过在本地运行时重启和云端重新部署后读取相同行,区分了临时 JavaScript 内存与持久存储。最后,你在 Dashboard 中检查了绑定、命名空间、存储行和日志,然后删除了准确匹配的命名空间和 Worker,并撤销了 VM 的授权。