自托管博客的选型很容易滑向两个极端:要么被某个框架的生态吸引,最后发现一半需求要靠插件拼;要么一上来就自研,前六个月都在补登录和富文本。这篇以 Limit Blog CMS 为例,先把需求拆成可验证的条目,再解释为什么最终落在 Go 后端、Astro 静态站和 React 管理端这三块上,以及它们之间那条不能含糊的边界。

1. 先列需求,再选框架

选型讨论里最常见的开场是「Go 和 Java 哪个快」「Astro 和 Next.js 哪个好」。这两个问题都没有答案,因为缺少约束条件。对个人博客来说,真正决定架构的是下面六件事:

需求 具体到什么程度 对架构的约束
内容 Markdown 写作、草稿、版本回滚、定时发布 需要数据库与版本表,不能只存文件
权限 作者只能改自己的文章,编辑能审所有人的稿 需要角色、权限点和数据归属校验
评论 审核队列、反垃圾、嵌套回复 需要写入 API 与后台管理界面
媒体 上传、去重、本地或 S3、封面裁剪 需要存储抽象与引用计数
构建 发布后自动重建、可预览草稿 需要构建服务与产物切换机制
检索 中文关键词、标题与正文 需要全文索引或构建期索引

如果只做第一项,Hugo 加一个 Git 仓库就够了;如果六项都要,纯静态生成器就会在评论、权限和在线编辑上不断外挂第三方服务。反过来,WordPress 能覆盖六项,但代价是 PHP 运行时、插件兼容性和持续的安全补丁。

这里没有「哪个技术更强」,只有「哪些代价由谁承担」。下面的对比只回答一个问题:这笔代价是否值得付。

2. 五条候选路线

方案 运行时 内容存储 扩展方式 适合谁 主要代价
WordPress PHP + MySQL 数据库 插件、主题目录 不想写代码、要现成生态 运行时与插件面大,升级要回归
Halo Java + Spring Boot 数据库 JAR 插件 Java 团队、要成熟后台 JVM 内存占用,插件开发门槛
Ghost Node.js MySQL / SQLite 官方主题与集成 写作与邮件通讯为主 功能取向固定,深度定制受限
Hugo + 静态托管 无 Git 中的 Markdown 主题、短代码 只写文章、接受 Git 工作流 评论、权限、在线编辑要外挂
自研 自选 自选 自己定义 有明确边界、愿意维护 前期的 CRUD 与长期运维

这个项目最终选择自研,但不是从零发明所有东西。设计文档里明确借鉴了三处:Halo 的存储策略抽象(媒体记录与存储后端解耦)、WordPress 的钩子思想(before_* / after_* 扩展点)、若依的权限点编码(article:create 这种 resource:action 字符串,鉴权中间件直接匹配)。

借鉴的是模型,不是运行时。没有采用 PHP 插件目录,也没有用 Java 的 JAR 热加载,因为跨平台调试和任意代码执行的代价,对一个单人维护的博客来说太高了。

3. 为什么是 Go + Astro + React 管理端

