事件发布隐藏了哪些调用关系

商品目录更新后,需要刷新界面上的商品计数。直接调用一个监听器十分明确:调用方知道接口类型,也知道异常会从哪里返回。改用 EventBus 后,发布方只需要 post 一个事件,订阅者通过注册和注解接收它。方法调用被移到运行时的订阅关系中,依赖并未消失,只是无法从发布语句直接看出全部接收者。

这会影响维护。商品计数没有更新,可能是事件类型不匹配、订阅对象没有注册、已经注销,或订阅方法执行失败。如果把库存扣减、通知、界面刷新都放在同一条事件上,发布方还可能无法判断哪些操作已经成功。使用事件总线之前,先确定业务是否接受这种调用和失败模型。

Guava 当前固定版本的 EventBus 文档明确建议避免新用法,并列出依赖注入、响应式编程等替代方向。本文研究 Guava 33.5.0-jre 的既有行为,用于理解和维护已有系统;是否引入新框架仍应由业务复杂度决定。一个明确的监听器接口常常就足够。固定版本 EventBus 类文档

本章限定在同一进程内的默认 EventBus。完整实验使用 Java 8 源码,在 Zulu 8u472 和 Corretto 21.0.11 运行,覆盖订阅生命周期、重入、线程和异常。AsyncEventBus 不在实测范围内,也不能从默认实现的输出推断它的全局顺序。

register 建立的是运行时订阅

订阅方法使用 @Subscribe,接收一个事件参数。注册对象时,SubscriberRegistry 查找符合要求的方法,并按参数类型记录订阅者。发布事件时,它会按事件类型的继承层次寻找匹配的方法,因此订阅 Object 的方法能够接收到多种事件。这种宽泛订阅容易扩大处理范围,实际业务应优先使用表达清楚的事件类型。

例如使用 CatalogChanged 表达目录已变化,比用字符串 “changed” 更容易追踪。字符串仅用于本章的小实验,以减少无关类型;不能据此认为字符串就是合适的事件协议。事件若带有可变集合,同一对象被不同订阅者修改还会引入别名问题,建议传递不可变的身份和值快照。

本章把同一个 Counter 对象注册两次,发布一次 String 事件,计数只增加一次。这个结论限定于相同订阅对象的重复注册,不意味着创建两个行为相同的对象也会合并。注册表中保存的是 Subscriber;对象实例不同,可以对应不同的订阅关系。

注销需要管理注册时的对象。Counter 注销后,后续发布不再触发该对象;再注销一次会抛 IllegalArgumentException。组件初始化和关闭应明确配对,不能依赖事件没有发生来推断对象已成功释放。注册表持有订阅关系,组件生命周期结束却仍被总线引用,会使其继续接收事件并延长可达时间。SubscriberRegistry 源码

默认派发使用发布线程,但重入事件会排队

默认 EventBus 使用直接执行器。这意味着普通订阅方法在发布线程中执行,慢订阅者会占用发布方线程。线程安全的注册表不等于每个事件都自动异步处理,也不表示调用 post 后就能立刻返回。

重入需要进一步区分。测试注册两个方法:父订阅方法接收 String,先记录 parent-start,再 post 一个 Integer,最后记录 parent-end;子订阅方法接收 Integer 并记录线程名。两个 JDK 的输出都是:

1
2
3
parent-start
parent-end
child@main

如果把 post 理解成直接递归调用匹配方法,就会预计 child 出现在 parent-end 之前。固定版本默认分发器实际使用 PerThreadQueuedDispatcher:事件进入当前线程队列,只有尚未处于分发状态时才启动循环。父订阅者内部 post 子事件时,线程已经在分发,因此先排队,等父方法结束后再处理子事件。Dispatcher 源码

这个顺序不能推广为多个任意订阅者之间的业务优先级,更不能推广为多个发布线程之间的全局顺序。需要“先保存、后刷新”的业务,应显式组织操作或发布保存完成后的新事件,不应依赖同一事件多个处理方法的偶然遍历顺序。重入队列解释的是一个线程的当前分发过程。

注销也不应解释为撤销所有已经进入分发过程的事件。源码获取订阅者时形成迭代快照,已经获得的快照不等于未来的注册表状态。本章实验验证的是注销完成后的下一次发布,不声称验证了并发注销与在途事件的全部交错。需要严格停止处理时,组件还应有明确的关闭状态和完成等待机制。

订阅异常不会自动成为发布失败

实验为 EventBus 提供 SubscriberExceptionHandler,并注册两个 String 订阅者。一个抛出 IllegalArgumentException,另一个增加计数。发布结束后,handler 记录一次异常,计数也增加一次,post 正常返回。测试没有断言这两个订阅者谁先执行,避免把遍历顺序写成契约。

因此,调用方不能用“post 没有抛异常”判断所有业务动作都成功。对非关键界面刷新,可以将处理错误交给独立记录渠道;对库存扣减等必须完成的操作,应返回可检查的结果,或者直接调用具有明确失败语义的服务接口。错误处理器提供观察机会,并不自动重试,也不会回滚此前完成的订阅动作。

异常边界还不能写成“任何 Throwable 都被吞掉”。Subscriber 通过反射调用方法,普通目标异常被 InvocationTargetException 包装后进入 handler;源码对 cause 为 Error 的情况重新抛出。官方问题记录也讨论了 Error 不进入 SubscriberExceptionHandler 的现象。本章对普通运行时异常有实测,对 Error 分支的结论来自固定源码核查,没有把未执行的分支标成实测。Subscriber 源码、官方问题记录 7728

