「GopherInk」:做一个简单,安全,个性,可拓展,高性能的CMS封面图

「GopherInk」:做一个简单,安全,个性,可拓展,高性能的CMS

在去年,我就有想拿Go语言开发一个CMS的想法了,原因其实很现实:虽然我现在用的Typecho已经算是用PHP写的CMS里面,小而美的典范了,相比WordPress,Halo这种,已经好很多了。但是,因为PHP是解释型语言,众所周知,解释型语言的运行效率肯定是不如编译型语言的。而Go语言是编译型语言,还适合网站后端开发。目前在这个方向,并没有成熟的开源CMS,而现在诸如Codex这类AI工具极大的降低了开发的门槛,我就有了想用Go语言搞一个个人博客CMS的想法。

因为我个人是信奉功能完善但不臃肿的软件是好文明,所以在设计和构思这个CMS的时候,我就希望这个CMS的功能够用,恰到好处,但是又不能过于臃肿把一些和个人博客使用体验无关的功能加进来。于是,我打算参考Typecho进行开发,原因很简单:Typecho很好的回答了一个个人博客系统,最基础和应该有的功能有那些。

GopherInk的开发并不是一帆风顺,而是充满了曲折。遇到的问题包括但不限于:AI辅助开发导致的项目代码“高耦合,低内聚”,Go语言写的CMS并发性能不如PHP写的Typecho,WAF防护没能管理好资源导致可以被直接CC打死,长期运行导致严重的内存泄漏,开发插件和主题的接口时不考虑兼容性和功能复用导致功能堆积或者不可用等等。

不过经过我的各种测试:暴力CC压力测试,实际为CMS开发插件和主题,在测试中发现和修复了不少问题,目前这个CMS算是基本可用的状态了,而版本我也是暂时定在了v0.5.0。虽然是第一个公开版本,但是我不定版本是v1.0.0的原因就是这个程序还没有经过大量测试和稳定性验证,所以不敢百分百保证质量,定个0.5.0的版本会比较合理。

接下来,请让我介绍GopherInk:

构建时自动发现插件和主题

Go 是编译型语言,本身不会去扫描一个目录里没被导入的包,所以做插件和主题的「自动发现」这件事,没办法像 PHP 那样把文件丢进 usr/plugins/ 下次刷新就生效。但我也没打算让使用者每次新增一个插件都要去 cmd/gopherink 里手动加一行 import _ "...",这种维护方式和「单二进制部署」这样简单的部署方式的初衷就不太对的上了。

最后落地的方案是写了一个统一构建器 cmd/gopherink-builder,也就是 make build 背后真正执行的东西。它做的事情其实很简单:扫描 plugins/themes/ 下的直接子目录,只要目录里有非测试的 .go 文件,就认为这是一个可构建的扩展包,然后生成一个临时的 cmd/gopherink/zz_components_autoload.go,把所有发现的包以空白导入的形式写进去:

// Code generated by gopherink-builder; DO NOT EDIT.
package main

import (
    _ "github.com/Chocola-X/GopherInk/plugins/links"
    _ "github.com/Chocola-X/GopherInk/plugins/sitemap"
    _ "github.com/Chocola-X/GopherInk/themes/default"
)

这样插件和主题包里的 init() 就会被执行,调用 plugin.Registerplugin.RegisterTheme 完成登记。生成的文件在构建完成后会被删掉,不会污染仓库。make list-components 可以在不真正编译的情况下先打印一遍发现结果,方便核对有没有扫到不该扫的东西。

这里有个细节要处理:有些扩展我想作为独立仓库发布,自带 go.mod,而不是塞在主项目里。构建器遇到这种带 go.mod 的子目录,会读出它的 module path,然后写一个临时的 go.work 把这些独立 module 加进 workspace,编译完再清理掉,不会动主项目的 go.modgo.sum。这样 GopherInk-ServerInfoGopherInk-CommentNotifier 这些官方增强插件就可以各自有独立仓库和 issue tracker,又能被主项目构建时无缝纳入。

需要说明的是,这并不是「热加载」。后台点「停用」只是把插件的活动状态标记为 false,钩子和路由不再被 ApplyActive 调用,但机器代码还在进程里;新增插件源码后必须重新编译并重启。这是 Go 的固有约束,我也没有去搞 .so 动态加载那套,因为那会引入一堆版本兼容和符号导出的麻烦,得不偿失。对于个人博客场景,重启一下进程的成本本来就低得可以忽略。

