插件系统 v2——函数式、依赖注入、蓝绿部署¶
ZeroBot 基因:ZeroBot 的"插件"通过
init()+StoreMatcher()全局注册实现,没有生命周期、依赖注入或热重载。Remilia v1 继承此模式,v2 才彻底重写为 Descriptor 模式。参阅11-zerobot-inspiration.md。
设计哲学¶
v1 插件系统采用经典的面相对象继承模式:BasePlugin 基类 + 子类覆写生命周期方法。这种方式虽然直观,但随着功能增长暴露出一系列问题:
| 问题 | 影响 |
|---|---|
| 继承强耦合 | 插件与框架硬绑定,难以独立测试 |
| 样板代码多 | 每个插件都要重复写 BasePlugin 结构 |
| 依赖管理隐式 | Deps 字段手动声明,容易遗漏或弄错 |
| 权限模糊 | 插件持有 Engine 引用,可做任何写操作 |
| 热重载不灵活 | 只有"卸载-加载"一种策略 |
v2 弃用继承,采用纯函数式描述符(Descriptor):
type Descriptor struct {
Name string
Version string
Meta *Metadata
Deps []string // 手动声明依赖(Smart 注册时可省略)
Setup func(*SetupContext) (any, error)
Teardown func(*TeardownContext) error
Advanced *Advanced // 可选高级功能
}
核心设计¶
1. 描述符模式(Descriptor Pattern)¶
插件开发者不再继承框架类型,而是返回一个描述插件的"数据对象":
func New() *plugin.Descriptor {
return &plugin.Descriptor{
Name: "myplugin",
Version: "1.0.0",
Meta: &plugin.Metadata{
Description: "我的插件",
Category: "工具",
},
Setup: func(ctx *plugin.SetupContext) (any, error) {
ctx.Reg.RegisterCommand(...)
return &MyAPI{}, nil
},
Teardown: func(ctx *plugin.TeardownContext) error {
ctx.API.(*MyAPI).Close()
return nil
},
}
}
优势:
- 插件是纯函数,易于测试(传入 mock 的 SetupContext)
- 无强制类型约束,API 返回类型由插件自定
- 描述符可序列化,支持插件商店的元数据发现
2. 自动依赖注入¶
依赖注入容器 Container 采用两阶段设计:
阶段 1:注册阶段(可变)
type Container struct {
services sync.Map // 并发安全的读写
}
阶段 2:冻结阶段(只读快照)
type Container struct {
frozen atomic.Bool
frozenMap atomic.Pointer[map[string]any]
}
func (c *Container) Freeze() {
c.frozen.Store(true)
c.refreshSnapshot()
}
func (c *Container) Get(name string) (any, bool) {
if c.frozen.Load() {
if m := c.frozenMap.Load(); m != nil {
v, ok := (*m)[name]
return v, ok
}
}
return c.services.Load(name)
}
冻结后 Get 仅需一次 atomic.Load,读性能提升 2-3 倍。冻结后仍可注册新服务(热重载场景),会自动刷新快照。
Smart 注册(v2 核心改进):
// DryRun 阶段自动推断依赖图
func (m *Manager) resolveDeps(desc *Descriptor) ([]string, error) {
if len(desc.Deps) > 0 {
return desc.Deps, nil // 显式声明优先
}
// 模拟运行 Setup,追踪 ctx.Require / ctx.Optional 调用
deps := dryRunResolve(desc)
return deps, nil
}
插件不需要手写 Deps——框架在注册时通过 DryRun 自动发现依赖。
3. 读写分离权限模型¶
// SetupContext 只读视图(插件开发者的主要 API)
type SetupContext struct {
Reg RegistryWriter // 注册匹配器
Config Config // 插件配置
Logger Logger
Go func(fn func()) // 启动生命周期绑定的 goroutine
Require func(name string) any
Optional func(name string) (any, bool)
MustAs func(name string, target any)
Container *Container // 注入 API 到容器
}
// ManagerWriter 写权限(需要 Privileged: true)
type ManagerWriter interface {
DisablePlugin(name string) error
EnablePlugin(name string) error
ReloadPlugin(name string) error
}
设计要点:
- 插件 Setup 只能获取 SetupContext,其中 Reg 接口仅暴露注册命令/匹配器的写操作
- ManagerWriter 需要插件声明 Privileged: true 才能获取
- PluginInfo 是全只读的——任何人都可以查询插件状态,但不能修改
4. 三种热重载策略¶
const (
ReloadUnloadLoad ReloadStrategy = iota // 停机重载(默认)
ReloadInPlace // 原地重载
ReloadBlueGreen // 蓝绿部署
)
ReloadUnloadLoad(停机重载)¶
- 调用
Teardown清理旧实例 - 从 Engine 移除旧匹配器
- 加载新配置
- 调用
Setup创建新实例 - 注册新匹配器
存在短暂不可用窗口,但支持完整状态迁移(SaveState / RestoreState)。
ReloadInPlace(原地重载)¶
Advanced: &plugin.Advanced{
Strategy: ReloadInPlace,
Reload: func(ctx *ReloadContext) error {
// 自行处理状态更新,无需卸载
ctx.API.(*MyPlugin).UpdateConfig(newConfig)
return nil
},
}
适用于配置更新、规则热加载等场景。
ReloadBlueGreen(蓝绿部署——v2 亮点)¶
旧实例运行中 → 新实例 Setup → 原子切换 Matcher → 旧实例 Teardown
时间线:
旧 Matcher ████████████████████████████████████░░░░░░░░
新 Matcher ░░░░░░░░░░░░░░░░░░░░░░░░░░██████████████████
↑ 原子切换点(零停机)
零停机切换:切换过程中新旧 Matcher 共存,新实例就绪后一次性接管,旧实例清理。
实现要点:
- 新实例在并行 goroutine 中执行 Setup
- 切换前冻结新实例的状态,确保一致性
- 切换使用 atomic.Store 替换 Engine 中的匹配器引用
- 旧实例的 Teardown 在新实例完全就绪后才执行
5. 内置插件生态¶
框架内置了 25+ 个业务插件,覆盖机器人场景的常见需求:
| 类别 | 插件 | 功能 |
|---|---|---|
| 核心 | core/help |
命令自动发现 + 图片生成 |
| 核心 | core/admin |
管理员指令 |
| 核心 | core/permission |
细粒度权限(持久化 + ACL) |
| 安全 | antispam |
反垃圾 |
| 安全 | keywordfilter |
敏感词过滤 |
| 安全 | acl |
访问控制列表 |
| 运营 | broadcast |
广播消息 |
| 运营 | scheduler |
定时任务调度 |
| 运营 | job |
一次性/周期性任务 |
| 消息 | sendqueue |
发送队列(退避重试) |
| 消息 | messagelog |
消息日志持久化 |
| 消息 | conversation |
对话管理(含 GC) |
| 配置 | cooldown |
冷却系统 |
| 配置 | i18n |
国际化 |
| 治理 | pluginctrl |
插件启停管理 |
| 治理 | pluginstore |
插件商店 |
| 治理 | ratelimitui |
限流可视化 |
| 工具 | calendar |
日历 |
| 工具 | idiomdict |
成语词典 |
| 工具 | verifycode |
验证码生成 |
| 工具 | vevent |
虚拟事件扩展 |
| 其他 | subscription |
订阅管理 |
| 其他 | bundle |
资源包管理 |
| 其他 | auditlog |
审计日志 |
| 其他 | stats |
统计 |
| 存储 | storage |
存储抽象 |
6. 生命周期与 EventBus¶
插件管理器内部使用事件总线(EventBus)在插件之间广播生命周期事件:
// EventBus 插件事件总线(泛型实现)
type EventBus struct {
listeners sync.Map // map[string][]Handler
}
// 插件监听其他插件的事件
func (p *Plugin) OnEvent(event any) {
// 通过 Container 获取 EventBus
bus := ctx.Require("eventbus").(*EventBus)
bus.Subscribe("user.login", func(e any) {
// 处理登录事件
})
}
EventBus 支持泛型类型安全的事件处理,与 lifecycle 包的生命周期事件解耦。
7. 与生命周期管理器的集成¶
插件的生命周期由 Manager 统一管理,通过 ManagerComponent 适配到 lifecycle.Manager:
func NewManagerComponent(pm *Manager) lifecycle.Component {
return lifecycle.NewSimpleComponent(
"plugin-manager",
nil, // OnStart
func(ctx context.Context) error { // OnRun
<-ctx.Done()
return nil
},
func(ctx context.Context) error { // OnStop
return pm.StopAll(ctx)
},
)
}
停止顺序:插件管理器 → 平台适配器 → Engine(插件 Teardown 在平台断开之前执行,确保插件仍能使用平台 API 做最后的清理)。
迭代过程¶
V1:继承模式(BasePlugin + Plugin 接口)¶
最初的插件系统采用经典的 OOP 继承模式:
// V1 代码 — 继承模式(根包 plugin.go)
type Plugin interface {
Name() string
Load(engine *Engine) error
Unload(engine *Engine) error
Reload(engine *Engine) error
Dependencies() []string
}
type BasePlugin struct {
name string
matchers []*Matcher
mu sync.RWMutex
}
func NewBasePlugin(name string) *BasePlugin {
return &BasePlugin{name: name}
}
// 用户插件
type MyPlugin struct {
*BasePlugin
api *MyAPI
}
func (p *MyPlugin) Load(engine *Engine) error {
p.BasePlugin.name = "myplugin"
// 直接操作 engine,权限过大
engine.OnCommand(...)
return nil
}
func (p *MyPlugin) Dependencies() []string {
return []string{"storage"} // 手写依赖,容易遗漏
}
问题清单:
| 问题 | 具体表现 | 后果 |
|---|---|---|
| 框架强耦合 | 插件导入 *Engine,依赖整个框架 |
无法独立编译、测试 |
| 样板代码 | 每个插件必须写 BasePlugin 嵌入 + 4 个方法 |
开发效率低 |
| 隐式依赖 | Dependencies() 手写字符串切片 |
依赖变更时忘记同步 |
| 权限过大 | Load(engine *Engine) 持有完整引擎引用 |
插件可以删除其他插件的匹配器 |
| 测试困难 | 必须构造完整的 *Engine 实例 |
单元测试成本高 |
| 热重载单一 | 只有 unload→load 一种策略 | 更新配置也需要停机 |
此外,插件都在根包 plugin.go 管理,随着内置插件增多(后来演变为 builtin/ 下 25+ 个包),单个文件难以维护。
V2 过渡:从继承到组合¶
在正式引入 v2 之前,框架经历了一个过渡阶段——引入了 PluginCoordinator 接口来缩小插件的框架视图:
// 过渡阶段 — PluginCoordinator 接口
type PluginCoordinator interface {
// 只暴露必要的操作
RegisterCommand(eventType string, cmd string, handler Handler) *Matcher
GetPluginInfo(name string) PluginInfo
// ...
}
但 Dependencies() 手写的问题仍然存在,插件仍然需要继承。这个阶段的一个重要经验是:接口隔离不能完全解决继承模式的问题,需要根本性的设计转变。
V3(当前):函数式 Descriptor + DryRun 依赖注入¶
v2 的正式版本完全弃用继承:
// V3 — 函数式 Descriptor
func New() *plugin.Descriptor {
return &plugin.Descriptor{
Name: "myplugin",
Version: "1.0.0",
Setup: func(ctx *plugin.SetupContext) (any, error) {
// ctx.Reg 是受限的 RegistryWriter(不是整个 Engine)
// ctx.Require 自动追踪依赖
storage := ctx.Require("storage").(Storage)
ctx.Reg.RegisterCommand("/hello", handler)
return &MyAPI{storage: storage}, nil
},
Teardown: func(ctx *plugin.TeardownContext) error {
ctx.API.(*MyAPI).Close()
return nil
},
}
}
关键改进:
| 方面 | V1 | V3(当前) |
|---|---|---|
| 模式 | 继承 BasePlugin |
纯函数 func() *Descriptor |
| 依赖声明 | Dependencies() []string 手写 |
DryRun 自动推断 + 可选显式覆盖 |
| 权限控制 | Load(engine *Engine) 全权限 |
SetupContext 受限 + Privileged 声明 |
| 测试 | 需要完整 Engine | mock SetupContext + DryRun |
| 热重载 | 仅 unload-load | UnloadLoad / InPlace / BlueGreen 三种 |
| 序列化 | 不可序列化 | Descriptor 是纯数据,可 JSON/序列化 |
| 插件搜索 | 运行时遍历 | 元数据驱动,支持商店发现 |
Smart 注册(DryRun)的原理:
// 框架在注册时模拟运行 Setup 来发现依赖
func (m *Manager) dryRunResolve(desc *Descriptor) ([]string, error) {
var deps []string
mockCtx := &SetupContext{
Require: func(name string) any {
deps = append(deps, name)
return nil // DryRun 时返回 nil,不真正初始化
},
Optional: func(name string) (any, bool) {
deps = append(deps, name)
return nil, false
},
// 其他字段为 no-op 实现
}
_, err := desc.Setup(mockCtx)
return deps, err
}
相比手写 Dependencies(),DryRun 有两个核心优势:
1. 自动精确:无论插件 Require 了多少依赖,框架精确知道——不会遗漏也不会冗余
2. 永不出现"写了但没用"的幽灵依赖:依赖声明和执行逻辑之间的关联是确定性的
V4 扩展:OptinalDeps 弱依赖¶
在实际使用中发现,一些插件(如 pluginctrl)在依赖不存在时可以降级运行,而非完全不能工作。为此引入了 OptionalDeps:
// 弱依赖 — 影响加载顺序但不存在时不报错
type Descriptor struct {
Deps []string // 强依赖:必须存在
OptionalDeps []string // 弱依赖:存在时调整加载顺序,不存在时不报错
}
// 使用示例 — builtin/pluginctrl
func New() *plugin.Descriptor {
return &plugin.Descriptor{
Name: "pluginctrl",
OptionalDeps: []string{"storage"}, // 有 storage 就持久化,没有就内存运行
Setup: func(ctx *plugin.SetupContext) (any, error) {
if storage, ok := ctx.Optional("storage"); ok {
// 持久化模式
return &PluginCtrl{store: storage.(Storage)}, nil
}
// 内存模式
return &PluginCtrl{}, nil
},
}
}
迭代历程¶
| 版本 | 核心变化 | 动机 |
|---|---|---|
| V1 | 继承模式(BasePlugin + Plugin 接口) | 快速实现插件能力 |
| V2 过渡 | PluginCoordinator 接口隔离 | 缩小框架接触面 |
| V3(当前) | 函数式 Descriptor + DryRun 自动依赖 | 解耦、可测试、权限隔离 |
| V4 扩展 | OptionalDeps 弱依赖 | 优雅降级能力 |
设计权衡¶
| 方面 | 选择 | 理由 |
|---|---|---|
| 组合 vs 继承 | 纯函数式描述符 | 易测试、无框架耦合 |
| 依赖声明 | Smart DryRun + 手动覆盖 | 减少样板,保留灵活性 |
| 热重载 | 三种策略可选 | 不同场景最优解 |
| 权限 | SetupContext / ManagerWriter 分离 | 最小权限原则 |
| 服务容器 | sync.Map + 原子快照 | 注册灵活,读取高效 |