这个博客是怎么做的:没有数据库,GitHub 就是数据库

Joe NG
3792 字
10 分钟
–

这个博客是怎么做的:没有数据库,GitHub 就是数据库

发布于

一般博客的数据流是「后台写进数据库,前台从数据库读」。这个站没有数据库——文章就是 repo 里的 Markdown 档,后台是一个把文件 commit 回 GitHub 的客户端。

这个决定听起来像偷懒,但它把备份、版本控制、脱机编辑、回滚这几件事一次解决了:git log 就是完整的修改史,git revert 就是回滚。代价是写入延迟从毫秒变成一次 CI 建置。

这篇记录几个真正花掉时间的设计,还有三个只有在真的部署过才会遇到的坑。

存一篇文章会发生什么事

flowchart TD
    A["後台按下儲存"] --> B["Worker 收到 PUT /api/admin/posts/..."]
    B --> C{"sha 跟遠端一致?"}
    C -- 否 --> D["回 409:這篇在別處被改過"]
    C -- 是 --> E["GitHub Contents API commit 到 main"]
    E --> F["push 觸發 GitHub Actions"]
    F --> G["frontmatter:check"]
    G -- 失敗 --> H["部署中止"]
    G -- 通過 --> I["astro build"]
    I --> J["wrangler deploy"]
    J --> K["Cloudflare 邊緣節點"]

整个后端是 13 个 TypeScript 档、约 1800 行,跑在一个 Cloudflare Worker 上。后台前端是 1450 行的原生 JavaScript,没有框架、没有打包步骤——它就放在 public/admin/,跟着静态资源一起被送出去。

Worker 的分派逻辑只有一句话值得看:

if (!url.pathname.startsWith('/api/')) {
  return env.ASSETS.fetch(request);
}

不是 API 的,全部交给静态资源。所以整个网站的阅读路径上没有任何我写的代码在跑,缓存和分发都是 Cloudflare 的事。Worker 只在后台操作和浏览计数的时候醒过来。

并发:后台不会默默盖掉你

我常常两个分页都开着后台。GitHub 的 Contents API 刚好提供了解法——每个文件都有 sha,更新时把你读到的那个 sha 一起送上去:

const current = await getFile(ref, path);
if (payload.sha && payload.sha !== current.sha) {
  throw new HttpError(409, '這篇文章在別處被改過了,請重新載入後再儲存,以免覆蓋別人的修改。');
}

这是乐观锁,而且我不用自己实作——版本戳是 GitHub 本来就给的。

CI 那边还有一层:

concurrency:
  group: deploy-main
  cancel-in-progress: true

连续存三次,前两次的部署会被取消,只有最后一次真的上线。没有这行的话,三个 build 会互相打架,最后上线的不保证是最新的那份。

同一个串行化器,我写了两次

这是整个项目里最反直觉的决定。

scripts/normalize-frontmatter.mjs 是建置期的 Node 脚本,会把每篇文章的 frontmatter 用固定格式重新串行化。worker/lib/frontmatter.ts 是 Worker 里的 TypeScript,后台存盘时用它产生文件内容。

两者的输出必须逐字节相同。 文件里直接写了这件事:

/**
 * The serialized output intentionally matches `scripts/normalize-frontmatter.mjs`
 * byte for byte, so posts written from the admin keep `npm run frontmatter:check`
 * green and never show up as spurious diffs.
 */

为什么不共用一份?因为两边的运行环境不一样:建置脚本可以 require('yaml') 这种完整的解析器,Worker 要跑在边缘节点上,我不想为了读七个字段的扁平 YAML 背一个解析器进去。所以 Worker 那份是刻意写小的,只认这个 collection 允许的形状,遇到别的就直接拒绝而不是猜。

代价很明确:改串行化格式时要记得改两个地方。但这个代价是看得见的(CI 会红),而共用一份的代价是把一个完整的 YAML 解析器塞进每一个请求的冷启动时间里。

CI 是契约的执法者

frontmatter 的规则本身是 zod schema:

if (!data.draft && data.categories.length !== 1) {
	context.addIssue({
		code: 'custom',
		path: ['categories'],
		message: '正式文章必須且只能有一個分類',
	});
}

草稿可以没有分类和标签,正式文章不行——写到一半的东西不该被还没想好的分类卡住。