WAF 与 L4 连接层防护

安全这一块是我花时间最多的地方,也是踩坑踩得最狠的地方。

一开始只有 L7

最早 WAF 的设计很「教科书」:在 HTTP 中间件链里检查 IP 封禁、限流、无效路径,命中就返回 429403。这套东西跑起来看着也没问题,常规的扫描和低速爬虫都能挡住。

但是当我拿自己写的压测工具对着裸部署的实例猛打的时候,发现一个问题:某个 IP 已经触发了封禁,进程的 CPU 和内存还是居高不下。攻击者根本不需要换 IP,继续从同一个被封的 IP 往死里打,就能把一个低配 VPS 拖到响应迟缓甚至 OOM。

根因其实一想就明白:封禁检查放在 Handler 链里,意味着一个请求在被判定为「被封」之前,已经走完了 TCP 三次握手、Accept() 返回、goroutine 创建、TLS 握手(如果启用了 HTTPS)、HTTP 请求头解析、http.Request 对象构造这一整套流程。WAF 返回 429 只是省下了后面的数据库查询和模板渲染,但前面那一大坨连接建立和协议解析的开销一分没省。更要命的是 keep-alive,HTTP/1.1 默认长连接,旧实现封禁后只是「每个请求返回 429」,连接本身不会被关闭,攻击者复用一个连接持续发请求,goroutine 和缓冲区一直占着不释放。

把封禁下沉到 Listener

想通这一点之后,解法就很直接了:把封禁检查从 http.Handler 提到 net.Listener.Accept() 这一层。net.Listener 是 Go 网络栈的 L4 抽象,http.Server.Serve(l) 的工作循环就是不断调用 l.Accept() 拿连接,然后 spawn goroutine 处理。如果我包装一层 Listener,在 Accept() 返回连接之前就检查来源 IP,命中封禁就直接 conn.Close() 并继续循环,那这个连接永远不会进入 http.Server 的处理流程。

这就是 pkg/connfilter/listener.go 里的 FilteringListener 做的事,核心逻辑也就一百多行:

func (l *FilteringListener) Accept() (net.Conn, error) {
    for {
        conn, err := l.Inner.Accept()
        if err != nil {
            return nil, err
        }
        ip := remoteIP(conn.RemoteAddr())
        result := l.Blacklist.CheckAndRecordHit(ip, time.Now())
        if result.Blocked {
            _ = conn.Close()   // 立即关闭,不返回给 http.Server
            continue
        }
        // ...注册 trackedConn,返回给上层
    }
}

被封 IP 的连接在 Accept 阶段就被 Close,不分配 goroutine、不执行 TLS 握手、不解析 HTTP、不构造 http.Request。开销从「完整协议栈」降到「一次 IP 查表 + 一次系统调用 close」。

但光这样还不够,因为已经建立的 keep-alive 连接还是能继续发请求。所以我又加了 trackedConn 注册表,每个被 Accept 返回的连接都按 IP 分桶注册,当 WAF 在请求处理中触发新的封禁时,会调用 listener.CloseIP(ip) 主动把该 IP 当前所有活跃连接全部掐断。这样攻击者没法靠复用旧连接绕过新封禁——连接直接没了,下次得重新建立连接,而新连接又在 Accept 阶段被拒。

裸部署 vs 反向代理

这里有个必须认真处理的模式切换:连接层过滤只在裸部署模式下生效。原因是反向代理模式下,TCP 直接来源是 Nginx/Cloudflare 的回源 IP,不是真实访客 IP。如果在连接层按 TCP 来源封禁,攻击者从 Cloudflare 后面打你,你把 Cloudflare 节点 IP 封了,所有经过该节点的正常访客也连不上,相当于“我防我自己”。

所以我把部署模式拆成 bareproxy 两种:bare 模式下 TCP 直接来源就是真实访客 IP,连接层过滤完全安全,会启用;proxy 模式下连接层过滤关闭,只在 HTTP 请求层解析 X-Forwarded-For/X-Real-IP 还原真实访客 IP 后再拒绝,连接级的限流和丢弃交给前置代理做。小型 CMS 最常见的部署方式就是直接 ./gopherink -p 80 跑在 VPS 上,这种场景下 L4 过滤的价值最大。

配套的防护

