上一篇文章写了 responsevent——一个 Go 事件调度器 + Node.js 子进程插件的框架。设计上很漂亮:拓扑排序的插件链、stdin/stdout 的 JSON 协议、权限隔离、RPC 能力注入。做完之后用了一段时间,发现一个问题:部署摩擦没有变小,只是从「改 Go 代码」变成了「写 TypeScript 插件 + 配 plugin.yml + 调试 stdin 协议」。
这就是本文要讲的事:为什么最终选了一个看起来「退步」的方案——Go 编译一个二进制,所有业务逻辑用 Lua 脚本写,文件挂载进容器,改完即生效。
responsevent 的问题不在设计,在场景
responsevent 的设计是面向「多人协作 + 不可信插件」的:权限声明、能力隔离、action handler 统一执行。这些在一个有十几个开发者各自维护插件的团队里是必要的。
但实际场景是:维护者只有一两个人,插件数量不超过五个,每个插件做的事很明确——飞书消息触发构建、GitLab issue 关闭后推送摘要、定时 canary 发版。这些逻辑加一起不到 300 行有效代码。
为这 300 行代码,我需要维护的东西:
Go 主进程的 dispatcher、registry、executor
Node.js 的 runner、stdin/stdout 协议解析
每个插件的
plugin.yml、index.ts、package.json插件的 RPC 通道(AI 调用、GitLab API 调用都得走 Go 代理)
Docker 镜像里同时装 Go 二进制 + Node 运行时 + 插件目录
两个运行时、两套包管理、一个 JSON 协议。框架的复杂度远超业务本身。
Lua 基座的核心思路
新方案极其简单:Go 编译一个 autocicd 二进制,内嵌一个 gopher-lua VM。每次事件进来,新建一个 Lua VM,注入事件数据和模块绑定,执行对应脚本,用完销毁。
事件源(飞书长连接 / GitLab webhook / cron)
↓
Go 主进程 → 新建 Lua VM → 注入 EVENT + 模块绑定 → DoFile("on_xxx.lua")
↓
Lua 脚本调用 Go 绑定:feishu.send_card / build.full_build / ai.chat / ...
Go 侧提供的模块是固定的「能力层」:
Lua 脚本只做一件事:根据事件内容,决定调用哪些能力、传什么参数。和 responsevent 里「插件只返回 actions,Go 执行」的思路一致——区别是去掉了中间的协议层、权限层、拓扑排序层。
为什么 Lua 而不是继续用 Node
三个原因:
启动开销。 Node 子进程每次启动约 50-100ms(加载模块更久),对于「收到飞书消息立即回复」的场景有体感延迟。gopher-lua 创建一个 VM 的开销是微秒级,事件响应几乎无感。
部署体积。 Node 运行时 + node_modules 在镜像里占 200MB+。gopher-lua 是纯 Go 库,编译进二进制后镜像只需要 node:22-slim(给 electron-builder 用),Lua 脚本本身就是几个文本文件。
修改即生效。 Lua 脚本通过 Docker volume 挂载,改完文件下一次事件触发就用新代码。不需要编译、不需要重启容器、不需要 npm install。Node 插件做到这一点需要额外处理 require cache、ESM import 缓存等问题。
没有插件链,因为不需要
responsevent 里的拓扑排序插件链解决的是「多个独立开发的插件需要按依赖顺序执行」的问题。当前场景里,所有脚本是同一个人写的,执行顺序就是脚本内的 if-elseif 分支。
if cmd == "构建" then
builder.run_build(product, "canary", chat_id)
elseif cmd == "版本详情" then
vd.handle_version_detail(chat_id, operator_id)
elseif cmd == "帮助" then
feishu.send_card(chat_id, cards.help_card())
end
没有 depends_on,没有 priority,没有 handled 语义。一个事件对应一个脚本文件,脚本里用普通的条件分支决定行为。当复杂度不存在时,管理复杂度的基础设施就是累赘。
跨 VM 状态:store 模块
Lua VM 用完即销毁,天然无状态。但有些场景需要跨事件传递上下文——比如版本详情卡片的 message_id,发消息时记住,点按钮时取出来用 update_card 更新同一张卡片。
Go 侧维护一个进程级的 map[namespace]map[key]value,通过 store 模块暴露给 Lua:
local store = require("store")
-- 发送卡片时记住 msg_id
store.set("vd:" .. operator_id, "msg_id", msg_id)
-- 按钮回调时取出
local msg_id = store.get("vd:" .. operator_id, "msg_id")
feishu.update_card(msg_id, new_card_json)
进程重启就清空,不需要持久化。构建状态、卡片上下文这类数据本来就是临时的。
安全边界的取舍
responsevent 里有严格的权限模型:插件在 plugin.yml 里声明 permissions: [feishu],尝试调用 build.trigger 会被 Go 侧拒绝。这在多人协作场景下是必要的。
Lua 基座方案里没有权限隔离——所有脚本都能调用所有 Go 绑定。因为维护者就是系统的拥有者,限制自己访问自己的能力没有意义。
如果未来需要开放给其他人写脚本,可以在 Go 绑定层加一层 allowlist。但在那一天到来之前,这层抽象不应该存在。
Go 镜像的角色变化
在 responsevent 里,Go 是「调度器 + 能力执行器」,业务判断在 Node 插件里。
在 Lua 基座里,Go 是「能力层 + 事件路由」,业务判断在 Lua 脚本里。
听起来一样,但关键区别是:中间没有协议层。Lua 调用 Go 函数是直接的函数调用(通过 gopher-lua 的 LGFunction 注册),不是 stdin/stdout 的 JSON 序列化、不是 RPC channel、不是 action 声明后再执行。
调用链从:
事件 → Go dispatcher → stdin JSON → Node 进程 → 业务判断 → stdout JSON → Go action executor → 飞书 API
变成了:
事件 → Go execScript → Lua VM → feishu.send_card() → Go 直接调飞书 API
少了两层序列化、少了一个进程、少了一个协议。
什么时候该用 responsevent 那样的方案
不是所有场景都适合 Lua 基座。如果满足以下条件,拓扑排序 + 子进程 + 权限隔离的设计仍然是对的:
插件开发者超过 3 人,且互相不信任
插件之间有真实的依赖关系(A 的输出是 B 的输入)
需要严格的能力审计(谁调了什么、什么时候调的)
插件需要热加载而不能重启主进程
当前场景一个都不满足。所以选了更简单的方案。
要知其然也要知其所以然——框架的价值不在于它能管理多少复杂度,而在于它管理的复杂度是否真实存在。

参与讨论
(Participate in the discussion)
参与讨论