动态代理在发布订阅中的设计
当我们谈发布订阅时,通常想到的是 Event、Listener、Bus。
但在 IntelliJ IDEA、Spring、Dubbo 等大型系统中,
发布订阅早已不再是"传一个事件对象"这么简单。
本文从一个问题出发:
为什么 IntelliJ IDEA 的 MessageBus 要用"动态代理"来做发布订阅?
并通过代码,对比传统事件模型 与代理式发布订阅模型,拆解它背后的设计价值。
一、传统发布订阅:我们最熟悉的样子
先看一个最常见的实现。
1. 传统事件模型
java
class EventBus {
private final Map<String, List<Consumer<Object>>> listeners = new HashMap<>();
public void subscribe(String topic, Consumer<Object> listener) {
listeners.computeIfAbsent(topic, k -> new ArrayList<>()).add(listener);
}
public void publish(String topic, Object event) {
for (Consumer<Object> l : listeners.getOrDefault(topic, List.of())) {
l.accept(event);
}
}
}
使用方式:
java
bus.subscribe("order", e -> System.out.println(e));
bus.publish("order", new OrderCreatedEvent());
2. 这个模型的问题
这种方式在小系统里没问题,但在大型系统中会暴露大量缺陷:
- ❌ 弱类型(Object / String)
- ❌ 事件语义不清晰
- ❌ 发布者必须"认识"事件结构
- ❌ 难以演进(加一个 before/after?)
- ❌ 不利于插件化系统
二、问题升级:事件其实是"协议"
在复杂系统中,一个"事件"往往不是一个动作,而是一组行为:
java
beforeSave()
afterSave()
onError()
这时,事件更像一个接口协议,而不是一个数据对象。
三、代理式发布订阅的核心思想
把"事件发布"伪装成"接口方法调用"
调用者以为自己在调方法:
java
publisher.fileOpened(file);
但实际上:
接口方法调用
↓
代理拦截
↓
消息总线分发
↓
多个订阅者执行
四、基于动态代理的发布订阅模型
1. 定义事件协议(接口)
java
public interface FileListener {
void fileOpened(String name);
void fileClosed(String name);
}
这一步非常关键:
接口 = 事件协议
方法 = 事件语义
2. 定义 Topic
java
class Topic<T> {
private final Class<T> listenerType;
public Topic(Class<T> listenerType) {
this.listenerType = listenerType;
}
public Class<T> getListenerType() {
return listenerType;
}
}
3. MessageBus 的核心实现(简化版)
java
class MessageBus {
private final Map<Topic<?>, List<Object>> listeners = new HashMap<>();
public <T> void subscribe(Topic<T> topic, T listener) {
listeners.computeIfAbsent(topic, k -> new ArrayList<>()).add(listener);
}
@SuppressWarnings("unchecked")
public <T> T publisher(Topic<T> topic) {
return (T) Proxy.newProxyInstance(
topic.getListenerType().getClassLoader(),
new Class[]{topic.getListenerType()},
(proxy, method, args) -> {
for (Object l : listeners.getOrDefault(topic, List.of())) {
method.invoke(l, args);
}
return null;
}
);
}
}
4. 使用方式(重点看体验)
java
Topic<FileListener> FILE_TOPIC = new Topic<>(FileListener.class);
bus.subscribe(FILE_TOPIC, new FileListener() {
public void fileOpened(String name) {
System.out.println("open: " + name);
}
public void fileClosed(String name) {}
});
FileListener publisher = bus.publisher(FILE_TOPIC);
publisher.fileOpened("test.txt");
五、为什么一定要用"动态代理"?
1️⃣ 保持接口即协议
如果不用代理,你只能写成:
java
bus.publish(topic, "fileOpened", args);
这会导致:
- 方法名字符串
- 无编译期检查
- IDE 无法重构
- 插件风险极高
代理让 接口本身成为协议。
2️⃣ 发布者零感知订阅者
java
publisher.fileOpened(...)
发布者:
- 不知道有几个 listener
- 不知道 listener 在哪
- 不知道 listener 生命周期
真正的解耦
3️⃣ 天然支持"一对多"
代理拦截的是方法调用,而不是事件对象:
java
method.invoke(listener1)
method.invoke(listener2)
method.invoke(listener3)
无需额外设计。
4️⃣ AOP 级别的扩展能力
在代理中,你可以轻松加入:
- 线程切换(EDT / Executor)
- disposed 检查
- 异常隔离
- 父子 MessageBus 传播
- 性能统计
而业务代码完全无感知
六、与传统 EventBus 的核心差异对比
| 维度 | 传统 EventBus | 代理式 MessageBus |
|---|---|---|
| 事件定义 | Object / Event | 接口 |
| 类型安全 | ❌ | ✅ |
| 事件语义 | 弱 | 强 |
| API 体验 | 函数式 | 面向对象 |
| 插件友好 | ❌ | ✅ |
| 扩展能力 | 弱 | 极强 |
七、这套设计适合什么场景?
非常适合:
- IDE / 平台型系统
- 插件化架构
- 模块高度解耦的系统
- 需要长期演进的 API
不太适合:
- 极简单系统
- 超高频 tight-loop 场景
八、一句话总结
动态代理在发布订阅中的真正价值,
不是"炫技",
而是让"事件"升级为"接口协议",
让系统在强类型、强约束的前提下,获得最大的架构自由度。
九、写在最后
IntelliJ IDEA MessageBus、Spring 的一些高级事件机制,本质上都在做同一件事:
用运行时代理,换取架构上的长期可维护性。
这是大型 Java 系统非常典型、也非常值得学习的一种设计取舍。