除了 L4 这一下沉,WAF 还有几个配套的东西一起才叫「立体防护」:

  • 分类限流:动态请求、静态资源、上传附件、搜索、XML-RPC 各有独立的窗口和次数,避免把静态资源限流设得太低反而影响正常页面加载。
  • 动态请求并发准入:全局最多 16 个、单 IP 最多 4 个在途动态请求,槽位在插件检查、URL 索引、缓存回源和业务处理之前获取,容量耗尽立即返回 429 不排队。这是为了保护单核低内存实例,防止慢请求无限占用并发槽位。
  • 活跃封禁续期:被封 IP 在封禁期间继续请求达到阈值就延长封禁时长,攻击者持续猛攻就持续续期,封禁无限延长。续期计数用 atomic.Uint64 CAS 实现,连接层和请求层共享同一份原子状态,即使攻击者连一个 HTTP 请求都没成功发出去,新建连接被 Accept 拒绝时也会记一次命中。
  • 可用内存保护:这是独立的进程安全能力,不受 WAF 总开关控制。进程每 500ms 读一次系统可用内存,低于阈值(默认 50MB)就立即关闭 HTTP 监听并以非零状态退出,不等待优雅关闭。这是避免整台低内存服务器因为内存沾满卡死只能在控制台进行强行重启,生产部署靠 systemd/Docker 负责重启。

压测验证

这套东西做完之后,我搭了一个单核绑定的测试环境(taskset -c 0)跑了三轮压测。测试目标机器的 CPU 是 i5-2410M,也是老家伙了,而且我还是锁单核的情况,能比较好模拟低配VPS的实际情况。最早一轮测试的数据其实不太好看,当时持续限流升级还没接上,拿到的结果是「特定页面暴力」和「随机页面暴力」两个场景 CPU 98%、RSS 80+ MB。后来把持续限流升级补上之后重测,情况就完全不一样了。

持续限流升级的逻辑在 recordRateLimitViolation 里:裸部署模式下,某个 IP 持续触发 429 限流,在短窗口(默认 10 秒)内累计达到阈值(默认 3 次),WAF 就把它升级为全站动态封禁——调用 activateBan 写入封禁名单,再通过 listener.CloseIP(ip) 掐断该 IP 已建立的 keep-alive 连接。升级之后,在 Accept 阶段就被 L4 直接关掉,根本进不了 HTTP 层。换句话说,合法路径的 CC 打到一定量之后,也会被 L4 接管,而不是一直在 L7 跑 429。

重测后的数据(1000 并发,8 秒):

场景状态码分布服务端CPURSS
特定页面暴力0:8901, 403:27, 429:106-12%~27 MB
随机页面暴力0:9389, 403:11, 429:108-12%~27 MB
不存在URL暴力0:9337, 403:1, 404:208-12%~27 MB

状态码 0 是连接错误,也就是连接在 Accept 阶段被 Close 了,连 HTTP 响应都没收到。三个场景的分布几乎一致:绝大多数请求被 L4 直接挡掉,只有极少量 429/403 是首次触发限流但还没达到升级阈值的那几个过渡请求。服务端 CPU 从旧数据的 98% 降到 6-12%,RSS 从 80+ MB 降到 27 MB 左右,和「不存在 URL」场景在同一档。

这就是 L4 下沉加上持续限流升级组合起来的实际效果——不管是合法路径还是非法路径,只要 CC 打到一定量触发升级,封了就真的进不来,而不是封了还在帮你跑协议栈。当然这套东西的能力情况我也写得很清楚:反向代理模式下连接层过滤关闭(会误杀共享代理节点),分散来源攻击(每个 IP 都不触发阈值)得靠入口限流,TCP 握手本身仍由内核完成,挡不住 SYN 洪水。它解决的是「已识别恶意 IP 的后续成本」,不是万能的抗 DDoS 防护。

插件与主题接口设计

既然要参考 Typecho,插件和主题的接口就是重头戏。Typecho 的 Hook 系统其实设计得挺清晰的,我参考了它的大部分生命周期划分,但因为 Go 和 PHP 的架构差异,有些地方必须换一种实现方式。

参考 Typecho 的部分

Typecho 的 Hook 大致分几块:index.phpbegin/end 系统启动结束、Widget\Archive 的内容渲染(header/footer/beforeRender/afterRender)、Widget\Base\Contents 的内容过滤(title/excerpt/content/markdown)、Widget\Comments\Edit 的评论管理、Widget\Feedback 的评论提交、Widget\Upload 的文件上传、Widget\Login 的登录流程等。

