事件系统(上):五种分发模式

专栏:Cordis 插件框架 · 第 6 / 12 篇
Cordis事件

:::info 学习目标 完成本篇后你能够:说出五种分发模式各自的语义与适用场景;读懂 waterfall 的 9 行实现;解释 isBailed 的判定规则。 前置:第 4~5 篇完成。预计时长:50 分钟。 :::

Cordis 的事件系统全部实现在 events.ts(352 行)。上一篇读了它的一半(register/on),这篇读另一半——分发。五种模式是它的全部表达力。

waterfall 的洋葱模型

图表(cordis-events-dispatch.md)

五种模式一句话总览

五种模式一句话总览

// vendor/cordis/src/events.ts:32
export type DispatchMode = 'emit' | 'parallel' | 'serial' | 'bail' | 'waterfall'
模式await顺序返回值一句话
emit注册序广播,人人知道
parallel全部并发等全部并行收集,任何失败抛 AggregateError
serial依次第一个非空返回谁先给出决定听谁的
bail依次同 serialserial 的同步版
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/requestagent/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

),它做三件事:取出 thisArg(显式上下文,用于作用域过滤)、取出事件名、对非 internal 事件发 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 扩展。

常见踩坑

  1. waterfall 监听器忘了调 next——直接短路,内置行为永远不执行,且没有报错;
  2. 在 emit 监听器里抛异常——emit 的隔离只保护”其他监听器”,发射方拿到的是静默忽略,错误会被吞进日志——观察逻辑里做好自己的 try/catch;
  3. null/false 当 serial 的有效返回——它们被 isBailed 视为”没有决定”,继续往下走。

随堂练习(带验收标准)

  1. 用三种监听器复现 waterfall 的”包装-短路”输出(教程式 demo),预测输出后再运行验证;
  2. 实现 serial 场景:三个监听器返回 null/‘ok’/‘no’,验证返回 ‘ok’;
  3. 打开 vendor/cordis/src/events.ts:234-243,给自己讲一遍这 9 行——讲不顺就再读一遍。

← 返回文章列表