跳转至

命令系统——双索引 O(1) 路由 + 前缀树补全

ZeroBot 基因:ZeroBot 通过 CommandRule("cmd") 将命令匹配混入普通 Rule 中,与其他 Matcher 一起线性扫描。Remilia 引入 commandIndex 独立索引 + Trie 前缀树,将命令路由从 O(n) 降至 O(1)。参阅 11-zerobot-inspiration.md

架构概览

命令系统是框架中最频繁使用的功能。Remilia 使用双索引策略:

命令注册 → command.Trie(前缀树,用于模糊搜索和补全)
         → engine.commandIndex(hash map,用于 O(1) 路由)
         → engine.matcherIndex(事件类型索引,用于全量排序)

三个索引各司其职:

索引 位置 用途 时间复杂度
commandIndex Engine state 事件热路径精确路由 O(1)
command.Trie command 包 命令补全、模糊搜索 O(k) k=命令长度
matcherIndex Engine state 非命令事件路由 O(n) n=匹配器数

1. commandIndex:O(1) 命令路由

// state.commandIndex 结构
commandIndex map[string]map[EventType][]*Matcher
//  命令名        事件类型      匹配器列表
//  "/help"  →   ""        →  [matchers]
//                "C2C"     →  [matchers]

当事件消息以触发前缀(如 /)开头时,提取命令名直接从 commandIndex 获取候选匹配器:

func processMessage(ctx *context.Context) {
    msg := ctx.GetMessageContent()

    // 检测命令前缀
    if len(msg) > 0 && msg[0] == '/' {
        cmdName, args := parseCommand(msg)
        // O(1) 查找
        if cmdMap, ok := state.commandIndex[cmdName]; ok {
            matchers := mergeCmdMatchers(cmdMap, eventType)
            // ...
        }
    }
}

这个优化使得命令路由延迟完全不受匹配器总数影响——即使有 10 万个匹配器,/help 仍然在一两微秒内完成路由。

命令索引维护

func (s *state) withAddedMatcher(m *Matcher) *state {
    if m.commandIndexed.Load() {
        cmdName := extractCmdName(m)
        newCommandIndex := copyCommandIndex(s.commandIndex)
        // 插入到特定事件类型下
        newCommandIndex[cmdName][m.EventType] =
            append(newCommandIndex[cmdName][m.EventType], m)
        // ...
    }
}

commandIndexed 标志由 OnCommand 注册时设置,只有命令类匹配器进入此索引。

2. Trie 前缀树:命令发现与补全

type TrieNode struct {
    children map[rune]*TrieNode
    commands []*Meta              // 该节点关联的命令
    isEnd    bool
}

type Trie struct {
    root *TrieNode
    mu   sync.RWMutex
}

type Meta struct {
    Name        string
    Description string
    Usage       string
    Aliases     []string
    Category    string
    Plugin      string
}

插入

func (t *Trie) Insert(name string, meta *Meta) {
    t.mu.Lock()
    defer t.mu.Unlock()

    node := t.root
    for _, r := range []rune(name) {
        if node.children[r] == nil {
            node.children[r] = &TrieNode{
                children: make(map[rune]*TrieNode),
                commands: make([]*Meta, 0),
            }
        }
        node = node.children[r]
    }
    node.commands = append(node.commands, meta)
    node.isEnd = true
}

前缀搜索(命令补全)

func (t *Trie) Search(prefix string) []*Meta {
    t.mu.RLock()
    defer t.mu.RUnlock()

    node := t.root
    for _, r := range []rune(prefix) {
        if node.children[r] == nil {
            return nil  // 无匹配
        }
        node = node.children[r]
    }

    // DFS 收集所有终端节点命令
    var result []*Meta
    seen := make(map[*Meta]bool)
    t.collectTerminalCommands(node, seen, &result)
    return result
}

输入 /he 时,通过 Trie Search 可以即时返回 /help/hello/heartbeat 等候选命令。

删除与修剪

func (t *Trie) Remove(name string, meta *Meta) {
    t.mu.Lock()
    defer t.mu.Unlock()

    node := t.root
    path := make([]*TrieNode, 0, len(name)+1)
    path = append(path, node)

    for _, r := range []rune(name) {
        if node.children[r] == nil { return }
        node = node.children[r]
        path = append(path, node)
    }

    node.commands = removeCommandFromSlice(node.commands, meta)
    if len(node.commands) == 0 {
        node.isEnd = false
    }

    // 从叶到根修剪空节点
    for i := len(path) - 1; i > 0; i-- {
        n := path[i]
        if !n.isEnd && len(n.children) == 0 && len(n.commands) == 0 {
            delete(path[i-1].children, runes[i-1])
        } else {
            break
        }
    }
}

自动修剪确保无命令的分支被及时回收,避免内存泄漏。

3. 命令注册 API

// Engine 层
func (e *Engine) OnCommand(eventType EventType, cmdPattern string, extraRules ...context.Rule) *Matcher {
    // 1. 创建匹配器,标记 commandIndexed
    // 2. 注册到 engine state(自动进 commandIndex)
    // 3. 同步到外部队列 command.Trie
    // 4. 绑定别名自动注册
}