确定自研之后,剩下的问题是「哪部分用什么写」。这个项目的划分原则很朴素:有状态、需要权限和事务的部分交给 Go;读多写少、可以整体替换的部分交给静态构建;高频交互的表单交给 React。

                       ┌─────────────────────────────┐
  读者 ───────────────▶│ Nginx:TLS、静态站、反代      │
                       └───────┬─────────────┬───────┘
                               │             │
                  /api/public/*│             │/api/admin/*
                               ▼             ▼
                       ┌─────────────────────────────┐
                       │ Go 服务(单二进制)           │
                       │ Huma + Chi + GORM            │
                       │ SQLite / MySQL / PostgreSQL  │
                       └───────┬─────────────┬───────┘
                               │             │
                     /statics/*│             │POST /build
                               ▼             ▼
                       ┌─────────────┐ ┌──────────────────┐
                       │ 媒体文件     │ │ site-builder     │
                       │ 本地 / S3    │ │ Astro 构建 + 切换 │
                       └─────────────┘ └────────┬─────────┘
                                                │ releases/<id>
                                                ▼
                                       data/site/current

3.1 Go 负责有状态的那一半

后端是 Headless 的:只提供 /api/admin/* 与 /api/public/*,不做 html/template 渲染。这样做带来三个直接收益:

  1. 单二进制部署:除了数据库,核心功能没有外部强依赖。本地用 SQLite 文件就能启动,生产再切 PostgreSQL 或 MySQL。
  2. 契约只有一份:接口用 Huma 的 Go 结构体定义,路由、校验、OpenAPI 3.1 文档和 TypeScript SDK 都从同一处派生。前台和管理端不需要各自猜字段。
  3. 权限与事务有统一落点:认证在 Chi 中间件,授权在 Huma 中间件读取 Operation.Metadata["permission"],事务连接通过 ctx 传给 DAO。这些机制在静态站里没有对应物,必须留在服务端。

数据库这一层刻意保持三库兼容。这不是为了炫技,而是为了让「本地 SQLite 起步、生产 PostgreSQL、已有环境 MySQL」成为一条配置切换,而不是一次重写。代价是不能使用窗口函数、数据库端 JSON 查询和 ON DUPLICATE KEY 这类方言特性,后面会单独写一篇。

3.2 Astro 负责可缓存的那一半

公开站是 Astro 的静态输出。构建时,Astro 通过 API_BASE_URL 调用公开 API,把文章、分类、标签、页面、项目、导航全部取下来生成 HTML;浏览器拿到的已经是成品,不需要 Node 运行时。

{
  "scripts": {
    "build": "pnpm run build:search",
    "build:search": "cross-env ASTRO_TELEMETRY_DISABLED=1 astro build && pagefind --site dist",
    "build:raw": "cross-env ASTRO_TELEMETRY_DISABLED=1 astro build"
  }
}

build:search 比普通构建多做一步:用 Pagefind 扫描产物,生成静态搜索索引。这样搜索不依赖后端,也不会因为一次关键词查询打到数据库。

React 在公开站里只以 islands 的形式出现,用在评论、点赞这类必须即时交互的地方。文章正文、目录、SEO 元信息都是纯 HTML。对内容站来说,这比「全站 hydration」更划算:首屏不下载用不到的组件代码,也不会因为一个评论框让整页变成客户端应用。

3.3 React 管理端负责复杂表单

管理端是独立的 React SPA,构建产物随 app 镜像发布到 /app/admin,由 Nginx 把 /admin/ 反代给 Go 服务。它承担的是重交互场景:Tiptap 富文本、Markdown 双写、文章列表筛选、评论审核、媒体库、权限配置。

这些界面的共同点是状态多、请求多、需要即时反馈,正好是 React 生态擅长的部分。接口类型来自 OpenAPI 生成的 SDK,字段改名会在 tsc 阶段报错,而不是等到运行时才发现。

3.4 构建服务单独一个进程

Astro 构建需要 Node、依赖包和可观的 CPU/内存,把它塞进 Go 进程既不公平也不安全。这个项目用 site-builder 单独承载构建:Go 通过回环地址和共享密钥调用它,构建成功后用符号链接原子切换产物目录。

site:
  build:
    enabled: true
    url: http://site-builder:9000
    secret: <your-secret>
    previewTTLMinutes: 15
    debounceSeconds: 2
    previewRoot: /app/data/site/preview

这样运行时的组成是清晰的:Go 二进制、site-builder、Nginx,外加数据库。Node 只出现在构建链路里,读者访问页面时不会碰到它。构建失败也不会拖垮 API,最坏情况是站点停留在上一个版本。

4. 静态站与动态 API 的边界

「静态站」不等于「全站静态」。这个项目把读路径和写路径分开:

场景 走哪条路 原因
文章、分类、标签、页面、项目 构建期读公开 API,生成静态 HTML 读多写少,可按发布事件整体失效
导航、友链、公告、站点设置 构建期读取 变更频率低,适合跟着构建走
评论列表与提交 浏览器直接调 /api/public/comments 必须即时可见,且带审核状态
浏览量、点赞 浏览器调 /api/public/articles/{slug}/view、/like 需要去重计数与实时反馈
站内搜索 Pagefind 静态索引 无需后端,构建期一次生成
管理端全部操作 /api/admin/* + JWT 涉及身份、权限与写事务
媒体文件 /statics/* 或对象存储 内容哈希命名,可长期缓存

这里有一条容易忽略的分工:后端其实也提供 /api/public/search(基于数据库全文索引),但公开站在构建期用的是 Pagefind。两者服务不同消费者:前者给 API 客户端和移动端,后者给静态站读者。多一条实现不是浪费,而是把「站点可用性」和「检索能力」解耦——搜索索引坏了,文章页仍然能打开。

边界一旦画错,代价会很具体:把评论列表也做成静态,读者提交后要等下一次构建才能看到;把文章正文做成客户端请求,SEO 和首屏都会倒退。判断标准不是「静态更先进」,而是「这份数据能否接受构建延迟」。

5. 扩展性:钩子、插件、主题

自研系统最怕「写死」。这个项目把扩展点收敛到 pkg/extension,原则是编译期注册、运行期开关,不使用 Go 的 plugin.Open。

// Plugin 是编译期注册的插件;Register 在 Huma API 构建完成后调用。
type Plugin interface {
	Info() Info
	// Register 可注册钩子与自定义路由;返回错误只影响该插件,不阻塞启动。
	Register(reg Registry, api huma.API) error
	// Enabled 在插件启用/停用时调用,用于初始化或释放资源。
	Enabled(ctx context.Context, on bool) error
}

插件在进程启动时注册进内存注册表,sys_plugin.status 决定是否执行 Register。单个插件注册失败只记日志并把状态写回表里,不阻塞服务启动。

事件钩子覆盖文章、评论、媒体、用户、设置五类,共 14 个。语义分两种:

  • before_*:任一处理器返回错误就中断主流程,适合反垃圾、字段校验;
  • after_*:错误只记日志,适合通知、索引、缓存刷新。

before_* 的处理器有 200ms 超时约束,因为它在用户请求路径上;重活必须放到 after_*。每个处理器都有 recover() 包裹,扩展 panic 不能让主请求崩溃。

主题则是另一种形态。因为前台是 Astro 静态站,主题不是后端模板,而是 Astro 工程里的页面、布局和样式;sys_theme 只存元数据与可配置项(主色、首页大图、是否显示阅读时长),构建时由 Astro 读取并注入。切换主题等于切换前端代码并重新构建,不提供运行时上传模板。

这个选择牺牲了「后台上传一个 zip 就换主题」的便利,换来的是没有任意代码/文件写入面。对自托管博客,这笔交换通常是值得的。

6. 代价与适用人群

把优点说完,必须把账单也列出来。选择这套组合,意味着你要长期维护:

  • 三条运行时:Go 服务、site-builder、Nginx,外加数据库;
  • 两套前端:Astro 公开站与 React 管理端,构建链和依赖都要跟;
  • 一整条内容链路:认证、RBAC、评论审核、媒体去重、版本回滚、全文检索;
  • 构建与发布:防抖、失败重试、预览 TTL、旧版本保留;
  • 数据面:三种数据库的兼容测试、备份恢复、迁移脚本。

这些工作不会因为「代码是自己写的」而消失。相反,用 WordPress 时它们由社区分担,自研后全部落在自己身上。

因此它适合这样的人:在意数据所有权,能读 Go 和 TypeScript,接受「发布后等几秒到几十秒才生效」,并且愿意把备份恢复演练当成日常。它不适合:完全不想碰代码的使用者、需要多租户或多站点的运营方、要求插件市场热插拔的团队,以及把「秒级发布」当硬指标的新闻站点。

如果目标只是「尽快开始写文章」,Hugo 加一个托管评论服务,总成本更低。选型不是选最强的,是选愿意维护五年的。

7. 决策清单

最后把判断压缩成十个问题。答案不需要全「是」,但每一个「否」都要有替代方案:

  1. 内容是否必须在浏览器里在线编辑,而不是走 Git?
  2. 是否需要作者、编辑、管理员这种多角色与数据归属?
  3. 评论是否要自托管,并接受审核与反垃圾?
  4. 能否接受发布后经过防抖与构建才生效?
  5. 中文检索是「能用就行」,还是必须精确可控?
  6. 是否愿意维护一个独立的构建服务与产物目录?
  7. 单机数据库是否撑得住未来三年的数据量?
  8. 备份与恢复是否演练过,而不只是写了脚本?
  9. 扩展能否接受「改代码、重新部署」,而不是后台上传?
  10. 团队是否能同时维护 Go 与 TypeScript?

如果第 1、2、3 题都是「否」,静态生成器更省事;如果第 4 题不能接受,应该选动态渲染而不是静态站;如果第 9 题必须是热插拔,WordPress 或 Halo 更合适。

8. 小结

这套选型真正解决的问题,是把「内容」和「展示」解耦:内容留在自己的数据库里,展示可以整体重建、整体替换。下一篇会拆开这条构建链路,讲清楚 2 秒防抖、SSE 状态推送和发布一致性是怎么落到代码里的。