架构演进之路——从 QQ 专用 Bot 到通用框架¶
这个框架不是一天建成的。从 2025 年 12 月的第一个 commit 到今天,经历了数百次重构。 回顾演进路径,可以看到每个阶段解决的核心问题以及背后的设计决策。 而一切的起点,来自对 wdvxdr1123/ZeroBot 的研读与借鉴。
第零阶段:ZeroBot 启蒙——从使用者到创作者¶
关键词:学习借鉴、模式识别、意识到天花板
起点:为什么要看 ZeroBot?¶
Remilia 最初的作者是 ZeroBot 的深度用户。ZeroBot 是一个优雅的 OneBot 框架——小而美、API 简洁、上手快。但在实际使用中,逐渐遇到了瓶颈:
- 单平台锁定:ZeroBot 紧绑 OneBot 协议,无法对接 Discord/Telegram
- 性能天花板:事件路由是线性扫描,100+ Matcher 后延迟不可控
- 无基础设施:熔断、限流、链路追踪等企业级能力全无
- 插件系统原始:基于
init()+ 全局变量,无法热重载、无法 DI - 生命周期空白:启动关闭全靠手动编排
这些痛点催生了一个想法:能不能以 ZeroBot 的设计为起点,构建一个更通用、更健壮、多平台支持的框架?
ZeroBot 的核心架构¶
ZeroBot/
├── bot.go # 主入口:Run/RunAndBlock,事件处理循环
├── api.go # OneBot API 绑定
├── context.go # Ctx:Send、SendChain、Echo、FutureEvent
├── engine.go # Engine:On* 触发器工厂 + Matcher 管理
├── matcher.go # Matcher、Rule、Handler、State、Priority
├── types.go # Event、User、File、Group 等类型
├── rules.go # PrefixRule、CommandRule、RegexRule
├── event_channel.go # FutureEvent:交互式事件等待
├── pattern.go # 链式消息段匹配器
├── driver/ # 通信驱动(wsclient/wsserver/http)
└── message/ # 消息段模型 + CQ 码编解码
核心事件流:
OneBot 实现 → Driver → json.Unmarshal → Event
→ match(ctx, matcherList) [线性扫描,按优先级排序]
→ preHandler → Rules → midHandler → Handler → postHandler
→ ctx.Send() / ctx.CallAction()
从 ZeroBot 继承的设计基因¶
| ZeroBot 模式 | Remilia 继承方式 | 后续演变 |
|---|---|---|
| Matcher + Rule + Handler 三元组 | 概念完全继承,类型签名几乎一致 | 新增 commandIndex O(1) 路由 + TempManager 分片 |
| Engine 作为 Matcher 容器 | 继承,但改为 COW 不可变状态 | atomic.Value + 泛型,消除锁竞争 |
| Priority 优先级排序 | 继承 | 扩展为 6 路合并排序 + 缓存失效 |
| preHandler / midHandler / postHandler | 继承管道概念 | 扩展为三层洋葱模型 + 版本计数器优化 |
| Ctx.Send() / Ctx.CallAction() | 保持相同的调用风格 | 扩展 platform.Sender 跨平台发送 |
| FutureEvent 临时匹配器 | 继承 | 抽离为 TempManager 独立管理 |
| Event 预处理(IsToMe、At 检测) | 继承 | 平台无关化,每个 Adapter 各自实现 |
最初的 Remilia 长什么样?¶
// 第一个 commit 的 bot.go — 与 ZeroBot 高度相似
type Bot struct {
wh webhook.WebHook // QQ Webhook → 参考 ZeroBot 的 driver
tm *token.Manager // QQ Token
api openapi.OpenAPI // QQ OpenAPI → 参考 ZeroBot 的 APICaller
engine *Engine // Engine → 概念源于 ZeroBot
}
// 第一个 commit 的 context.go — 几乎就是 ZeroBot Ctx 的翻版
type Context struct {
event *dto.Payload // 参考 ZeroBot Event
api openapi.OpenAPI // 参考 ZeroBot APICaller
state State // 完全复制 ZeroBot State
}
最初的目标是:做 ZeroBot 能做的事,但做到更好。后来目标变成了:做 ZeroBot 做不到的事。
从借鉴到超越的关键分叉点¶
| 决策 | ZeroBot 做法 | Remilia 最初做法 | 最终演进 |
|---|---|---|---|
| 并发模型 | sync.Mutex | sync.RWMutex | COW + atomic.Value(完全无锁读) |
| 平台支持 | 仅 QQ/OneBot | 仅 QQ(openapi) | 7 个平台适配器 + 插件系统 |
| 插件系统 | init() 全局注册 | 继承 BasePlugin | 函数式 Descriptor + DI 容器 |
| 生命周期 | 无 | Bot 内嵌管理 | lifecycle 独立包 |
| 中间件 | pre/mid/post 三阶段 | 同左 | 洋葱模型 + 企业级中间件 |
| 路由 | 线性 O(n) | 线性 O(n) | commandIndex O(1) + 6 路合并 |
| 日志 | 简单日志 | logrus | zerolog 零分配 |
| 观测 | 无 | 无 | Prometheus + OTel + Health |
参考文档¶
关于 ZeroBot 与 Remilia 的详细逐项对比(代码级),请参阅:
- 11-zerobot-inspiration.md — 本文的姊妹篇,完整技术对比
- ../06-archived/comparison-zerobot-floattech.md — 框架层与 FloatTech 系列库对比
- ../06-archived/comparison-zerobotplugin.md — 业务插件层对比
第一阶段:Monolithic QQ Bot(初始态)¶
关键词:单一职责、快速验证、紧耦合
初始目录结构¶
remilia/ # 所有代码都在根包
├── bot.go # Bot 入口(紧耦合 QQ openapi)
├── engine.go # 事件引擎(内含死信队列、状态管理)
├── context.go # 事件上下文(携带 dto.Payload + openapi.OpenAPI)
├── plugin.go # 插件系统 v1
├── matcher.go # 匹配器
├── rules.go # 匹配规则
├── middleware/ # 中间件(独立的子包)
├── openapi/ # QQ 官方 API SDK(内置)
├── errors.go # 错误处理
├── config/ # 配置管理
├── helper/ # 辅助函数
└── docs/archive/ # 大量历史文档
核心问题¶
1. Bot 与 QQ API 深度绑定
// 初始 bot.go
type Bot struct {
wh webhook.WebHook // QQ Webhook
tm *token.Manager // QQ Token
api openapi.OpenAPI // QQ OpenAPI
engine *Engine
}
所有核心结构都直接引用 dto.Payload(QQ 的消息结构):
// 初始 context.go
type Context struct {
event *dto.Payload // 直接依赖 QQ 数据结构
api openapi.OpenAPI // 直接依赖 QQ API
state State
}
// 初始 engine.go 的死信队列
type DeadLetterItem struct {
Event *dto.Payload // 强依赖 QQ 类型
Err error
Attempt int
}
2. 插件系统基于继承(v1 OOP 模式)
// 初始 plugin.go — 经典的 OOP 继承模式
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
}
每个插件继承 BasePlugin,覆写生命周期方法。这种方式的问题是:
- 样板代码多:每个插件都要写相同的结构
- 框架耦合:插件持有 *Engine 引用,可以做任何操作
- 依赖关系需要手写 Dependencies() 方法
3. 日志系统使用 logrus
// 初始代码使用 logrus
"github.com/sirupsen/logrus"
4. 庞大的 docs/archive 目录
记录了从 v1.2 到 v2.0 的每一次升级的详细设计文档——这是框架演进的重要见证。
本阶段典型用法¶
func main() {
eng := remilia.NewEngine() // 引擎在根包
adapter := webhook.NewWebHook(...) // QQ Webhook
bot := remilia.NewBot(adapter, eng) // Bot 在根包
bot.Start()
bot.WaitForShutdown()
}
第二阶段:引擎抽取 → 可测试性¶
驱动因素:引擎逻辑不断膨胀,根包 engine.go 达到 800+ 行,包含死信队列、状态管理、匹配器操作、清理器等。需要拆分 + 可测试性。
关键变化¶
# 引擎从根包拆分为独立子包
remilia/engine/ → core/engine/
remilia/context → core/context/
remilia/matcher → 合并入 core/engine/
remilia/rules → 合并入 core/context/
COW 引擎的诞生¶
在初始版本中引擎就已经使用了 COW 模式,但当时的实现较为粗糙:
// 初始引擎状态 -- 类型不安全
type Engine struct {
state atomic.Value // *engineState — 需要到处类型断言
middleware atomic.Value // *middlewareState
writeMu sync.Mutex
}
演进到泛型版本:
// 后续引入 infraatomic.Value 泛型封装
type Engine struct {
state *infraatomic.Value[*state]
middleware *infraatomic.Value[*middlewareState]
}
引擎内部的演进路径¶
V1(初始) V2(抽出 TempManager) V3(当前)
engine.go 800+ 行 core/engine/ core/engine/
├── Engine + 状态 ├── engine.go (核心定义) ├── engine.go (核心定义)
├── 死信队列 ├── engine_state.go ├── state.go (不可变状态)
├── 匹配器操作 ├── engine_matcher_ops.go ├── engine_matcher_ops.go
├── 清理器 ├── engine_command.go ├── engine_command.go
├── 批量处理 ├── engine_query.go ├── engine_query.go
├── temp_manager.go (新增) ├── temp_manager.go
├── middleware.go ├── middleware.go
├── process.go ├── process.go
├── process_platform.go (新增) ├── process_platform.go
├── component.go (新增组件抽象) ├── component.go
├── services.go (新增集中管理) ├── services.go
└── runtime.go └── runtime.go
关键洞察:temp_manager.go 的引入是为了隔离"临时 Matcher"(一次性/带过期时间)与永久 Matcher。如果没有这个隔离,每次事件处理都需要遍历所有临时 Matcher 检查过期,导致性能不可预测。
第三阶段:Context 改造 → Pool 化 + 平台无关 → 去 Pool¶
驱动因素:
- Context 在高并发下大量创建/销毁,GC 压力大
- Context 仍紧耦合 dto.Payload 和 openapi.OpenAPI
- 需要支持平台无关的事件传递
Context Pool(已废弃)¶
// 引入对象池,高并发下显著降低 GC 压力(V2 阶段)
var contextPool = sync.Pool{
New: func() any { return &Context{state: make(State)} },
}
func AcquireContext() *Context {
return contextPool.Get().(*Context)
}
func ReleaseContext(ctx *Context) {
ctx.reset()
contextPool.Put(ctx)
}
性能提升:池化后 0 allocs/op,无 GC 压力。
去 Pool 决策(V4 阶段)¶
池化虽然降低了 GC 压力,但引入了一个严重的安全问题:
// 插件开发者可能写出这样的代码
go func() {
ctx.Reply("hi") // ctx 可能已被池回收 → 发送到错误会话
}()
核心矛盾:池化 + goroutine 捕获 = Use-After-Free。sync.Pool 回收的 Context 可能被分配给下一个事件,而之前事件中 goroutine 捕获的 *Context 指针指向的对象已经属于新事件。
技术选型数据:
| 方案 | 分配 | 安全性 | 复杂度 |
|---|---|---|---|
| sync.Pool 池化 | 0 alloc/event | ❌ UAF | 中(refCount/Retain/reset) |
| 新鲜分配 &Context{} | 272 B/event, 1 alloc | ✅ 安全 | 低 |
| 双层 + 世代号 | 80 B/event, 1 alloc | ✅ 检测 | 高(atomics + generation) |
以 474k msg/s 吞吐量计算,新鲜分配增加 1.8% CPU + 114 MB/s 分配率。Go GC 可轻松处理(稳定态下 < 0.5% 开销),因此选择完全去池化。
同时消除了池化衍生的复杂机制:
- ctxMu sync.RWMutex — 用于保护 std context,但池化回收是它存在的唯一原因。去池化后中间件在 handler 前串行执行 SetStdContext,goroutine 只读,无需锁
- refCount / Retain() — 用于 ExecPool offload 路径确保池不回收运行中的 Context。去池化后不需要
- reset() — 用于清空池对象的旧事件数据。去池化后不需要
- Release() / ReleaseContext() / ReleaseContextFromEvent() — 池化时代的"归还"操作。去池化后已删除
// V4(当前):每事件新鲜分配,不复用
func NewContextFromEvent(event platform.Event, sender platform.Sender) *Context {
return &Context{
platformEvent: event,
platformSender: sender,
}
}
Context 平台无关化¶
// V1: Context 持有 dto.Payload
type Context struct {
event *dto.Payload
api openapi.OpenAPI
}
// V2: Context 支持双路径(适配器模式)
type Context struct {
// 旧路径
event *dto.Payload
api openapi.OpenAPI
// 新路径
platformEvent platform.Event
platformSender platform.Sender
isPlatformPath bool // 路径选择器
}
最终演进为完全抛弃旧路径:
// V3(当前):完全基于 platform.Event
type Context struct {
platformEvent platform.Event
platformSender platform.Sender
botID string
// ... 其他通用字段
}
第四阶段:插件系统革命 v1 → v2¶
驱动因素:
- 继承模式不够灵活,插件与框架紧耦合
- 热重载只有一种策略(unload-load)
- 权限控制缺失——插件持有 *Engine 可以做任何事
- 依赖管理靠手写 Dependencies(),容易出错
演进路径¶
v1 (继承模式) v2 (函数式描述符)
━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━
Plugin 接口 (Load/Unload/Reload) Descriptor + Setup/Teardown
BasePlugin 基类 无基类,纯函数
Dependencies() 手写 Smart DryRun 自动推断
Engine 完全访问 SetupContext 受限视图
热重载: 仅 unload-load + InPlace + BlueGreen
插件在根包 plugin.go 独立 plugin/ 包
BlueGreen 重载的引入¶
// v2 新增蓝绿部署策略
ReloadBlueGreen: 新实例 Setup → 原子切换 → 旧实例 Teardown
// 停机窗口对比
// unload-load: Teardown旧 → Setup新 → 有窗口(~100ms-1s)
// in-place: Reload函数 → 无窗口(但开发者负责)
// blue-green: Setup新 → 原子切换 → 旧Tear → 无窗口(框架保证)
依赖注入容器¶
// v1: 插件之间通过全局变量或 Engine 互相访问
// 问题:隐式依赖,测试困难,竞态条件
// v2: 依赖注入容器
type Container struct {
services sync.Map // 注册阶段
frozen atomic.Bool // 冻结标志
frozenMap atomic.Pointer[map[string]any] // 只读快照
}
// 插件通过 SetupContext.Require("storage") 显式声明依赖
内置插件的演进¶
v1: plugin.go (一个文件管理所有插件注册)
v2: builtin/ (25+ 独立包,每个包一个插件)
├── core/help/ # 帮助命令自动发现
├── core/admin/ # 管理命令
├── core/permission/ # 权限管理
├── acl/ # 访问控制
├── antispam/ # 反垃圾
├── auditlog/ # 审计日志
├── broadcast/ # 广播
├── bundle/ # 资源包
├── calendar/ # 日历
├── conversation/ # 对话
├── cooldown/ # 冷却
├── i18n/ # 国际化
├── idiomdict/ # 成语词典
├── job/ # 任务系统
├── keywordfilter/ # 关键词过滤
├── messagelog/ # 消息日志
├── pluginctrl/ # 插件控制
├── pluginstore/ # 插件商店
├── ratelimitui/ # 限流 UI
├── scheduler/ # 调度器
├── sendqueue/ # 发送队列
├── stats/ # 统计
├── storage/ # 存储
├── subscription/ # 订阅
├── verifycode/ # 验证码
└── vevent/ # 虚拟事件
第五阶段:多平台抽象(最关键的重构)¶
驱动因素:需要支持 Discord、Telegram、微信等更多平台。初始架构所有代码都依赖 openapi/dto。
三层抽象¶
┌──────────────────────────────────────────────┐
│ platform.Event │ ← 平台无关事件
│ Platform() string │
│ Kind() EventKind │
│ Raw() any │
│ GetMessage() / GetSender() / GetGroup() │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ platform.Adapter │ ← 平台适配器接口
│ Platform() string │
│ Start(ctx, func(Event)) error │
│ Stop(ctx) error │
│ Sender() Sender │
│ Capabilities() Capabilities │
│ IsRunning() bool │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ platform.Registry │ ← 多平台注册表
│ Register(adapter) │
│ All() []Adapter │
└──────────────────────────────────────────────┘
适配器演进¶
# QQ 适配器是第一个,也是最复杂的
platform/qq/
├── adapter.go # 包装 dto.Payload → platform.Event
├── webhook_server.go # Webhook HTTP Server
├── webhook_conn.go # WebSocket 连接
├── sender.go # platform.Sender 实现
└── event.go # QQ 事件转换
# 后来逐步添加
platform/discord/ # DiscordGo SDK 封装
platform/telegram/ # Telegram Bot API
platform/onebot/ # OneBot v11 协议
platform/satori/ # Satori 协议
platform/wechat/ # 微信
platform/milky/ # Milky QQ 协议
最关键的 commit¶
0709f98 — feat(platform): complete multi-platform abstraction migration
这个 commit 完成了:
1. 定义 platform.Event / platform.Sender / platform.Adapter 接口
2. core/context 双路径改造(兼容旧 dto.Payload 路径)
3. core/engine.ProcessPlatformEvent 新入口
4. 所有测试 21 个新增
// 新增平台无关入口(V3)
func (e *Engine) ProcessPlatformEvent(event platform.Event, sender platform.Sender) {
ctx := context.NewContextFromEvent(event, sender)
e.processEventContext(ctx)
}
// V4 统一入口:ProcessEvent 和 ProcessPlatformEvent 共用 ExecPool 自适应路径
// 慢 handler 自动 offload,池满 fallback 同步
func (e *Engine) ProcessEvent(ctx *context.Context) {
e.processEventGuard(ctx)
}
// ProcessPlatformEvent 同样走 processEventGuard
第六阶段:生命周期系统化¶
驱动因素:启动/关闭顺序越来越复杂,Engine、PluginManager、Adapters 之间的启动依赖和关闭顺序需要统一管理。
演进¶
V1: bot.go 内嵌启动逻辑 V2: lifecycle.Component 接口 V3(当前)
bot.Start() 里手动编排 lifecycle.NewManager() lifecycle + 双层 Context
bot.Stop() 里手动编排 component.OnStart/OnRun/OnStop
SimpleComponent 简化创建
关键变化:从"Bot 内部编排"演进到"独立的 lifecycle 包",Bot 只是其中一个使用者。
// V1 — Bot 手动管理
func (b *Bot) Start() {
b.wh.Start(b.handleEvent) // 1. 启动 Webhook
go b.engine.StartCleaner() // 2. 启动清理器
}
func (b *Bot) Stop() {
b.wh.Stop() // 逆序
b.engine.StopCleaner()
}
// V3(当前)— lifecycle 统一管理
func (b *Bot) Start() {
b.lifecycle.Register(engineComp)
b.lifecycle.Register(adapterComp)
b.lifecycle.Register(pluginComp)
b.lifecycle.Start(ctx) // 框架管理顺序
}
双层 Context 的设计也是演变出来的:
V1: 一个 Context 贯穿全部 V2: 区分 parentCtx / runCtx
bot.Start(ctx) → context 传递给所有 Start: parentCtx
├─ OnStart (准备)
└─ runCtx (运行时)
Stop:
├─ cancel(runCtx)
├─ 等待 OnRun 退出
├─ 逆序 OnStop
└─ cancel(parentCtx)
这个演进的关键动机:插件 Teardown 时还需要使用平台 API 发消息。如果 parentCtx 在 Stop 一开始就被取消,插件在 Teardown 中调用 ctx.Reply() 将立刻失败。
第七阶段:基础设施沉淀¶
随着 core/engine、plugin/、middleware/ 逐渐稳定,通用工具代码被提取到 infra/:
infra/
├── atomic/ # 泛型 atomic.Value(从 engine 中提取)
├── pool/ # 泛型对象池(从 context pool 中抽象)
├── syncx/ # 并发工具(从各模块提取)
├── health/ # 健康检查框架(从 bot 中提取)
├── metrics/ # Prometheus 封装(从 engine 中提取)
├── tracing/ # OpenTelemetry 封装(从 middleware 中提取)
├── server/ # HTTP Server 封装(从 webhook 中提取)
├── httpclient/ # HTTP 客户端(从 openapi 中提取)
├── textimage/ # 文本渲染引擎
├── dlq/ # 泛型死信队列
├── zhtext/ # 中文文本处理
├── audit/ # 操作审计
├── cache/ # TTL 缓存
├── coredump/ # 跨平台 coredump
├── option/ # Option 模式封装
└── fs/ # 懒加载文件系统
第九阶段:FSM + Adaptive Router + WASM + Per-Channel Engine(v1.2.0)¶
驱动因素:Bot 单引擎架构在多频道共享、多步对话、三方插件扩展三个方向上同时触到天花板。
三大问题的统一解决¶
| 问题 | 方案 | 文档 |
|---|---|---|
| 多频道共享 Engine,A 服命令影响 B 服 | Per-Channel Engine:按 guild/group 隔离 Engine 实例 | 15-per-channel-engine.md |
| 多步对话靠插件内部手写状态机 | FSM Engine:声明式状态机 + 会话管理 | 12-fsm-engine.md |
| Engine 分发逻辑硬编码在 bot.go | Adaptive Router:策略化规则链 | 13-adaptive-router.md |
| 插件无法安全加载第三方代码 | WASM Plugin:wazero 沙箱运行时 | 14-wasm-plugin.md |
FSM Engine(core/fsm/)¶
FSM Engine 将多步对话从"插件内手写状态映射表"提升为声明式状态机描述:
descriptor := &fsm.FSMDescriptor{
Name: "registration",
FSM: &fsm.FSM{
Initial: StateIdle,
States: []fsm.State{StateIdle, StateAwaitName, StateAwaitAge},
Transitions: []fsm.Transition{
{From: StateIdle, Match: cmdMatch("/register"), Action: start},
{From: StateAwaitName, Action: collectName},
{From: "*", Match: cmdMatch("/cancel"), Action: cancel},
},
},
}
关键设计:
- TryTransition 按注册顺序遍历 Transition,匹配 From + Match 谓词,执行 Action
- From="*" 通配符支持(如全局 /cancel)
- OnEnter/OnExit 回调 + 失败回滚(状态一致性保证)
- 会话过期 + 后台 Cleanup goroutine
- Storage 接口 + MemoryStorage 默认实现
Adaptive Router(router/)¶
Router 将 bot.go 中的硬编码 if/else 分发链抽象为可配置的 RouteRule 列表:
router.AddRule(&router.RouteRule{
Name: "commands",
Strategy: router.StrategyEngine,
Match: router.WithCommandPrefix("/"),
})
router.AddRule(&router.RouteRule{
Name: "fsm",
Strategy: router.StrategyFSM,
Match: router.WithFSMRoute(fsmManager),
})
三种策略:Engine(传统匹配器)、FSM(活跃会话)、Agent(预留)。
最终 bot.handlePlatformEvent 采用三阶段路由:engineManager > router > engine。
WASM Plugin(plugin/wasm/)¶
基于 wazero v1.11.0 的纯 Go WASM 运行时,提供真正的沙箱隔离:
Host (Go) Guest (WASM)
│ │
├─ malloc ──────────→│ 分配线性内存
│←────── ptr ───────┤
├─ write JSON ──────→│ 写入输入数据
├─ plugin_handle ───→│ 调用处理函数
│←────── JSON ──────┤ 读取输出
│ │
└─ remilia_host ────→│ 宿主函数:log/get_config/reply
- Bridge 将 WASM
plugin_init返回的注册请求转为 Engine Matcher - TokenBucket 限流 + 内存上限沙箱
- 独立 Manager(不修改
plugin.Manager)
Per-Channel Engine(core/engine/)¶
每个频道(guild/group/private chat)获得独立的 Engine 实例:
// template 定义全局匹配器
forkEng := template.ForkFrom(template)
forkEng.RegisterMatcher(channelOnlyMatcher) // 仅该频道可见
// 模板变更通过 syncTemplates 传播
template.RegisterMatcher(globalMatcher) // 所有 fork 延迟可见
Engine.fork新字段 +templateVeratomic.Int64 版本检测processEventGuard惰性同步:事件处理时发现模板版本变化才 syncEngineManager:sync.Map[ChannelKey]*Engine+evictIdleGC +StatsbumpVersion在所有 matcher 变更操作末尾调用
迭代历程¶
| 版本 | 核心变化 |
|---|---|
| v1.2.0 | FSM Engine + Adaptive Router + WASM Plugin + Per-Channel Engine |
第八阶段:去池化 + ExecPool 统一 + Context 精简¶
驱动因素:
- Context 池化导致 UAF:goroutine 捕获的 *Context 引用可能在池回收后指向新事件
- ProcessEvent 和 ProcessPlatformEvent 两套路径增加了不必要的复杂性
- 池化衍生的 ctxMu/refCount/Retain/reset 等机制成为技术债务
去 Pool 决策¶
性能基准(474k msg/s 吞吐量下实际测试):
| 方案 | 耗时 | 内存 | 每事件 |
|---|---|---|---|
| sync.Pool 池化 | ~69 ns | 32 B | 2 allocs |
| 新鲜分配 &Context{} | ~107 ns | 272 B | 3 allocs |
| 差异 | +38 ns (+55%) | +240 B | +1 alloc |
全链路(引擎级):~417 ns → ~456 ns(+9%)。换算到 474k msg/s 吞吐量: - CPU:18 ms/s = 1.8% - 分配率:129 MB/s(vs 池化 15 MB/s),Go GC 零压力
结论:1.8% CPU 开销换取完全安全的 goroutine 生命周期,值得。
删除的机制¶
| 旧机制 | 代码量 | 用途 | 替代 |
|---|---|---|---|
sync.Pool |
3 行 | 池化复用 | 无 |
refCount |
2 字段 + 2 方法 | 跟踪引用 | 无 |
Retain() |
5 行 | offload 前加引用 | 无 |
reset() |
22 行 | 清空旧数据防泄漏 | 无 |
Release() |
12 行 | 归还到池 | 无 |
ReleaseContext() |
8 行 | 兼容 wrapper | 无 |
ReleaseContextFromEvent() |
8 行 | 兼容 wrapper | 无 |
ctxMu sync.RWMutex |
12 行 | 保护 std context | atomic.Pointer[ctxHolder] |
ProcessEvent 统一¶
原有两个入口:
- ProcessEvent → 同步,不走池(用于内部测试)
- ProcessPlatformEvent → 自适应 ExecPool
现在统一为一个:所有入口均走 ExecPool 自适应路径。慢 handler 自动 offload,池满时 fallback 同步。
// V4:统一入口
func (e *Engine) ProcessEvent(ctx *context.Context) {
e.processEventGuard(ctx)
}
func (e *Engine) ProcessPlatformEvent(...) {
ctx := context.NewContextFromEvent(event, sender)
e.processEventGuard(ctx)
}
ExecProfile 算法的演进¶
V1: 默认同步,慢后提升(保守) → 第一次 handler 固定同步
V2: 默认池化,怀疑所有 handler 慢(激进) → 第一次 handler 就 offload
V3 (当前): 默认池化 + 数据驱动降级 → 慢 handler 自适应
当前执行流程:
ShouldPool():
promoted → 走池 ✓
数据不足 → 走池 ✓(默认怀疑慢)
p50 > 50ms → promoted,走池 ✓
p50 < 25ms & 连续 10 次快 → ExecDirect
其他 → 走池 ✓
测试环境通过 WithExecPoolDisabled() 选项禁用池,确保所有 handler 同步执行:
eng := engine.NewEngine(engine.WithExecPoolDisabled())
NewContextFromEvent → Cancel()¶
Clone 仍然保留(用途:创建与中间件超时隔离的异步副本)。Clone 内部通过 context.WithCancel/WithDeadline 创建独立的 context 链,cancel 函数存储在 Context.cancel 字段。调用 ctx.Cancel() 可主动释放。不调用也不泄露——GC finalizer 会在 *Context 不可达后自动关闭 done channel。
// 异步操作的标准模式
func handler(ctx *Context) error {
clone := ctx.Clone() // 隔离中间件超时
go func() {
defer clone.Cancel() // 主动释放(可选)
// ... 长时间操作
}()
return nil
}
2025-11 ZeroBot 启蒙——深度使用 → 发现瓶颈 → 萌生自研想法
│
├── 研究 ZeroBot 架构(Matcher/Engine/Context 模式)
├── 识别 ZeroBot 天花板(单平台/O(n)路由/无生命周期)
└── 初期目标:"做 ZeroBot 能做的事,但做得更好"
│
2025-12 Monolithic QQ Bot (根包、dto.Payload、logrus、v1 插件)
│ ↑ 保留了 ZeroBot 的核心模式,
│ 但开始使用不同技术栈
├── 引擎抽取 core/engine
├── Context 池化
├── 插件描述符 + 依赖注入
│
2026-01 多引擎 + Context v2
│
├── platform/adapter 抽象 ← 此时已决定"超越 ZeroBot"
├── 日志替换 zerolog
├── lifecycle 独立包
│
2026-02 Plugin v2 正式版
│
├── 蓝绿热重载
├── 多平台 (Discord/Telegram/OneBot)
├── infra 基础设施沉淀
│
2026-03 平台迁移完成
│
├── 内置 25+ 插件
├── textimage 引擎
└── v1.0.0 发布
│
2026-05 V4 重构
│
├── Context 去池化(新鲜分配,消除 UAF)
├── ProcessEvent 统一(全部走 ExecPool)
├── ctxMu → atomic.Pointer(无锁 std context)
├── 删除 refCount/Retain/reset/Release 全套机制
└── ExecPool 默认池化(怀疑所有 handler 慢)
│
2026-05 v1.2.x — 四阶段架构升级
│
├── Phase 1: State Machine (core/fsm/)
│ ├── 声明式 FSM,替代 if state == x
│ ├── TryTransition / TryStartSession / EndSession
│ ├── 终端过渡语义 + ended 标记
│ └── FSMContext.EndSession() 直观清理
│
├── Phase 2: Adaptive Router (router/)
│ ├── Priority 排序的 RouteRule 链
│ ├── FSM 作为内建规则(Priority=-1000)
│ ├── Handle 回调替代 Strategy 枚举
│ └── dispatchToEngine 统一 Engine/EngineManager 分发
│
├── Phase 3: WASM Plugin (plugin/wasm/)
│ ├── wazero v1.11.0 沙箱运行时
│ ├── HostFuncRegistry + Bridge → Engine Matcher
│ └── TokenBucket 限流 + ResourceLimit
│
└── Phase 4: Per-Channel Engine (core/engine/)
├── EngineManager + ForkFrom + syncTemplates
├── createMu 保护并发首次创建
├── ForkFrom 同步 middleware + ExecPool 共享
└── GC 首次 Dispatch 自动启动
关键决策时刻¶
| 决策 | 当时的选择 | 替代方案 | 为什么 |
|---|---|---|---|
| COW 引擎 | 写时复制 + atomic.Value | ZeroBot 的 sync.Mutex | 读多写少场景,5-6x 性能提升 |
| 插件 v2 | 函数式 Descriptor | ZeroBot 的 init() 全局注册 | 解耦、可测试、灵活 |
| 多平台抽象 | Adapter 接口 | ZeroBot 的 OneBot 单平台 | 跨平台 Handler 复用 |
| Context Pool | → 新鲜分配(V4) | sync.Pool | 池化导致 UAF,goroutine 不安全。474k msg/s 下 1.8% 开销可接受 |
| 生命周期 | lifecycle 独立包 | ZeroBot 无生命周期,Bot 内手工编排 | 组件化、可复用的生命周期管理 |
| 日志 | zerolog | ZeroBot 无零分配要求 | 零分配日志对性能关键 |
| O(1) 命令路由 | commandIndex + Trie | ZeroBot 线性扫描 O(n) | 100+ 命令时延迟不增长 |
| 中间件链 | 洋葱模型 + 三层级 | ZeroBot pre/mid/post 三阶段 | 从"三阶段"升级为"可组合链" |