别名支持

type Definition struct {
    Name        string
    Aliases     []string    // 别名列表
    Description string
    Usage       string
    Category    string
    // ...
}

别名注册通过 AliasRegistrar 回调自动完成:

primary.SetAliasRegistrar(func(def *command.Definition, h context.Handler) {
    for _, alias := range def.Aliases {
        // 为每个别名创建新的匹配器(共享 handler)
        // 注册到 commandIndex 和 Trie
    }
})

4. 命令信息缓存

type CommandInfo struct {
    Command     string
    Description string
    Usage       string
    Aliases     []string
    Category    string
    Examples    []string
    Permissions []string
    Plugin      string
    Source      string
    EventType   EventType
}

引擎维护 commandInfoCachecommandListCache 两个缓存:

type state struct {
    commandInfoCache map[string]*CommandInfo  // 单命令信息
    commandListCache []CommandInfo            // 展开后的只读切片
    commandListVer   int64                    // 版本号,缓存失效检测
}

commandListCache 在每次 COW 写操作时预先展开,GetAllCommands() 只做一次切片复制返回 (复制是为了防止调用方修改缓存本体):

func (e *Engine) GetAllCommands() []CommandInfo {
    state := e.state.Load()
    if len(state.commandListCache) == 0 {
        return nil
    }
    commands := make([]CommandInfo, len(state.commandListCache))
    copy(commands, state.commandListCache)  // O(n) 复制,无 map 遍历
    return commands
}

元数据缓存的一致性(2026-07 修复)

CommandInfo 是注册瞬间对 Definition字段拷贝。此前文档推荐的链式写法:

eng.OnCommand(et, "/hello").SetDescription("打招呼").Handle(h)

存在一个隐蔽缺陷:OnCommand 返回前就已注册并生成了 CommandInfo(此时 Description 还是空的),随后的 SetDescription 只改 Definition 字段、不刷新缓存——/help 显示 空描述、SetHidden(true) 不生效,直到某次无关的全量索引重建才被动修正。

现在 SetDescription/SetUsage/SetCategory/SetAliases/SetExamples/SetPermissions/ SetHidden/BindCommand 统一经 mutateDefinition 触发 UpdateCommandCache, 注册后修改元数据即时反映到 GetAllCommands/FindCommand。同批修复的还有: 批量注册(BatchRegisterMatchers)此前完全不更新 commandInfoCache,批量注册的 命令不出现在 /help 中。

索引分类不变式(2026-07 收紧)

commandIndexed 标志是命令索引归属的唯一权威:只有 OnCommand/ RegisterCommandDef 创建(注册前置位)的匹配器进入 commandIndex 并在 Match() 中跳过 Rules[0]。此前 rebuildIndexDefinition.Name != "" 判定——普通匹配器 事后补充 SetDefinition(只为出现在 /help 里)会在任意一次索引重建后被静默迁入 commandIndex:路由方式从"逐规则匹配"变成"首 token 精确命中",且第一条规则 (可能是正则)被跳过。语义漂移的根源是把两个独立事实("有命令名"与"Rules[0] 是 命令前缀规则")隐式绑定,现在由显式标志解耦:无标志的命令名匹配器留在常规索引 按全部规则匹配,元数据照常进入 help 缓存。

5. Fuzz 测试保障

命令系统的解析器(parser.go)有专门的 Fuzz 测试:

func FuzzParser(f *testing.F) {
    f.Add("/test arg1 arg2")
    f.Add("!command --flag value")
    f.Add("   ")
    f.Add("/")
    f.Fuzz(func(t *testing.T, input string) {
        result := Parse(input)
        // 永不 panic、永不返回不一致的结果
    })
}

确保恶意输入不会造成解析器崩溃。

迭代过程

V0:引擎内嵌的简单命令提取

初始版本的命令系统极简单——直接在 Engine 的 processEvent 中硬编码匹配:

// V0 代码 — 在 engine.go 中硬编码
func (e *Engine) processEvent(ctx *Context) {
    msg := extractContent(ctx)
    for _, m := range e.state.Load().matchers {
        // 每个匹配器逐一检查规则,命令和普通规则没有区分
        if matchesAllRules(m, ctx) {
            // ...
        }
    }
}

命令信息(CommandInfo)也没有独立的缓存,每次 Help 插件调用 GetAllCommands() 都需要遍历所有匹配器重新构建。

问题: - 命令事件和普通事件没有区分——/help 跟一条普通群消息的处理路径完全一样 - 没有 commandIndex,每次都需要遍历所有匹配器 - 命令信息没有缓存——Help 命令每次都重新构建命令列表 - 没有命令补全搜索

V1:commandIndex 分离

引入 commandIndex 作为硬哈希表,实现 O(1) 命令路由:

