Anheyu-App 插件开发实录:给 Go 后端搓一个能热插拔的插件
本文介绍 Anheyu-App 基于 go-plugin 的插件开发流程:插件是独立子进程,支持 Searcher 和 EventHook 两类能力;需编写 plugin.json、注入配置、调用 sdk.Serve 注册,并交叉编译打包 zip 安装。重点提醒日志写 stderr、zip 路径用正斜杠,以及安装限制和常见坑。
碎碎念
上一篇博客介绍了怎么自己开发一个主题,那里面有一些遗留的问题,比如我上传了SSR主题之后,还要去对应的CDN后台删除缓存,那么有什么办法能一键式解决吗?
可以!而且比你想的简单——鱼鱼已经写好了插件系统,那套 pkg/plugin/sdk 已经把 go-plugin 的握手、RPC、元信息注册全给你包好了,正常情况下你只需要在自己的 main 里写一行 sdk.Serve(...)。
但"简单"这两个字,是我写完之后才敢说的(尽管我中间照样还是遇到了一些问题),这篇把整条链路从头捋一遍:插件到底是什么进程、有哪些能力、清单怎么写、配置怎么进来、怎么打包、怎么装,以及我踩到的几个坑。
那么,正式开始吧~
插件到底是个啥进程
这是理解后面一切的前提,第一次看官方文档最容易糊弄过去,但糊弄过去后面全乱。
Anheyu 的插件系统是基于 HashiCorp go-plugin 的运行时插件——注意不是 .so 动态链接,而是:主程序把你的插件当成一个独立的子进程拉起来,两边通过 stdin/stdout 上的 RPC(net/rpc,不是 gRPC,先记住这点)通信。
插件(子进程) | 主程序 anheyu(父进程) | |
|---|---|---|
形态 | 你交叉编译出来的一个二进制 | Go 后端本体 |
权限 | 和主程序同等的系统权限 | 同上 |
通信 | go-plugin 握手 + net/rpc | 拉起、Dispense、转发事件 |
配置注入 | 启动时通过环境变量灌进来 | 后台保存的配置下发 |
下面有两个重要的东西需要记住:
go-plugin 靠 stdout 上的一行特殊输出完成握手。插件里任何往 stdout 打印的内容都会干扰握手、导致失败,所以运行期日志统一写到 stderr::
GOlog.SetOutput(os.Stderr)插件进程和主程序权限一样。它不是沙箱里的 WASM,而是一个完整进程:能读写文件、能开端口、能访问外网,所以你最好是再额外做一个鉴权来保证安全(假如你又读又写的话)。
顺带一提:因为插件是独立进程,它没法把页面注入进闭源的
/admin,也没法直接复用主程序内存里的 Redis 客户端对象。想给前台用,得走自己的 HTTP 服务 + Next 反代那套——这正是我这次写的插件cdn-console干的事,下一篇单开细讲这个插件的功能,这篇只是插件开发教程。
Searcher 和 EventHook
别被"插件"两个字勾起无限想象,鱼鱼写的目前运行时只认两类能力,PluginMap 里写死了:
能力类型 | 接口 | 干什么用 | 典型例子 |
|---|---|---|---|
|
| 接管站点搜索:索引 + 检索 | 把 Meilisearch / ES 接进来 |
|
| 订阅站点事件做二次开发 | 文章发布后清 CDN、发通知 |
一个插件可以只实现一种,也可以两种都实现(sdk.Options 里 Searcher、EventHook 都填上就行)。清单里的 types 只是展示用的,真实能力以运行时 Dispense 出来的为准。
仓库骨架
我是独立仓库开发、产物当包分发。目录长这样(以 cdn-console 为例):
TEXT
my-plugin/
├── plugin.json # 清单:id/entry/config_schema,后端就认它
├── main.go # 唯一调 sdk.Serve 的地方
├── go.mod # 注意:SDK 依赖不在这里固化(见下)
├── build.sh # 交叉编译 + 打包
└── internal/ # 你的业务,尽量只依赖标准库
├── config/ # 读注入的环境变量
└── eventhook/ # 实现 plugin.EventHook 的薄适配层
go.mod这里我做了个有点偏执但很值的决定:不把 SDK 写死在 require 里。 anheyu 这个大模块一拉依赖就是 gin/ent/aws-sdk 一大串,本地离线跑单测会很慢很慢。我的做法是:在internal/下除适配层外全部只用标准库,go test ./internal/... 秒开秒过;SDK 留到打包时在有网环境 go get 一下,放在 build.sh 里做。
GO
module github.com/xxx/anheyu-plugin-my
// SDK 仅 main.go / eventhook 适配层用到,不在此处固化;
// 打包时在具备网络的环境 `go get github.com/anzhiyu-c/anheyu-app@latest` 补全。
go 1.23
清单 plugin.json
plugin.json 是安装时的唯一真相。我把官方 manifest.go 的 Validate() 逐行对了一遍,几条硬规则记清楚,不满足直接拒装:
manifest_version必须 = 1,别自己发挥;id要匹配^[a-z0-9][a-z0-9_-]{1,63}$(小写字母/数字/中划线/下划线,且首字符是字母或数字,就像是cdn-console这样的);entry是平台标识 -> 包内相对路径的映射,平台标识形如linux-amd64;路径必须是相对、且在包内(绝对路径或带..一律拒);config_schema[].type只能是string/number/boolean/select,select必须给options;default一律是字符串(哪怕数字端口也写"9800")。
一份完整可用的最小清单:
JSON
{
"manifest_version": 1,
"id": "cdn-console",
"name": "多 CDN 统一控制台",
"version": "1.0.0",
"description": "封装多家 CDN 的清除/预热/指标为 HTTP API,并在内容变更时自动清缓存",
"author": "白夜",
"homepage": "https://blog.byer.ink/",
"min_app_version": "1.0.0",
"types": ["eventhook"],
"entry": {
"linux-amd64": "bin/cdn-console-linux-amd64",
"linux-arm64": "bin/cdn-console-linux-arm64",
"darwin-amd64": "bin/cdn-console-darwin-amd64",
"darwin-arm64": "bin/cdn-console-darwin-arm64",
"windows-amd64":"bin/cdn-console-windows-amd64.exe"
},
"config_schema": [
{ "key": "listen_port", "label": "HTTP 端口", "type": "number", "default": "9800" },
{ "key": "auto_purge_enabled", "label": "事件自动清除", "type": "boolean", "default": "true" },
{ "key": "redis_password", "label": "Redis 密码", "type": "string", "secret": true }
]
}
secret: true 这个字段很关键——它让后台在渲染表单时把该项当密码框、不回显明文。凡是密钥一律标上。
配置是怎么进到插件里的
后台填的那些配置,主程序在拉起插件进程时通过环境变量注入,两种形态:
ANHEYU_PLUGIN_CONFIG:整份配置的 JSON;ANHEYU_PLUGIN_CONFIG_<KEY>:逐项注入,键名转大写、非字母数字全变下划线(listen_port→ANHEYU_PLUGIN_CONFIG_LISTEN_PORT)。
SDK 给了现成的读取器 sdk.LoadConfig(),返回一个 map[string]string,带 String/StringDefault/Int/Bool 四个便捷方法:
GO
cfg := sdk.LoadConfig()
host := cfg.StringDefault("host", os.Getenv("ANHEYU_MEILISEARCH_HOST")) // 顺便兼容旧的环境变量
port := cfg.Int("port", 7700)
on := cfg.Bool("enabled", true) // "true"/"1"/"yes"/"on" 都算 true
我在 cdn-console 里为了让 internal/config 保持零依赖,没用 SDK、直接用标准库自己解析了这两个环境变量(逐项 env 优先于 JSON)。官方 meilisearch 插件则用了 sdk.LoadConfig()。殊途同归,你随意。
main.go
只需要把插件信息塞进 sdk.Options,Serve 一调,握手/RPC/元信息就全自动注册完成了。
最小事件钩子插件(官方 SDK 文档注释里的原版示例):
GO
package main
import (
"context"
"github.com/anzhiyu-c/anheyu-app/pkg/plugin"
"github.com/anzhiyu-c/anheyu-app/pkg/plugin/sdk"
)
type MyHook struct{}
func (h *MyHook) Subscriptions() []string { return []string{plugin.EventArticlePublished} }
func (h *MyHook) OnEvent(ctx context.Context, e plugin.Event) error { return nil }
func main() {
sdk.Serve(sdk.Options{
Metadata: plugin.Metadata{ID: "my-plugin", Name: "我的插件", Version: "1.0.0"},
EventHook: &MyHook{},
})
}
加一个 Searcher 能力(对照 meilisearch 的接口面):model.Searcher 要实现以下五个方法——
GO
Search(ctx, query string, page, size int) (*model.SearchResult, error)
IndexArticle(ctx, *model.Article) error
DeleteArticle(ctx, articleID string) error
ClearAllDocuments(ctx) error
HealthCheck(ctx) error
Searcher 和 EventHook 同时给 sdk.Options 填上,就是一个双能力插件。Metadata.Types 留空的话,SDK 会按你给了哪些能力自动补。
深挖EventHook
事件对象是一个 plugin.Event:
GO
type Event struct {
Name string // "<领域>.<动作>",如 "article.published"
OccurredAt time.Time
Payload json.RawMessage // 各事件自带的 JSON 负载
}
事件名常量目前就这几个:
GO
EventArticleCreated = "article.created"
EventArticlePublished = "article.published"
EventArticleUpdated = "article.updated"
EventArticleDeleted = "article.deleted"
EventCommentCreated = "comment.created"
EventSubscribeAll = "*" // 订阅全部
Subscriptions() 里返回你要订阅的名字(或 "*" 全都要)。OnEvent 里 json.Unmarshal(event.Payload, ...) 拿到业务字段即可。
一个反直觉但很重要的点:OnEvent 返回的 error 只会被主程序记日志,不影响站点业务流程。也就是说钩子挂了不会把文章发布卡住。因为鱼鱼的这个地方没写,所以我在 article.created 里补了一下,发布文章也会更新:
GO
func (h *Hook) Subscriptions() []string {
// 连 created 一起订,弥补后端"创建时不触发清除"的缺口
return []string{"article.created", "article.published", "article.updated", "article.deleted"}
}
小提示:
EventHandlerFunc还给了个偷懒类型,直接拿函数当钩子、默认订阅"*",一次性脚本式插件可以秒用。
打包zip
后端只相信plugin.json + entry指向包内相对路径,所以我们直接交叉编译N个平台,然后连着plugin.json打包进行zip。
打包脚本build.sh :
BASH
go get github.com/anzhiyu-c/anheyu-app@latest
go mod tidy
mkdir -p bin
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o bin/my-plugin-linux-amd64 .
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o bin/my-plugin-linux-arm64 .
# darwin-amd64 / darwin-arm64 / windows-amd64 同理,windows 记得加 .exe
使用-trimpath -ldflags="-s -w" 来减少一下构建包的体积,然后使用CGO_ENABLED=0是为了编译出纯静态二进制,跨平台时更稳定,不容易出问题。
zip 布局有两条注意事项(我照着install.go 源码翻出来的):
plugin.json要在压缩包根目录——后端找的是f.Name == "plugin.json",你压成了my-plugin/plugin.json就直接报"缺少 plugin.json";每个条目的路径必须是
filepath.IsLocal认可的相对路径(防 zip slip),而且要用正斜杠。
第 2 条就是本篇最阴的一个坑,单独拎出来讲。
目录中带反斜杠导致安装不上
我第一版是用 Windows PowerShell 5.1 的 Compress-Archive 打的包(也就是 `powershell.exe` 里那个)。这个包看着挺正常的,传到后台安装直接报"安装包中缺少当前平台的二进制文件"。

