设计模式修炼之路 ㉒ 中介者模式——网状依赖变星型
网状依赖变星型、N×(N-1) 收成 N 条、聊天室聚餐事故、中介者 vs 观察者
目录
行为型第九站。
上一篇存完档,去聊天室放松一下。聊天室里有个老问题:用户 A 想发消息给 B 和 C,B 想回 A,C 想私聊 A……每个用户都得直接持有其他所有用户的引用,N 个人就是 N×(N-1) 条关系线,来一个新人,全员改代码。
把网状依赖收拢成星型——所有人只认识群主一个——这就是中介者模式。
一、中介者模式是什么
GoF 的原始定义:
Define an object that encapsulates how a set of objects interact. Mediator promotes loose coupling by keeping objects from referring to each other explicitly, and it lets you vary their interaction independently. (用一个中介对象来封装一系列对象的交互。中介者使各对象不需要显式地相互引用,从而使其耦合松散,而且可以独立地改变它们之间的交互。)
关键词是"封装交互"。各对象之间的交互规则——谁的消息发给谁、私聊怎么路由、新人进群要不要广播——全部从成员身上抽走,装进一个专职的中介对象。
现实中最贴切的类比是机场塔台:飞机与飞机之间从不直接通话,所有飞机只跟塔台说话,塔台认识空域里的每一架飞机,负责协调所有起降。你不会允许两架飞机自己商量谁先降落——同样的道理,复杂系统里你也不该允许对象之间私自拉群。
四个角色:
| 角色 | 干什么 | 本文对应 |
|---|---|---|
| Mediator(抽象中介者) | 定义同事对象与中介者通信的接口 | ChatMediator |
| ConcreteMediator(具体中介者) | 认识所有同事,负责协调它们之间的交互 | ChatRoom(群主) |
| Colleague(抽象同事类) | 每个同事只持有中介者的引用,发消息一律委托中介者 | User |
| ConcreteColleague(具体同事类) | 具体的交互参与者,互相不认识 | CommonUser / VipUser |
拓扑学一点说:中介者把 N×(N-1) 条关系线压缩成了 N 条。三人群从 6 条线变 3 条,百人群从 9900 条变 100 条——依赖从平方级降到线性级,这是这个模式全部价值的数学表达。
二、优点与缺点
优点:
- 网状变星型:依赖条数从 N×(N-1) 降为 N,"来一个新人全员改代码"的惨剧从机制上根除——新人只注册给群主,老成员零改动。
- 交互规则集中:"谁该收到什么"的知识全部住在群主家。改路由规则(比如"群主消息不发给发送者本人")只改一处,而不是改遍所有成员。
- 同事类可复用:CommonUser 对中介者内部一无所知,只认识一个抽象接口——把聊天室换成公会频道、客服系统,同事类一行不改。
- 类间解耦:张三和李四互相不知道对方存在,删掉李四,张三毫无感知。
缺点:
- 上帝对象风险:所有交互逻辑都往 ConcreteMediator 里堆,群主类从 30 行膨胀到 3000 行,变成无所不知无所不管的上帝对象——GoF 原书都专门警告了这一点,这是该模式最大的坑。
- 中介者单点:所有消息都过它,它挂了全群哑火;高并发场景下,中心节点就是性能瓶颈。
- 同事与中介者双向依赖:User 持有 ChatMediator,ChatRoom 遍历 User 列表——比"谁也不认识谁"的单向依赖紧,这是星型的固有代价。
三、什么时候用
判断标准:多个对象之间存在网状交互,改一个牵动全员,且交互规则本身经常变。
- 聊天室 / IM 服务器:消息路由、成员管理——本文场景
- GUI 事件协调:对话框里"按钮禁用依赖文本框内容、下拉框联动刷新列表"——GoF 原书的经典例子
- MVC 中的 Controller:Model 和 View 互不相识,全靠 Controller 穿针引线(㉖ 会展开细讲)
- 游戏房间 / 匹配大厅:玩家状态同步、房间广播
- 微服务注册中心:服务间调用不写死地址,先问 Nacos/Eureka
反面对照:两个对象交互——一对一根本不需要中介,直接调用最清晰;三个对象、关系稳定——两两直连只有 3 条线,引中介者反而多一层间接性;交互规则简单到没有——中介就退化成传话游戏,除了费劲毫无收益。
四、实现方式:聊天室
4.1 反面教材:人人互联的网状地狱
先看最直觉的做法——谁要联系谁,就持有谁的引用:
public class BadUser {
private final String name;
private final List<BadUser> contacts = new ArrayList<>();
public void addContact(BadUser user) {
contacts.add(user);
}
public void sendGroupMessage(String message) {
for (BadUser contact : contacts) {
contact.receive(name, message);
}
}
// 私聊?前提是你手里有对方的引用——不认识的人聊不了
public void sendPrivateMessage(BadUser target, String message) {
target.receive(name, message);
}
// ...
}
三个用户入群,六条关系线全部手工维护。运行一下,见证一场"聚餐事故":
========== 反面教材:人人互联的网状地狱 ==========
[张三 → 群发] 大家好,我是张三!
[李四 收到来自 张三] 大家好,我是张三!
[王五 收到来自 张三] 大家好,我是张三!
[李四 → 群发] 欢迎欢迎~
[张三 收到来自 李四] 欢迎欢迎~
[王五 收到来自 李四] 欢迎欢迎~
—— 赵六进群:老成员理论上全体加一遍引用,张三手滑漏了 ——
[赵六 → 群发] 我是新人赵六,请多关照
[张三 收到来自 赵六] 我是新人赵六,请多关照
[李四 收到来自 赵六] 我是新人赵六,请多关照
[王五 收到来自 赵六] 我是新人赵六,请多关照
[张三 → 群发] 周末聚餐,来的扣 1
[李四 收到来自 张三] 周末聚餐,来的扣 1
[王五 收到来自 张三] 周末聚餐,来的扣 1
—— 赵六:???聚餐怎么没人叫我 ——
[张三 → 李四] 你看赵六的头像是不是很眼熟(私聊)
[李四 收到来自 张三] 你看赵六的头像是不是很眼熟
灵异事件复盘:赵六能收到李四王五的消息、也能把自己的消息发给所有人——唯独张三发的聚餐他收不到。因为张三忘了 addContact(zhaoliu)。群消息遍历的是发送者自己的通讯录,通讯录不同步,消息就丢。这种 bug 的可怕之处在于:不报错、不抛异常,消息悄无声息地消失,排查全靠人肉对账。
再看结构层面的账:四人群 = 12 条关系线,每条都要手工建立、手工维护。赵六进群那一刻,张三、李四、王五的代码全都得改——开闭原则阵亡。
4.2 正解:只认群主的星型结构
四个角色依次登场。抽象中介者:
public interface ChatMediator {
void register(User user);
void sendMessage(String from, String message);
void sendPrivateMessage(String from, String to, String message);
}
抽象同事类——注意它只认识中介者,不认识任何具体同事:
public abstract class User {
protected final ChatMediator mediator;
protected final String name;
protected User(ChatMediator mediator, String name) {
this.mediator = mediator;
this.name = name;
}
public void send(String message) {
System.out.println(" [" + name + " → 群发] " + message);
mediator.sendMessage(name, message);
}
public void privateSend(String to, String message) {
System.out.println(" [" + name + " → " + to + "] " + message);
mediator.sendPrivateMessage(name, to, message);
}
// 包级私有:只有中介者(同包)能调
abstract void receive(String from, String message);
// ...
}
具体中介者——所有"谁该收到什么"的知识,全部收拢到这里:
public class ChatRoom implements ChatMediator {
private final List<User> users = new ArrayList<>();
@Override
public void register(User user) {
for (User member : users) {
member.notifyNewMember(user.getName());
}
users.add(user);
}
@Override
public void sendMessage(String from, String message) {
for (User user : users) {
if (!user.getName().equals(from)) {
user.receive(from, message);
}
}
}
@Override
public void sendPrivateMessage(String from, String to, String message) {
for (User user : users) {
if (user.getName().equals(to)) {
user.receive(from, message + "(私聊)");
return;
}
}
System.out.println(" [群主] 查无此人:" + to + ",私聊失败");
}
}
具体同事就是纯纯的参与者,收消息的姿势各不相同:
public class CommonUser extends User {
@Override
void receive(String from, String message) {
System.out.println(" [普通用户 " + name + " 收到来自 " + from + "] " + message);
}
}
public class VipUser extends User {
@Override
void receive(String from, String message) {
System.out.println(" [VIP " + name + " 火速收到来自 " + from + "] " + message);
}
}
运行:
========== 正解:只认群主的星型结构 ==========
[群主] 欢迎 张三 加入聊天室
[群主] 欢迎 李四 加入聊天室
[系统] 张三 得知 李四 加入了群聊
[群主] 欢迎 王五 加入聊天室
[系统] 张三 得知 王五 加入了群聊
[系统] 李四 得知 王五 加入了群聊
[张三 → 群发] 大家好,我是张三!
[VIP 李四 火速收到来自 张三] 大家好,我是张三!
[普通用户 王五 收到来自 张三] 大家好,我是张三!
[李四 → 张三] 你的头像我好像在哪见过
[普通用户 张三 收到来自 李四] 你的头像我好像在哪见过(私聊)
[王五 → 群发] 周末有人组队打球吗
[普通用户 张三 收到来自 王五] 周末有人组队打球吗
[VIP 李四 火速收到来自 王五] 周末有人组队打球吗
—— 赵六进群:只跟群主打交道,老成员零改动 ——
[群主] 欢迎 赵六 加入聊天室
[系统] 张三 得知 赵六 加入了群聊
[系统] 李四 得知 赵六 加入了群聊
[系统] 王五 得知 赵六 加入了群聊
[赵六 → 群发] 我是新人赵六,请多关照
[普通用户 张三 收到来自 赵六] 我是新人赵六,请多关照
[VIP 李四 火速收到来自 赵六] 我是新人赵六,请多关照
[普通用户 王五 收到来自 赵六] 我是新人赵六,请多关照
[赵六 → 李四] 老哥带带我
[VIP 李四 火速收到来自 赵六] 老哥带带我(私聊)
[赵六 → 田七] 在吗?
[群主] 查无此人:田七,私聊失败
对照事故现场:反面教材里赵六进群要 6 次 addContact 还漏了 1 次;正解里一行 chatRoom.register(zhaoliu) 全搞定,全员零改动。查无此人?群主统一兜底,不会再有消息无声消失。
4.3 三处细节
细节一:不对称的认识关系。 中介者持有所有同事的列表,而每个同事只持有一个中介者引用——并且只认识 ChatMediator 抽象接口。星型的中心认识所有分叉,分叉只认识中心。这就是"换中介者同事类零改动"的底层原因:对同事来说,中介者就是个黑盒接口,聊天室换成客服系统,只要接口对得上,同事浑然不觉。
细节二:包级私有的 receive。 abstract void receive(...) 故意不加 public——同包的中介者能调,外界想绕过群主直接戳成员 receive?编译器第一个不答应。把"必须经过中介者"从团队纪律升级成语法约束,和 ⑳ 迭代器把 next 藏在 Iterator 协议里是同一个思路:走正门,堵后门。
细节三:register 里的顺序。 先 notifyNewMember 通知老成员,再 users.add——新人不会收到"欢迎自己"的系统消息。这种边角规则也只在中介者一个地方定义——反面教材里它得散落在每个成员的 addContact 里,漏一处就是灵异事件。
五、在框架里找它
-
MVC 里的 Controller:GoF 原书就把 MVC 列为中介者模式的应用——View 不直接改 Model,Model 数据变化也不直接命令 View 刷新,Controller 居中协调两者。这是日常开发里最常见的"隐形中介者",㉖ 框架模式篇会展开细讲。
-
Spring 的事件机制 / Guava EventBus:
ApplicationEventPublisher.publishEvent()本质是"中介者接口泛化成事件流"——同事连中介者有哪些具体方法都不用知道,只会发事件和听事件。这是中介者与观察者的混血,第六节细说。 -
MQ 消息中间件(Kafka / RocketMQ):生产者和消费者互不认识,都只连 broker——跨进程的中介者。broker 按主题路由消息,和群主按名单转发消息,干的是同一件事。区别只是中介者从进程内的对象变成了独立部署的基础设施。
-
微服务注册中心(Nacos / Eureka / Zookeeper):服务之间调用不写死对方地址,先问注册中心拿实例列表——把"谁在哪儿"的知识收拢到中心节点,和群主持有成员名单一模一样。星型拓扑撑起了整个微服务世界。
六、中介者 vs 观察者——终于还是走到了这一步
这个系列里两个"解耦谁通知谁"的模式终于碰面了。都解决对象间通信问题,基因却完全不同:
| 维度 | 中介者模式 ㉒ | 观察者模式 ⑯ |
|---|---|---|
| 关系形态 | 多对多,双向协商 | 一对多,单向广播 |
| 谁认识谁 | 中介者认识所有同事,同事只认识中介者 | 主题不认识具体观察者,只持有接口列表 |
| 解耦的对象 | 交互的**双方**(A 和 B 互不认识) | 通知的**发送方与接收方** |
| 典型场景 | "A 私聊 B"、"按钮点击后列表联动刷新" | "数据变了,关心的人各自更新" |
| 交互逻辑住在哪 | 中介者(显式的路由代码) | 分散在各个观察者(各自决定怎么响应) |
| 主要风险 | 中介者膨胀成上帝对象 | 观察者风暴、通知链失控 |
血缘关系其实很近:把中介者接口从 sendMessage / sendPrivateMessage 泛化成 publish(event),把同事的 receive 泛化成 onEvent——中介者就退化成了事件总线。EventBus 和 Spring 事件机制正是这个杂交产物:中介者的星型拓扑 + 观察者的广播语义。
选用口诀一句话:有来有回的协商用中介者,单向的广而告之用观察者。
写在最后
- 网状依赖的本质:N×(N-1) 条关系线,平方级的维护成本,漏一条就是无声的灵异事件
- 中介者的手术:所有"谁该收到什么"的知识收拢进群主,同事只剩一条到中心的线
- 认识关系的不对称是灵魂:中心认识所有分叉,分叉只认识抽象中心
- 最大的坑是上帝对象——群主类膨胀失控,届时要按业务拆中介者,而不是往里硬塞
- 和观察者的分界:协商用中介者,广播用观察者,事件总线是两者的混血
最后一行输出里藏了个伏笔:[群主] 查无此人:田七,私聊失败——群主老实交代了失败。可如果中介方法返回的是 null,调用方就得 if (user != null) 判一路空,判空代码满天飞,漏判一处就是 NullPointerException。能不能造一个"什么都不做的对象"顶替 null,让判空彻底从代码里消失?——空对象模式,下一篇见。