自托管博客的选型很容易滑向两个极端:要么被某个框架的生态吸引,最后发现一半需求要靠插件拼;要么一上来就自研,前六个月都在补登录和富文本。这篇以 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 渲染。这样做带来三个直接收益:
- 单二进制部署:除了数据库,核心功能没有外部强依赖。本地用 SQLite 文件就能启动,生产再切 PostgreSQL 或 MySQL。
- 契约只有一份:接口用 Huma 的 Go 结构体定义,路由、校验、OpenAPI 3.1 文档和 TypeScript SDK 都从同一处派生。前台和管理端不需要各自猜字段。
- 权限与事务有统一落点:认证在 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. 决策清单
最后把判断压缩成十个问题。答案不需要全「是」,但每一个「否」都要有替代方案:
- 内容是否必须在浏览器里在线编辑,而不是走 Git?
- 是否需要作者、编辑、管理员这种多角色与数据归属?
- 评论是否要自托管,并接受审核与反垃圾?
- 能否接受发布后经过防抖与构建才生效?
- 中文检索是「能用就行」,还是必须精确可控?
- 是否愿意维护一个独立的构建服务与产物目录?
- 单机数据库是否撑得住未来三年的数据量?
- 备份与恢复是否演练过,而不只是写了脚本?
- 扩展能否接受「改代码、重新部署」,而不是后台上传?
- 团队是否能同时维护 Go 与 TypeScript?
如果第 1、2、3 题都是「否」,静态生成器更省事;如果第 4 题不能接受,应该选动态渲染而不是静态站;如果第 9 题必须是热插拔,WordPress 或 Halo 更合适。
8. 小结
这套选型真正解决的问题,是把「内容」和「展示」解耦:内容留在自己的数据库里,展示可以整体重建、整体替换。下一篇会拆开这条构建链路,讲清楚 2 秒防抖、SSE 状态推送和发布一致性是怎么落到代码里的。




评论
加载中…