把包解开一看,条目名是 bin\my-plugin-linux-amd64——反斜杠。Linux 上反斜杠不是路径分隔符,所以filepath.FromSlash 不认这个,于是解压出来的是个名字里带反斜杠的文件,根本就不存在bin/xxx 。
所以我们直接别用PowerShell 5.1(在AI中这个5.1也经常出错所以直接换PowerShell 7吧),我们直接用 Go 的 archive/zip 打(它用的是正斜杠,还能顺手给二进制打 0755)。写一个 //go:build ignore 的一次性脚本就够:
GO
//go:build ignore
package main
import ("archive/zip"; "os"; "path/filepath")
func main() {
f, _ := os.Create("my-plugin.zip")
zw := zip.NewWriter(f)
add := func(name, disk string, exec bool) {
data, _ := os.ReadFile(disk)
h := &zip.FileHeader{Name: name, Method: zip.Deflate} // name 用正斜杠:"bin/xxx"
if exec { h.SetMode(0755) } else { h.SetMode(0644) }
w, _ := zw.CreateHeader(h)
_, _ = w.Write(data)
}
add("plugin.json", "plugin.json", false)
add("README.md", "README.md", false)
_ = filepath.WalkDir("bin", func(p string, d os.DirEntry, err error) error {
if err == nil && !d.IsDir() { add("bin/"+d.Name(), p, true) }
return nil
})
_ = zw.Close(); _ = f.Close()
}
安装插件
这里专门给上一篇埋个呼应:SSR 主题(tar.gz)走的是 downloadUrl 直装、还有一堆 HTTPS/SSRF 限制;插件走的是另一条路——后台『插件管理』里直接本地上传 zip。别搞混了。
上传后 InstallFromZip 干的活:解析校验 manifest → 解压到临时目录 → 原子 rename 替换 → 拉起加载。解压环节自带了一些防护,不要触发了就行:
防护项 | 上限 | 触发即拒 |
|---|---|---|
解压后总大小 | 512 MB | zip 炸弹 |
包内文件数 | 1000 | 塞太多 |
路径 | 必须 | zip slip( |
符号链接等特殊文件 | — | 直接跳过不落地 |
当我们需要对插件进行更新时,直接上传就行,插件系统对同 id 升级会保留你的持久化配置和禁用状态,旧进程会被 Kill 掉再换新的二进制。所以迭代时改配置不用每次重填。
没有插件入口
装完插件、后台也显示"已启用",然后……就没有然后了。插件不像主题有前台页面,它的能力(searcher/eventhook)是被主程序调用的,很多并没有一个现成的"操作界面"。
cdn-console 这类需要给用户点按钮(手动清缓存、看指标)的插件,SDK 本身不提供往后台塞页面的能力——因为插件进不了闭源的 /admin。我的解法:
插件自带一个 HTTP 服务(监听端口别对公网开),把能力封装成 REST;
Next 主题里加一条 rewrite,
/cdn-api/:path*代理到插件/api/:path*,透传Authorization;前台主题页
/cdn-console消费它,插件侧拿调用者的 Bearer 去回源主程序校验"是不是管理员"。
如果你的插件只想脱离主题独立用,我还在清单里加了个
serve_ui开关,用 Goembed把单文件控制台直接塞进二进制,开了就自带登录页。这套 HTTP + 鉴权 + 缓存 + Redis 的完整玩法,我在第二篇《多 CDN 控制台插件》里写,这里先点到为止。
一些零碎的坑和 FAQ
Q:插件起来就崩,日志里握手相关的一大堆乱码? A:十有八九是你往 stdout 打了日志,污染了 go-plugin 握手。log.SetOutput(os.Stderr) 伺候上,fmt.Print* 也别往 stdout 招呼。
Q:改了后台配置,插件端口提示被占用 / 起不来? A:配置热重载会重启插件进程,旧端口的 TIME_WAIT 可能瞬占。我加了个 listenWithRetry 退避重试(6 次 × 500ms)兜底。
Q:config_schema 里数字类型写了 9800(不带引号)会咋样? A:清单里 default 字段类型是字符串,写成裸数字 JSON 能过、但语义不统一。老实写 "9800",读的时候用sdk.LoadConfig().Int("listen_port", 9800) 处理。
Q:go get 拉 SDK 失败 / 卡在 GitHub? A:anheyu-app 是公开模块,走 GOPROXY 或本地代理都行。我打包时临时设了 HTTP(S)_PROXY 指向本机代理(127.0.0.1:7897 之类),go get + 交叉编译一路畅通。服务器 / CI 上一般直连 proxy.golang.org 即可。
Q:插件能用 net/http 开服务、能连数据库 / Redis 吗? A:能,它就是普通进程、权限和主程序一样。但记住它复用不了主程序内存里的客户端对象(不同进程),要用就自己按地址重新连。
芜湖,可算写完了,也不写总结了,就这样就行了,都着手开发插件了,应该不难理解。
完整源码参考:
主仓库(
pkg/plugin/sdk所在):https://github.com/anzhiyu-c/anheyu-app