GopherInk 对应实现了类似的分层:

  • 请求和认证生命周期request.before/request.after/request.fallback 对应 Typecho 的 index.php begin/enduser.login_* 对应登录钩子。访问统计、安全审计这类插件挂这里。
  • 内容、评论、附件业务事件content.before_save/content.after_save/content.before_deletecomment.before_save/comment.after_save/comment.before_mark/comment.after_mark 对应 Typecho 的 Widget\Contents\Post\EditWidget\Comments\Edit 那一套。适合在核心状态变化前后过滤 payload。
  • 内容查询和渲染过滤content.filter/content.title/content.excerpt/content.before_render/content.parse/content.after_render 对应 Typecho 的 Widget\Base\Contents 过滤钩子。content.parse 可以接管 Markdown 或普通文本解析,设置 Handled=true 后核心不再执行默认解析,这和 Typecho 的 markdown 过滤器语义一致。

钩子优先级用数值表示,越小越先执行,相同优先级保持注册顺序,这和 Typecho 的 _10 后缀权重机制思路一致。

因为架构不同换掉的部分

Typecho 有几个设计是和 PHP 的运行模型强绑定的,硬搬过来反而别扭:

插件直接操作核心数据库表 → 独立插件数据库 + 命名服务。Typecho 的插件可以直接 Typecho_Db::get() 拿到核心数据库连接,往 typecho_contents 里塞字段或者建自己的表。Go 这边如果让插件直接拿 *sql.DB 去操作核心表,类型安全和升级时的兼容性都没法保证。所以 GopherInk 的插件要持久化复杂数据,走的是 DatabaseProvider 接口声明自己的表,核心负责建表,插件通过 OpenPluginDatabase 拿到自己的库连接(可以是独立 SQLite 也可以合并到主库加前缀),表名和占位符方言由 DatabaseTableNameRebindSQL 统一处理。跨插件/主题要共享数据,用命名服务(RegisterService/CallService),比如 links.list 提供友链列表,links.emails 提供友链邮箱集合给评论增强用。这比 Typecho 那种「插件之间互相直接读对方塞在核心表里的字段」要干净。

动态属性扩展 ___readTime → 模板函数 + ContentFields。Typecho 可以通过 \Typecho\Plugin::factory('Widget\Archive')->___readTime = ... 给 Widget 动态加一个属性,模板里直接 $this->readTime 调用。Go 的 html/template 没有这种动态派发能力,所以 GopherInk 走两条路:主题自己注册的 Funcs 模板函数(比如默认主题的 readingTimeI18ndaysSince),以及需要持久化的扩展字段走 ContentFieldsProvider 声明 Schema,插件用 SetContentField/IncrementContentFieldInt 写入,主题用 GetContentFields 读取。阅读量、点赞数这种计数器用 IncrementContentFieldInt 原子递增,避免并发请求丢失增量。

config(Form) 声明式表单ConfigSchema []FieldSchema。Typecho 的插件配置是在 activate 里用 $form->addInput(...) 一行行加表单元素。GopherInk 换成了声明式的 Schema:插件实现 ConfigSchema() []plugin.FieldSchema 返回一个字段描述数组,核心负责渲染成 MDUI 组件、处理 POST、CSRF 和持久化。这样插件不用关心表单 HTML 怎么写,主题配置、用户级配置、内容字段 Schema 都复用同一套机制。条件显示用 ShowWhenField,跨字段校验用 ConfigValidator,保存前要同步外部资源用 ConfigHandler

插件热加载 → 构建时集成。这个前面已经说过了,Go 不支持运行时安装 .so,所以 GopherInk 的插件是构建时编译进二进制的。后台「启用/停用」只切换活动状态,不装载新代码。这换来的是单二进制部署和没有插件版本地狱的好处,代价是改插件要重新编译。

编排层和递归防护

还有一个 Typecho 没有但 GopherInk 必须有的东西:编排层。Go 的插件钩子是同步调用的,如果插件在 content.before_save 钩子里又调用 Runtime.SaveContent,就会无限递归。所以我把内容/评论的「钩子编排 + 写入调用」下沉到独立的 core/orchestration/write.go,用写入上下文追踪当前链路,阻止同一链路内的递归写入。需要派生内容时,应该在钩子完成之后异步排队,或者由插件自己的后台路由显式触发。这是 Go 把插件做成编译期集成带来的一个副作用,必须处理掉。