// V1 — commandIndex
type state struct {
    // 新增
    commandIndex map[string]map[string][]*Matcher
    // 命令名       事件类型     匹配器列表
    // "/help"  →   ""        →  [matchers]
}

注册时,如果规则中包含 OnCommand("/help"),则自动添加到 commandIndex

// RegisterCommand 自动添加 commandIndex
func (e *Engine) OnCommand(eventType string, cmd string, rules ...Rule) *Matcher {
    m := &Matcher{
        Rules:     append([]Rule{OnCommand(cmd)}, rules...),
        EventType: eventType,
    }
    m.commandIndexed.Store(true)
    e.registerMatcher(m)
    return m
}

效果:命令路由从 O(n) 降为 O(1),1K 匹配器场景下延迟从 5μs 降为 ~50ns(map 查找)。

V2:Trie 树 + 命令信息缓存

// V2 — command/trie.go(独立包)
type Trie struct {
    root *TrieNode
    mu   sync.RWMutex
}

type Meta struct {
    Name        string
    Description string
    Usage       string
    Aliases     []string
    Category    string
    Plugin      string
}

Trie 树的引入解决了两个问题: 1. 前缀搜索:用户输入 /he 时,Trie 即时返回 /help/hello/heartbeat 等候选 2. 命令发现:Help 插件遍历 Trie 获取所有命令,无需遍历 Engine 的匹配器

命令信息也引入了缓存层:

// V2 — 命令信息缓存
type state struct {
    commandInfoCache map[string]*CommandInfo   // 单命令
    commandListCache []CommandInfo             // 展开的只读切片
    commandListVer   int64                     // 缓存失效版本
}

func (e *Engine) GetAllCommands() []CommandInfo {
    state := e.state.Load()
    if state.commandListCache != nil {
        return state.commandListCache  // 零分配返回
    }
    // 首次构建
}

commandListCache 的设计原因是:Help 命令可能被频繁触发,每次遍历 map 构建切片在匹配器较多时消耗可观。缓存后首次调用构建,后续 O(1) 返回。

V3:别名自动注册

// V3 — 别名自动注册(engine/engine_command.go)
primary.SetAliasRegistrar(func(def *command.Definition, h context.Handler) {
    for _, alias := range def.Aliases {
        // 为每个别名创建匹配器,共享 handler
        aliasMatcher := &Matcher{
            EventType: primary.EventType,
            Rules:     []Rule{OnCommand(alias)},
            Handler:   h,
            Source:    primary.Source,
        }
        aliasMatcher.commandIndexed.Store(true)
        e.registerMatcher(aliasMatcher)
    }
})

之前的版本需要插件开发者手动为别名注册匹配器——这导致了很多遗漏(Help 中显示了别名,但实际输入别名没反应)。V3 改为引擎层自动处理。

V4:解析器改进——Fuzz 测试 + 自定义前缀

早期解析器在恶意输入下有 panic 风险:

// V0 解析器 — 未考虑边缘情况
func Parse(input string) *ParsedCommand {
    parts := strings.Split(input, " ")
    if len(parts) == 0 {
        return nil  // 空的字符串返回 nil,但调用方没检查
    }
    cmd := parts[0][1:] // 假设 parts[0] 长度至少为 2
    // ...
}

V4 使用 Fuzz 测试覆盖所有边缘情况:

// V4 — Fuzz 测试
func FuzzParser(f *testing.F) {
    f.Add("/test arg1 arg2")
    f.Add("!command --flag value")
    f.Add("   ")          // 纯空白
    f.Add("/")            // 只有前缀
    f.Add("")             // 空字符串
    f.Fuzz(func(t *testing.T, input string) {
        result := Parse(input)
        // 永不 panic、永不返回不一致的结果
    })
}

同时支持自定义命令前缀(不限于 /),通过 triggerPrefix 字段实现:

// V4 — 自定义前缀
m.triggerPrefix = string(trimmedPattern[0])
cmdName := trimmedPattern[1:]

这样 !help.help#help 等都可以作为命令前缀。

迭代历程

版本 核心变化 延迟 解决的问题
V0 引擎内嵌,命令跟普通事件混在一起 O(n) 遍历 快速实现
V1 commandIndex 硬哈希表 O(1) ~50ns 命令事件从 1K 匹配器中 O(1) 路由
V2 Trie 树 + 命令信息缓存 ~500ns 搜索 + 零分配 Get 命令补全 + 频繁 Help 调用优化
V3 别名自动注册 消除手动注册遗漏
V4(当前) Fuzz 测试 + 自定义前缀 健壮性 + 灵活性

性能基准

// BenchmarkCommandParsing
// 命令解析 ~1-2 μs/op
// Trie 搜索 ~500 ns/op (前缀长度 3-5)
// commandIndex 查找 ~50 ns/op (一次 map 查找)
操作 延迟 说明
OnCommand 注册 ~1 μs 含索引更新
命令事件路由 ~50 ns commandIndex O(1)
前缀补全 ~500 ns Trie DFS
GetAllCommands 0 ns(缓存命中) 预计算列表缓存