DeadEvent 只表示当次没有匹配订阅者

没有匹配订阅者的普通事件会被包装成 DeadEvent 再发布。本章注册一个 DeadEvent 观察者:在 Counter 注册前发布 “before”,得到一个 DeadEvent;注册期间发布 “during”,Counter 计数增加;注销后发布 “after”,又得到一个 DeadEvent。观察到的原始事件依次是 before 和 after。

这对排查缺少订阅者有帮助,但并不是端到端处理确认。存在匹配方法却执行失败的事件不会因此自动变成 DeadEvent;同样,只要一个宽泛的 Object 订阅者匹配,原事件就已有接收者,不能再用“没有 DeadEvent”证明特定业务处理器工作正常。

监控应区分接收匹配、处理开始、处理成功、处理失败。若业务需要处理确认,就应引入显式结果,而不是继续为 DeadEvent 增加推测逻辑。进程崩溃、外部系统失败和重启重放也不属于这个内存总线提供的保障。

用明确的监听器保留需要的解耦

一个组件只有少数已知订阅者时,可用接口表达通知关系。下面的 Java 8 示例保留“发布方依赖接口”的边界,同时使监听器列表、执行顺序和异常传播直接可读。它选择同步且遇错停止;这只是明确的策略,不代表所有业务都应该如此。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import java.util.Arrays;
import java.util.List;

public final class ExplicitCatalogListeners {
interface Listener {
void changed(String sku);
}

static final class Catalog {
private final List<Listener> listeners;

Catalog(List<Listener> listeners) {
this.listeners = listeners;
}

void notifyChanged(String sku) {
for (Listener listener : listeners) {
listener.changed(sku);
}
}
}

public static void main(String[] args) {
Listener first = sku -> System.out.println("count:" + sku);
Listener second = sku -> System.out.println("view:" + sku);
new Catalog(Arrays.asList(first, second)).notifyChanged("SKU-1");
}
}

这里未实现动态注册和并发修改,列表在构造后保持不变。需要动态管理时,首先定义修改和派发同时发生时的语义,再选择集合与同步策略。若需要异步执行,则应把执行器、排队上限、拒绝和关闭协议加入接口,不能只把 for 循环提交到线程池就宣称获得可靠事件系统。

迁移已有 EventBus 时,可以先列出每种事件的发布点和订阅方法,再区分必须成功的业务操作与允许独立失败的通知。前者转成明确调用或结果链,后者保留有限的通知接口。每迁移一种事件,都保留注册前、注册期间、注销后以及异常输入的测试,避免删除隐式订阅关系时漏掉副作用。

线程安全需要落实到订阅方法

源码还区分普通订阅方法和标记 AllowConcurrentEvents 的方法。普通 Subscriber 包装会对同一个订阅方法实例的调用进行同步;带注解的分支允许并发调用。这个局部保护不等于整个业务对象被串行访问:对象还有其他方法,其他对象也可能引用相同的状态。移除保护之前,应确认订阅逻辑本身可以安全并发,而不是根据 EventBus 是线程安全的就省略检查。

事件对象同样属于共享状态。如果发布之后还继续修改其中的列表,订阅者看到什么值可能取决于实际执行时间。最直接的约束是将事件定义为创建后不变的值,并在边界复制可变容器;这些要求与是否采用 EventBus 无关。异步迁移会扩大发布和消费之间的时间差,因此更容易暴露此前被同步执行掩盖的别名问题。

这里的同步分支来自固定版本 Subscriber 源码核查,三个实验没有覆盖并发发布和 AllowConcurrentEvents 的交错。评估具体项目时,应针对共享状态补充受控线程测试。默认重入顺序、普通异常以及生命周期实验已经分别锁定,但不能用它们替代线程安全论证。

验证、练习与选择

完整测试和运行说明包含三个测试;Java 8 与 Java 21 均为 0 失败、0 错误、0 跳过。它们验证的是限定场景中的可观察行为,不是对所有线程交错的穷举证明。
要确认的问题 本章可观察结果
父订阅者重入发布 父方法结束后处理子事件,线程main
普通订阅异常 handler一次、另一个订阅者执行、post返回
重复注册同一对象 一次事件只增加一次计数
注销后再次发布 不调用已注销对象,产生DeadEvent
再次注销 IllegalArgumentException

手算题:父订阅方法 post 子事件后立即检查由子订阅者更新的计数,会得到什么?在本章默认同线程重入模型中,子事件仍在队列里,因此不能假设计数已经更新。若业务必须使用该结果,应直接调用返回结果的方法。

改动练习:给订阅者增加一个受 latch 控制的慢处理,使用单独发布线程,断言 post 返回之前处理尚未结束;再加入 Object 订阅者,观察 DeadEvent 的变化。执行时明确超时和清理步骤,避免实验永久挂起。

可迁移做法 适用边界
把注册、注销和组件生命周期一起管理 插件、界面组件、进程内通知
将必要业务结果与可独立失败的通知分开 事件总线迁移、异步流程审查

对少量已知接收者,显式调用和监听器接口更容易审查。需要复杂异步流时,应先列出顺序、背压、取消和错误传播要求,再比较方案。已有 EventBus 可以在边界明确的进程内通知场景中维护,但它不能替代持久化消息系统,也不提供跨进程处理确认。