Cloudflare Workers、D1 与 R2:一套轻量内容系统的取舍
小系统真正稀缺的,不是功能,而是清晰的边界。
个人博客看起来很简单:写文章、传图片、把页面展示出来。可一旦把“以后还要继续用”算进去,问题就变成了另一件事——数据放在哪里,公开读取与后台写入如何隔离,部署失败时能否快速回退,以及一年后自己是否还看得懂。
这套博客最后选择了 Workers、D1、R2 和 Service Binding。不是因为组件越多越好,而是它们恰好对应四个互不混淆的责任。
先把数据流画清楚
公开请求只经过 Blog Worker。它负责页面渲染、SEO、RSS、搜索索引和缓存,但不具备管理文章的能力。
后台写作发生在 CMS Worker:
- Cloudflare Access 确认操作者身份;
- CMS 校验输入并渲染 Markdown;
- 文章与版本进入 D1;
- 图片原文件进入 R2;
- 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 直接查询可写数据库接口
- 不把图片二进制塞进关系表
工程设计经常被误解为“增加正确的东西”。对个人项目来说,更重要的往往是持续删除那些需要长期照看的东西。
最后看维护成本
这套结构并不适合所有内容系统。它没有复杂的审核流、协同编辑和插件市场,却非常适合一个人长期写作:资源数量少,计费边界清楚,数据可以导出,前后端也能分别演进。
当一个系统安静到几乎感觉不到它的存在,写作者才有机会把注意力重新放回文字本身。