前端 UI 选型:MDUI

前端 UI 框架的选择我想了挺久。WordPress 的 admin 是 jQuery + 传统的 form 提交,Typecho 也是类似的路子,功能没问题但是风格实在有点年代感。Halo 那种前后端分离的方案又太重了,对一个个人博客 CMS 来说,搞一套独立的前端工程、还要维护它的构建和部署,违背了我「不臃肿」的初衷。

最后选了 MDUI 2。理由挺朴素的:

  • 组件足够完善:侧边栏、卡片、对话框、Snackbar、Chip、表单控件、日期选择器、导航栏这些后台常用的东西都有开箱即用的实现,不用自己从 div 拼起。
  • 框架不算太重:它是一个基于 Material Design 3 的 Web Components 库,不依赖 React/Vue 那套虚拟 DOM 和构建工具链,直接 <script> 引入就能用,体积也控制得住。
  • 风格现代:对比传统 CMS 那种「Bootstrap 3 + jQuery」的 admin 界面,MDUI 的动态配色、圆角卡片、Snackbar 反馈要现代得多,用起来也不像一个 2010 年代的后台。
  • 动态配色:MDUI 2 支持 CSS 变量驱动的主题色切换,后台可以选 Purple/Rose/Indigo/Blue 这些种子色,整个界面跟着变,默认主题也复用这套机制。

默认主题和后台都实现了 AJAX。后台的列表翻页、设置保存、标签搜索这些都是 AJAX 局部更新,不走整页刷新;默认主题走的是 PJAX,用 fetch 拉回新页面的 HTML 片段替换正文容器,保留侧边栏和背景音乐之类的状态。PJAX 这套东西调起来坑不少——比如 scroll-behavior: smooth 会把所有滚动都变成平滑滚动,导致回到顶部时先停在中间再滚上去;又比如 history.scrollRestoration 不设成 manual,浏览器后退时会尝试恢复 scrollY 和我们的滚动冲突。这些都是在实际用的时候一个个发现并修掉的,git 提交记录里还能看到当时写的长篇修复说明。

基础插件:links、sitemap、virtual-files

做完插件接口之后,第一件事就是写几个基础插件来验证接口够不够用。这三个插件解决的都是个人博客几乎一定会遇到的需求:

links(友链管理)。友链这东西几乎每个博客都有,但 Typecho 里通常是主题自己用一个独立页面模板来存,换个主题就没了。我把它做成了独立插件,数据存在插件配置里,通过命名服务 links.list 暴露给主题。主题在 AdjustData 里检查 rt.ServiceAvailable("links.list"),启用了就调 rt.CallService(ctx, "links.list") 拿友链列表渲染,没启用就静默跳过。另外还有个 links.emails 服务暴露友链邮箱集合,给评论增强用——评论者邮箱命中友链邮箱就显示一个「好友」徽章。这个插件验证了命名服务机制和主题-插件协作的边界。

sitemap(站点地图)。SEO 基本功,/sitemap.xml 列出所有已发布文章。实现很简单,挂一个路由,调 rt.ListContents 拉已发布文章,按 sitemap 协议输出 XML。这个插件验证了 RegisterRouteListContents 这两个最基础的 Runtime 接口。

virtual-files(虚拟文件)。这个解决的是 robots.txt、域名所有权验证文件(比如 Google Search Console 的 /google1234.html)、.well-known/ 下的各种验证文件这类需求。这些文件的特点是:路径固定、内容是纯文本、由管理员在后台维护、不需要进版本控制。插件通过 RegisterPublicPathProvider 把自己管理的路径登记进 WAF 的公开 URL 索引(避免被当成无效路径 404 封禁),然后挂 HookRequestFallback 钩子,在核心路由没匹配到的时候检查请求路径是不是自己管理的文件,是就返回对应内容。默认带一个 robots.txt,内容是 User-agent: *\nAllow: /。这个插件验证了请求回落钩子和公开路径登记接口。

这三个插件加起来不到 500 行代码,但把「主题-插件数据协作」「插件路由」「请求回落」这几条主要接口路径都走通了,算是给后续开发更复杂的插件打了底。

增强插件:ServerInfo、VisitorLogger、CommentNotifier

基础插件验证了接口能跑通,但还差些分量。于是又写了三个更复杂的官方插件,进一步压测接口的覆盖度。

