Cloudflare Workers、D1 与 R2:一套轻量内容系统的取舍

小系统真正稀缺的,不是功能,而是清晰的边界。

个人博客看起来很简单:写文章、传图片、把页面展示出来。可一旦把“以后还要继续用”算进去,问题就变成了另一件事——数据放在哪里,公开读取与后台写入如何隔离,部署失败时能否快速回退,以及一年后自己是否还看得懂。

这套博客最后选择了 Workers、D1、R2 和 Service Binding。不是因为组件越多越好,而是它们恰好对应四个互不混淆的责任。

先把数据流画清楚

公开请求只经过 Blog Worker。它负责页面渲染、SEO、RSS、搜索索引和缓存,但不具备管理文章的能力。

后台写作发生在 CMS Worker:

  1. Cloudflare Access 确认操作者身份;
  2. CMS 校验输入并渲染 Markdown;
  3. 文章与版本进入 D1;
  4. 图片原文件进入 R2;
  5. Blog 通过同账户 Service Binding 读取已经发布的内容。

这样做的直接收益是:公开站点即使面对大量请求,也不会把管理 API 一同暴露出去。

三种能力,各自只做一件事

能力 负责什么 不负责什么
Workers 请求处理、鉴权、渲染 长期保存业务数据
D1 文章、标签、版本和设置 大文件与图片分发
R2 原始媒体对象 关系查询与发布状态

这张表看似朴素,却能阻止很多“顺手放进去”的决定。边界一旦稳定,代码通常也会跟着变短。

一个很小的读取接口

Blog 不需要知道 CMS 的路由、Access Cookie 或数据库结构。它只依赖一个内部读取入口:

export class ContentService extends WorkerEntrypoint<Env> {
  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);

    if (url.pathname === "/posts") {
      return Response.json(await listPublishedPosts(this.env.DB));
    }

    return Response.json({ error: "Not found" }, { status: 404 });
  }
}

这层接口的价值不在于“少写一次 HTTP 地址”,而在于明确表达:这是账户内部的能力,不是面向互联网的公共 API。

缓存不是越久越好

文章站点很适合缓存,但发布体验也需要可预测。这里选择了一分钟的新鲜期,并允许边缘节点在后台更新:

  • 正常访问优先返回缓存内容;
  • 缓存过期后可以边返回、边刷新;
  • 上游短暂失败时继续提供旧页面;
  • 预览链接始终使用 private, no-store。

一分钟并不是某个神奇数字。它只是一个容易理解的承诺:发布后稍等片刻,所有读者都会看到新版本。

被刻意放弃的复杂度

  • 不引入独立数据库服务器
  • 不在浏览器中保存管理密钥
  • 不为个人站点建设多租户权限系统
  • 不让 Blog 直接查询可写数据库接口
  • 不把图片二进制塞进关系表

工程设计经常被误解为“增加正确的东西”。对个人项目来说,更重要的往往是持续删除那些需要长期照看的东西。

最后看维护成本

这套结构并不适合所有内容系统。它没有复杂的审核流、协同编辑和插件市场,却非常适合一个人长期写作:资源数量少,计费边界清楚,数据可以导出,前后端也能分别演进。

当一个系统安静到几乎感觉不到它的存在,写作者才有机会把注意力重新放回文字本身。