Article

这个博客是怎么造出来的:正文静态化,动态能力走旁路

这个博客是怎么造出来的:正文静态化,动态能力走旁路

这个博客上线时,我给它定过一条很硬的约束:公开站不能依赖任何运行时服务。

现在回头看,这句话说过了头。

第一篇文章发布不到一周,公开域名下就多了阅读上报和友链申请两个动态入口;文章里的图片从一开始也要经过 /img/proxy/,由服务端换取真正的存储地址。继续把它叫作“完全静态”,多少有点在跟自己抬杠。

真正保留下来的边界更具体:文章页面的生成与交付,不依赖在线的内容服务。 Nginx 可以直接从磁盘返回已经构建好的 HTML。数据库或 Service 临时不可用时,后台编辑、发布、阅读计数和友链申请会受影响,正文仍然在。带图文章的图片可能加载失败,但页面不会因为一次数据库查询失败而消失。

这条边界,才是后面所有设计的起点。

博客整体架构与组件职责
博客整体架构与组件职责

一篇文章要跨过四道边界

仓库里仍然是四个顶层目录,但比目录名更重要的是它们不替彼此做什么:

  • admin/ 负责写作和预览,通过 OpenAPI 生成的客户端访问 Service;
  • service/ 管草稿、修订、媒体和发布状态;
  • site/ 不认识数据库表,只读取一份版本化的 Release Bundle;
  • deploy/ 把构建产物送到 Nginx 的文档根目录。

一篇文章从编辑器走到公开站,要依次经过四步。

第一步是保存草稿。草稿可以反复修改,每次写入都带 lock_version,两个编辑窗口同时打开时,旧版本不能悄悄覆盖新版本。

第二步是冻结修订。冻结会产生一份不再变化的版本,发布只认这个版本,不读取编辑器里的即时内容。后续继续改草稿,也不会改变已经冻结的修订。

第三步是创建 Release。Service 以当前线上状态为底,换入这次准备发布的修订,生成一份完整站点快照。新文章、旧文章、标签和站点设置都在里面,共同回答“这一版网站应该包含什么”。

最后才轮到 Jenkins:下载 Bundle、校验、运行 Astro 构建、检查产物,再同步到服务器。浏览器看到的标题、目录、代码高亮和正文 HTML,都在这里一次性生成。

这条链路看起来绕,换来的是发布结果不依赖操作顺序。同一个 Release 重建多少次,输入都不会被后来改动的草稿污染。

真正隔开三端的是 Bundle

如果 site/ 直接读 Service 的表结构,静态构建只是换了个地方调用数据库,内容和网站并没有真正拆开。

现在两边只通过 Release Bundle 说话。Bundle 有独立的 Schema,包含站点设置、标签、文章、修订和媒体信息;Site 只认这份契约。只要契约不变,Service 内部怎样拆表、怎样组织 Repository,都不该波及页面模板。

这条边界也制造过一个很具体的麻烦。Bundle 的校验和由 Service 生成,Site 会重新计算。Go 的 json.Marshal 会把 <>& 等字符写成 Unicode 转义,Node 的 JSON.stringify 不会。最初的构建器按 Node 的习惯计算,样例数据一直正常,真实文章里出现一个 & 就翻车。

最后修的不是那份文章,也不是在两端各自找一个“差不多”的 JSON,而是让构建器精确复刻 Service 的规范化规则。契约这种东西,一字不差才叫契约。

失败应该停在读者看不见的地方

静态构建的价值不只在速度,它还把不少错误赶到了发布阶段。

正文里的原始 HTML 会被校验拦住,危险协议的链接也不能进入产物。Bundle 到达构建器后还要重新验 Schema 和 sha256;任何一项对不上,Jenkins 直接失败。失败的任务不会推进当前 Release,也不会把文章的已发布修订指向新版本,线上继续保留上一份完整产物。

这比让浏览器临时解释 Markdown 更适合个人博客。读者不该替我发现一段内容为什么渲染失败,更不该替我验证某个输入能不能执行脚本。

动态能力回来,但不能拿走正文

后来的阅读统计和友链申请都需要 Service,这没有推翻静态交付,只是逼着边界变得诚实。

阅读上报走 /pv/。浏览器发送失败时页面安静地放弃,计数也不会写进 Release Bundle,否则一个每分钟变化的数字会破坏“相同内容得到相同快照”这件事。友链列表跟随 Release 构建进静态页面,提交表单才走 /fl/。这两个入口挂了,会少一次计数或暂时不能申请友链,不会影响文章 HTML。

图片更特殊。Markdown 里保存的是稳定的 /img/proxy/{publicKey},避免把会过期的存储签名写死进文章;代价是图片读取仍然依赖 Service 完成一次重定向。这个依赖值得保留,但不能假装它不存在。

公开域名下的 /api/ 至今仍然明确返回 404。动态能力只能通过几个用途单一、请求很小的入口回来,后台会话和管理 API 不跟着暴露到读者这一侧。

现在我会怎样描述这套系统

这不是一个“没有后端的博客”。它有数据库、Redis、管理后台、构建服务和几条公开动态路由,维护起来也绝不比把 Markdown 扔进 Git 仓库省事。

我真正想换掉的是另一种耦合:读者每打开一篇旧文章,内容系统都必须在线回答一次。现在,Service 负责决定下一版网站是什么,Site 负责把这个决定编译成文件,Nginx 负责把文件交出去。发布之前可以复杂,正文交付尽量简单。

第一版文章把这件事写成了“公开站没有动态面”。运行一段时间后,我更愿意把它说成:动态能力可以坏,正文不能跟着一起坏。