GopherInk-ServerInfo 是服务器状态监控插件,在后台侧边栏加一个「Server status」入口,实时显示每个 CPU 核心的利用率、物理内存和交换分区使用情况、各挂载点磁盘容量、GopherInk 进程的 RSS。数据通过一个只读的 /admin/plugins/server-info/stats 接口轮询,仅对管理员会话响应。这个插件有意思的地方是跨平台指标采集:Linux 读 /proc/stat/proc/meminfo/proc/self/mountinfo,macOS 通过 cgo 调 Mach host_processor_info,Windows 用 GetSystemTimes/GlobalMemoryStatusEx/GetLogicalDriveStrings,其他平台降级为只报核心数和 Go 运行时内存。全程不依赖任何第三方监控库,纯系统调用和 cgo。这个插件验证了 AdminMenuProviderRegisterRouteConfigSchemaAdminPageProvider 这套后台扩展接口。

GopherInk-VistorLogger 是访客记录插件,参考了 Typecho 的 VisitorLoggerPro 但重新实现。设计上做了几个和原版不同的取舍:原版把庞大的本地 IP 库打包进插件,记录时同步写入地理位置字段,空间浪费严重;移植版把 IP 库做成插件数据目录下的独立 SQLite 文件,不编译进程序,可以随时替换更新,而且采用「缓存式懒解析」——记录访问时只存 IP 和访问信息,地理位置在后台查看统计时才按需查询并缓存到 ip_geo 表,每个 IP 只查一次 IP 库。百万次访问、几千个 IP,只需几千次查询。记录走 request.after 钩子,核心在响应发出后用独立 goroutine 派发,不阻塞请求路径。统计页的 ECharts 图表跑在 sandbox="allow-scripts" 的 iframe 里,图表库和数据都在 iframe 内,无法触达后台 DOM、Cookie 或存储;由于沙箱 iframe 读不到父页面的 MDUI 变量,图表配色由服务端读取后台主题种子色后在 Go 里推导出一套调色板随数据注入。这个插件验证了 HookRequestAfterDatabaseProviderPluginDataDirOpenPluginDatabase 这套插件数据库和请求生命周期接口。

GopherInk-CommentNotifier 是评论邮件通知插件,新评论发布、回复或审核通过时自动发邮件。挂 HookCommentAfterSaveHookCommentAfterMark 两个钩子,SMTP 支持 SSL/STARTTLS/无加密三种模式,邮件发送基于 Go channel 的异步队列,2 个 worker 并发消费,不会阻塞评论提交返回。邮件模板可以在后台可视化编辑并实时预览,支持一堆占位符({post_title}{comment_author}{comment_avatar_url} 等)。这个插件验证了评论生命周期钩子、AdminActions(设置页操作按钮)、AdminNotices(持续提示)和 ConfigHandler(保存配置时同步初始化邮件队列)这套组合。

这三个插件各自有独立仓库和 go.mod,靠构建器的 workspace 机制被主项目编译时纳入。它们合起来把插件接口里大部分能力都用到了,写完之后我对接口的覆盖度算是比较放心了。

主题移植:RoricalTheme

接口验证完,下一个问题是:这套主题接口对一个已有的 Typecho 主题来说,移植起来到底顺不顺?光自己写个默认主题说明不了问题,因为默认主题就是照着接口设计的,当然顺。得拿一个不是我为接口设计的、已经存在的 Typecho 主题来试。

我选了 RoricalTheme,一个基于 Argon Design System 的卡片式主题,有 Spotlight 聚光灯特效、PJAX、Cookie 合规管理、TOC、阅读统计、点赞、友链这些功能,体量不算小。移植过程在本地 themes/GopherInk-RoricalTheme/ 里,README 里记了一张映射表:

维度原版 (Typecho)移植版 (GopherInk)
模板引擎Typecho 内置 PHP 模板Go html/template
资源管理文件系统直接引用go:embed 编译时嵌入
国际化Typecho _e()自定义 i18n.go 翻译函数
友链管理Typecho 独立页面模板Go 后端管理页面 + JSON 存储
评论增强comment-by-author CSS 类EnrichComments 回调 + 徽章系统
Cookie 合规PHP 实现Go + JS 实现