但真正在把关的是那个 normalize 脚本。它不只验证,它重新产生整个文件,然后跟原档比对:

if (source.replace(/^\uFEFF/, '') !== nextSource) {
  report.changedFiles.push(relativePath);
}

差一个字节就算失败。所以「字段顺序」「引号风格」「数组缩进」这些平常靠自律的事情,全部变成 CI 会挡的硬规则。

里面还有一段我觉得最值得抄的设计——重新串行化之后,它会确认内文一个字都没变:

const currentBodyHash = hash(frontmatter.body);
const nextBody = findFrontmatter(nextSource, relativePath).body;

if (hash(nextBody) !== currentBodyHash) {
  throw new Error(`${relativePath}: body hash changed`);
}

一个「自动修 frontmatter」的工具,最可怕的失败不是修错 frontmatter,是把正文吃掉。这个哈希比对让那件事变成不可能发生,而不是「应该不会发生」。

三个真的部署过才会遇到的坑

一、Astro 会把 slug 转小写,而 macOS 帮你把问题藏起来

文件名叫 GoogleSheet當理財App後端_20260908.md,产生的网址是 /blog/googlesheet當理財app後端_20260908/——连 App 都变成 app。

我照文件名写了一条站内链接,在本机测完全正常。因为 macOS 的文件系统不分大小写,而 Cloudflare 的静态资源分。这种 bug 在本机是测不出来的,只能上线之后 404。

解法是让文件名和 slug 一致:ASCII 部分一律小写。顺便学到一件事——验证站内链接不能用「文件存不存在」,要拿 sitemap 当权威来源比对。

二、只 deploy 不 commit,下一次 CI 会默默还原

我改了首页的背景图,用 wrangler deploy 推上去,看起来好了。

几小时后在后台改了一个设置,那个动作 commit 到 main、触发 CI,CI 从 git 重新建置——背景图变回旧的。因为我的改动从来没进过版控。

在「push 就是部署」的架构里,git 才是唯一的事实来源。手动 deploy 只是把一份不在 git 里的东西暂时放上去,下一次任何人碰到 repo 都会把它擦掉。

三、加了自订网域,workers.dev 会被默默关掉

在 wrangler.jsonc 加 routes 之后,wrangler 印了一行警告就把 workers.dev 网址禁用了,因为设置档里没有明确写 workers_dev。原本的网址直接变成 404。

现在那行是明写的:

// 只留自訂網域;workers.dev 網址刻意關閉,避免同一份內容有兩個入口。
"workers_dev": false,

值跟预设行为一样,但它现在是一个决定,不是一个副作用。

唯一一个真的需要保存的功能

文章、设置、图片全都是 repo 里的文件,只有浏览计数不行——它每次造访都要变,不可能每次都开一个 commit。

这是整个站唯一用到 Cloudflare KV 的地方,也是唯一不需要登录的 API 端点:

// 公開端點:文章瀏覽計數。讀者沒有登入,所以要擋在 admin 檢查之前。
if (url.pathname === VIEWS_PATH) {
  return await routeViews(request, env, url);
}

因为公开,slug 是信任边界,会直接变成 KV 的 key,所以先验形状才用。KV 没有原子递增,同时进来的两个请求会少算一次;免费方案每天一千次写入,等于每天一千次浏览封顶。两个限制都写在注解里,真的撞到再换 Durable Object。

刻意不做的事

没有留言系统。 要嘛自己存(就得有真的数据库,整个「repo 就是数据库」的前提就没了),要嘛挂第三方(就要把读者的浏览行为送给别人)。

后台没有做「跟正式站一模一样」的预览。 真正的渲染是 Astro 的 remark/rehype 管线加上 shiki,要在浏览器里完整重现等于把整条管线搬过去。后台的实时预览只求看得出结构,正式的长相靠 npm run dev。

没有草稿分享链接。 草稿在 draft: true 时只有本机看得到,正式站直接滤掉。想给人看就先发布。


回头看,这个架构真正的好处不是省钱(虽然它确实是零月费),而是它没有一个「只存在于某台机器上」的状态。整个网站——文章、设置、样式、部署流程——都在 repo 里。把 repo clone 下来,npm ci && npm run build,就是一模一样的站。

唯一的例外是那个浏览计数。而那也是它唯一一个我需要另外备份的东西。