事件系统(上):五种分发模式
专栏:Cordis 插件框架 · 第 6 / 12 篇:::info 学习目标
完成本篇后你能够:说出五种分发模式各自的语义与适用场景;读懂 waterfall 的 9 行实现;解释 isBailed 的判定规则。
前置:第 4~5 篇完成。预计时长:50 分钟。
:::
Cordis 的事件系统全部实现在 events.ts(352 行)。上一篇读了它的一半(register/on),这篇读另一半——分发。五种模式是它的全部表达力。
waterfall 的洋葱模型
五种模式一句话总览
五种模式一句话总览
// vendor/cordis/src/events.ts:32
export type DispatchMode = 'emit' | 'parallel' | 'serial' | 'bail' | 'waterfall'
| 模式 | await | 顺序 | 返回值 | 一句话 |
|---|---|---|---|---|
emit | 否 | 注册序 | 无 | 广播,人人知道 |
parallel | 是 | 全部并发 | 等全部 | 并行收集,任何失败抛 AggregateError |
serial | 是 | 依次 | 第一个非空返回 | 谁先给出决定听谁的 |
bail | 否 | 依次 | 同 serial | serial 的同步版 |
waterfall | 监听器可异步 | 环绕组合 | 最外层的返回值 | 洋葱模型,可短路 |
洋葱模型:waterfall 的 9 行实现
整个 waterfall 的实现精简到令人发指(events.ts
):// vendor/cordis/src/events.ts:234-243
waterfall(...args: any[]) {
const cbs = this.dispatch('waterfall', args)
const inner = args.pop() // 最后一个参数是"内置行为"(终端 next)
const next = () => {
const cb = cbs.shift() ?? inner // 从监听器队列取下一个;取光了用内置行为
return cb(...args) // args 末尾被替换成 next 本身
}
args.push(next)
return next() // 从最外层监听器开始
}
机制:监听器收到的最后一个参数永远是 next——调用它就执行”队列里的下一个监听器(或最终的内置行为)“,并把返回值传回来;不调用就是否决。每个监听器都是洋葱的一层皮,next() 往里走,返回值沿洋葱往外传。dsh 的 tools/pre-execute(allow/deny/ask)、approval/request、agent/pre-step 全部建立在这个 9 行之上。
bail 与 serial:首个非空值定胜负
// vendor/cordis/src/events.ts:13-15
export function isBailed(value: any) {
return value !== null && value !== false && value !== undefined
}
serial/bail 的终止条件是 isBailed(result)——null、false、undefined 都不算”有决定”,任何其他值(包括 0、空串)都算。serial 是异步版(逐个 await),bail 是同步版。agent/turn-stopping 用 serial:按注册顺序逐个征询,且没有 next——监听器无法委托,只能返回或注入数据。
dispatch:所有模式的公共入口
五种模式都先经过 dispatch(events.ts
internal/dispatch 诊断事件,然后按上下文过滤器筛选监听器并绑定 this:
// vendor/cordis/src/events.ts:165-175(节选)
dispatch(type: string, args: any[]) {
const thisArg = typeof args[0] === 'object' || typeof args[0] === 'function' ? args.shift() : null
const name: string = args.shift()
if (!name.startsWith('internal/')) {
this.emit('internal/dispatch', type, name, args, thisArg) // 诊断事件
}
const filter = thisArg?.[Context.filter]
return (this._hooks[name] || [])
.filter(hook => hook.global || !filter || filter.call(thisArg, hook.ctx))
.map(hook => hook.callback.bind(thisArg))
}
过滤器就是第 4 篇 Service 的 [symbols.filter]——事件沿作用域链向上投递,兄弟作用域互不可见。dsh 的”审批事件只发给发起审批的那个 agent”由此实现。
parallel 与 emit 的错误语义差异
// vendor/cordis/src/events.ts:183-196(节选)
async parallel(...args: any[]) {
const results = await Promise.allSettled(this.dispatch('emit', args).map(async cb => cb(...args)))
const errors = results.filter(r => r.status === 'rejected')
if (errors.length) throw new AggregateError(errors.map(e => e.reason))
}
emit(...args: any[]) {
this.dispatch('emit', args).map(cb => cb(...args)) // 监听器异常被逐个隔离
}
parallel 会把所有监听器错误聚合成 AggregateError 抛出;emit 则逐个隔离(一个监听器出错不影响其他)。选型的隐含约定:emit 的监听器必须自己兜住异常——这也是 dsh 文档反复强调”emit 监听器是观察者,别在里面抛错”的原因。
与其他主题的联系
- dsh 的承重墙:
tools/pre-execute(把关)、approval/request(审批分发)、agent/request(换配置)全是 waterfall;agent/turn-stopping是 serial(无 next,靠数据反转控制权,第 7 篇 dsh 专栏);tools/result是 emit; - 第 7 篇(下篇)讲事件名的类型安全(声明合并的 Events 接口)与作用域过滤的 dsh 扩展。
常见踩坑
- waterfall 监听器忘了调 next——直接短路,内置行为永远不执行,且没有报错;
- 在 emit 监听器里抛异常——
emit的隔离只保护”其他监听器”,发射方拿到的是静默忽略,错误会被吞进日志——观察逻辑里做好自己的 try/catch; - 把
null/false当 serial 的有效返回——它们被 isBailed 视为”没有决定”,继续往下走。
随堂练习(带验收标准)
- 用三种监听器复现 waterfall 的”包装-短路”输出(教程式 demo),预测输出后再运行验证;
- 实现
serial场景:三个监听器返回 null/‘ok’/‘no’,验证返回 ‘ok’; - 打开
vendor/cordis/src/events.ts:234-243,给自己讲一遍这 9 行——讲不顺就再读一遍。