整体移植下来,感觉接口的兼容性是够用的。视觉风格和核心功能都能保留,PHP 模板转 Go html/template 主要是把 <?php ?> 换成 {{ }},再把 $this->title 换成 .Post.Title 这种,工作量是机械的但不算难。真正要重新设计的是那些依赖 Typecho 特定机制的部分:原版用 comment-by-author CSS 类标记博主评论,移植版改用 EnrichComments 回调批量生成徽章数据;原版的友链存在独立页面模板里,移植版改用 links 插件的命名服务。

后来又本地移植了一个 Butterfly 主题放在 themes/GopherInk-Butterfly/,进一步验证了不同风格的主题都能在这套接口上跑起来。这两个移植主题加上默认主题,算是把「简洁现代」「卡片可爱」「功能丰富」三种风格都覆盖了。

需要说明的是,这两个移植主题目前都只是我本地的测试产物,并没有提交进 GopherInk 主仓库——我在 .gitignore 里把 themes/ 整个目录排除掉了,只保留 themes/default/ 默认主题。原因是第三方主题各有各的更新节奏和 issue 处理需求,塞在主仓库里反而不好维护。其中完成度比较高的 RoricalTheme 我单独建了仓库 github.com/Chocola-X/GopherInk-RoricalTheme,Butterfly 暂时只留在本地。要试用的话,把对应主题目录放进 themes/ 重新 make build 就行。

一些有意思的特点

最后聊几个和同类项目比起来我觉得比较有意思的点:

单二进制部署。前后台模板、静态资源、默认主题、内置插件全部通过 embed.FS 编译进二进制,部署就是扔一个可执行文件加一个 data/ 目录,不用配 PHP-FPM、不用装 Node、不用 npm run build、不用维护 vendor/。对个人博客这种场景,这个体验差距是很大的。Typecho 至少要 PHP 环境 + Web 服务器配置,WordPress 还要一坨 PHP 依赖,Halo 需要 Java 运行时,且其开发/主题生态涉及前端构建链。GopherInk 这边 ./gopherink 就完了。

读写分离core/services/dbrouter.go 把数据库能力抽象成统一接口,Exec/Begin 走写库,Query/QueryRow 默认走读库,需要「写后立即读」一致性的场景用 WithWriter(ctx) 强制走写库。支持 SQLite/MySQL/MariaDB/PostgreSQL,对个人博客来说 SQLite 零配置就够,想上量了再切 MySQL 读写分离也不用改代码。

兼容性 API。XML-RPC(MetaWeblog/WordPress/Blogger)、Pingback、Trackback、RSD 都实现了。这意味着原来用 Typecho 或 WordPress 时接的那些离线写作工具(比如 Open Live Writer、MWeb)和第三方推送服务,对着 GopherInk 也能直接用。这个对从 Typecho 迁移过来的用户挺重要的。

命令行应急恢复。忘了管理员密码不用进数据库手动改哈希,./gopherink user reset-password --id 1 就行,重置后还会自动撤销该账户的现有登录会话。这个命令只访问启动配置指向的数据库,不启动网站,即使网站因为配置问题起不来也能用。

编排层递归防护。前面提过的 core/orchestration/write.go,阻止插件在内容保存钩子里再次调用 SaveContent 造成无限递归。这是 Go 同步钩子模型下必须有的东西,Typecho 的 PHP 插件因为每个请求是独立进程,递归最多把当前请求搞挂,不会拖垮整个进程;Go 这边一个递归能把整个服务搞 OOM,所以必须防护。

评论守卫。主题可以声明 CommentGuard: true 能力,核心会向模板注入守卫端点和 token 机制,匿名评论必须先通过 GET /comment/guard?cid=<id> 取 token 再带 token 提交。这挡住了那种只解析 HTML 表单直接 POST /comment 的通用垃圾评论程序,而且 token 签发、访客 Cookie、内容 ID 绑定、重放校验都在核心做,主题只负责交互。

写在最后

GopherInk 现在的状态是 v0.5.0,基本可用,但我不敢说它有多稳定。开发过程中踩过的坑——AI 辅助导致的高耦合低内聚、并发性能不如 PHP、WAF 被 CC 打死、内存泄漏、接口设计反复——每一个都实打实地修过了,但没经过大量用户和长时间运行验证之前,定 v1.0.0 都是不诚实的。

如果你正好在找一个 Go 写的、小而美的个人博客 CMS,或者对「把 Typecho 的设计搬到 Go 上」这件事感兴趣,可以来 github.com/Chocola-X/GopherInk 看看,压测工具和文档都在仓库里,欢迎拿去折腾。

评论区 (0)