事件系统

整棵上下文树共享一条事件总线,作用域化发生在 Context 层:通过上下文订阅的监听器, 在该上下文的作用域回收时一起摘除。对应实现:src/Cordis/Events.cs。

五种派发模式

事件名是字符串,派发方式由调用哪个方法决定。

方法返回派发方式返回值处理
EmitAsyncValueTask按注册顺序逐个 await忽略
ParallelAsyncValueTask全部启动,然后一起等待忽略
SerialAsyncValueTask<object?>按注册顺序逐个 await遇首个命中值即中断并返回它
BailAsyncValueTask<object?>按注册顺序逐个 await遇首个命中值即中断并返回它
WaterfallAsync<TResult>ValueTask<TResult>监听器嵌套包裹 terminal由是否调用 next 决定
EmitAsync L₁ L₂ L₃ 顺序逐个 await,返回值忽略 ParallelAsync L₁ L₂ L₃ 全部启动,然后一起等待 SerialAsync BailAsync L₁ L₂ ✓ L₃ 遇首个命中值即中断 并把它作为结果返回
两个 bail 类方法画在同一行,因为在当前实现里它们的派发逻辑逐字等价。

Serial 与 Bail 在当前实现里行为相同

两者的派发逻辑完全一致:顺序执行,取首个命中值。差别只在 BailAsync<TResult> 多提供一个把结果转成目标类型的重载。 不要依赖它们之间存在语义差异。

bail 判定

判定一个监听器返回值算不算“命中”的规则只有一条:既不是 null 也不是 false。这意味着监听器可以用 false 明确表达“我不处理,继续往下走”,与“没有返回值”等价。

// 谁先给出非 null 且非 false 的结果,谁的结果被采用
var handler = await ctx.BailAsync<IToolHandler>("tool/resolve", name);

ctx.On<string, bool>("tool/resolve", name =>
{
    return false;   // 不处理,派发继续
});

Waterfall:监听器包在 terminal 外层

waterfall 不是把值依次传递下去,而是把监听器套在一个 terminal 委托的外层,形成洋葱结构。每个监听器拿到原始事件参数和一个续延 next,只有显式调用它才会进入后续监听器和 terminal。

next() 逐层深入 L₁ L₂ terminal(内置行为) 结果逐层返回 不调用 next() L₁ L₂ terminal 整条剩余链路短路
监听器是环绕式的,因此可以在 terminal 前后各做一次加工,也可以完全不让它执行。异常沿返回路径向外冒,外层监听器有机会处理。
// terminal 是内置行为,监听器可以在它前后加工,也可以完全拦截
var result = await ctx.WaterfallAsync(
    "request/send",
    () => SendAsync(request),
    request);

// 加工型:调用 next 让链条继续
ctx.OnWaterfall<Request, Response>("request/send", async (req, next) =>
{
    var response = await next();
    response.Headers.Add("x-traced", "1");
    return response;
});

// 拦截型:不调用 next,后续监听器与 terminal 都不会执行
ctx.OnWaterfall<Request, Response>("request/send", (req, next) =>
    ValueTask.FromResult(Cached[req.Key]));

省略 next 会短路整条剩余链路。反过来,terminal 或内层监听器抛出的异常会先经过外层监听器,让它们有机会处理;未被处理的异常最终向 WaterfallAsync 调用方传播。

订阅与作用域

通过 ctx.On(...) 订阅的监听器自动绑定当前作用域,作用域回收时摘除;返回的 IDisposable 是提前退订的手段,不是必须调用的清理。

  • On 有无参、单参、双参,以及产生结果供 serial / bail 使用的多种重载。
  • OnWaterfall 注册环绕式监听器,最多接收两个事件参数外加续延。
  • Once 注册一次性监听器,在首次调用前就被摘除;并发 dispatch 下以原子认领保证至多调用一次,输掉认领的 dispatch 视同该 listener 不存在。
  • prepend: true 把监听器插到同名监听器之前,用来抢先处理。
  • Off(name, listener) 按委托实例移除;HasListeners(name) 查询是否有人在听。

绕过作用域的订阅要自己负责

直接用 ctx.Events.On(...) 订阅不会绑定作用域,等于放弃了自动回收。 插件里应当一律走 ctx.On(...)。

异常传播与 contained 通知

固定 rc.8 的普通派发契约是:EmitAsync、SerialAsync、BailAsync 与 WaterfallAsync 的 listener 异常直接使整次 dispatch 失败,顺序模式不再运行后续 listener; ParallelAsync 等待全部 listener settled 后聚合所有失败。外层 waterfall listener 仍可显式 捕获下游异常并返回替代值。

普通派发不会把 listener failure 隐式改写成错误通知:调用方若需要事务语义,就 await 对应 dispatch 并处理失败;需要通知语义时,必须显式选择下面的 contained 入口。

EmitContained 是独立的 Tether fire-and-forget 扩展:它启动 listener 而不等待完成,异步失败 尽量路由到 internal/error;错误事件自身的失败不会递归路由。调用方必须显式选择 contained, 普通 dispatch 不能隐式切换到这条路径。

内置事件名

ready、dispose、fork、service 和 internal/error 的触发时机与参数列在 插件与 Context 的生命周期事件一节。

下一步

在 GitHub 上编辑此页