做后端时间长了,你会发现一个规律:凡是带“多个对象要互相通知”这种需求的功能,十有八九最后都会变成一团乱麻。这是咱们设计模式系列的第十八篇,今天聊的主角是这个系列里我最常用、也最容易被用歪的一个——中介者模式。我最近在做的多人会议系统就是这么个例子:主持人禁言、成员发言、私聊提醒、全员公告,一堆功能互相穿插,最初图省事让各个对象直接互相调用,结果改一个方法要牵连七八个类。后来我老老实实把中介者模式请了回来,代码才算消停。中介者模式是行为型设计模式里的老将,核心就一句话:用一个中介对象封装一系列对象之间的交互,让各对象不再显式地相互引用,从而把复杂的网状通信改成简洁的星型通信。这篇就结合我的实际重构经历,聊聊它怎么拆、怎么用、以及踩过哪些坑。前端后端都适用,被对象间通信折磨过的同学应该都会有所共鸣。
1. 从业务痛点说起:为什么需要中介者模式
1.1 一个让人头皮发麻的典型场景
当时我的需求其实一点都不复杂,无非是这么几条规则:主持人点击“全员禁言”后,所有成员的发言按钮要置灰,聊天窗口要滚动输出一条系统提示;如果有成员发起私聊,接收方的通知面板要弹出提醒,聊天窗口要切成私聊会话;任意成员发言之后,成员列表里那个人的状态要变化,通知面板要出现未读,聊天窗口要新增消息。你看,规则一共就这么多,但它们分散在主持人、普通成员、成员列表、聊天窗口、通知面板这五类对象上。
如果按“谁需要什么数据就去找谁拿”的思路写,聊天窗口要感知成员状态,成员状态又要通知按钮组件,按钮组件还要同步给角色权限模块……画出来的调用关系就是一张蜘蛛网。我当时最直观的感受是:新增一个“观众”角色时,要给它接的线比功能本身还多。你说数据联调容易出问题吗?倒也未必,但每次需求变更都要顺着网线找半天,改完这一头还要猜那一头会不会受影响,这种心理负担非常重。
1.2 网状依赖为什么会失控
为什么网状依赖可怕?我们来算一笔简单的账。N个对象如果两两互相通信,最多会产生 N×(N-1)/2 条调用关系。6个对象是15条,10个对象是45条,对象越多,关系数增长得越离谱。而且这些关系不是静态的——每条线两端的类都在各自演进,你今天改一个方法签名,根本不知道哪条线会因为参数变化而崩掉。
这就像智能手机没有普及的年代,每个人都把所有人的手机号存进通讯录,谁换号了就要挨个通知。后来有了总机客服,你只需要记一个总机号码,其他事情都由它来协调。还有一个更生活化的类比:家庭微信群。一家人要商量点事儿,不用挨个私聊,往群里一扔,大家都能看到。中介者模式就是那个群,参与者是群成员,所有消息都经过群里绕一圈,再由群规则决定谁该看见。这种从“网状”到“星型”的转变,正是中介者模式存在的根本理由。
1.3 中介者模式的适用边界
既然中介者这么好用,是不是所有对象通信都该用它?当然不是。我自己的判断标准有下面几个,同时满足才考虑上中介者:第一,参与通信的对象数量比较多,至少三五个起步;第二,交互规则不固定,经常因为产品需求要改;第三,你希望这些对象本身能复用到其他场景,而不是被当前这套交互逻辑绑死。
反过来,如果只是两三个对象之间简单的调用,强行引入中介者反而多了一层间接层,代码绕来绕去还不好读。如果通信方向很明确,就是一个对象发生了什么事要通知一堆下游对象,这种情况观察者模式更轻量。中介者是精密的总机,不是万能的传话器,别拿大炮打蚊子。这个边界想清楚,后面写代码时就不容易跑偏。
2. 结构拆解:中介者模式的四个核心角色
2.1 中介者接口与具体中介者
中介者模式的标准类图,看多了其实很容易审美疲劳,我习惯把它拆成四个角色来记:抽象中介者(Mediator)、具体中介者(ConcreteMediator)、抽象同事(Colleague)、具体同事(ConcreteColleague)。注意,前两个是一组,后两个是另一组,它们之间通过接口互相耦合,不是在同一个类里各写各的。
抽象中介者负责定义通信协议,比如注册方法、群发消息、私聊消息、系统公告这些接口。具体中介者是整个模式的心脏,它要持有所有同事对象的引用,并且真正实现路由逻辑——收到一条消息该转给谁、该跳过谁,规则全写在它这里。在上面的会议系统里,协调者可以设计成 MeetingMediator;后面实操部分我会用一个更简单的 ChatRoom 来演示,职责完全一样。
这个阶段最容易犯的错,是把中介者当成“通信工具类”,里面堆一堆静态方法。我见过有人把消息处理写成public static void sendToAll(List<User> users, Message msg),然后所有地方都去调它。这确实也是星型结构,但它不是一个对象,没有状态,无法维护成员注册信息,更没办法承载复杂的交互规则。中介者必须是一个有状态的对象,它知道谁在线、谁被禁言、谁正在私聊中。
2.2 同事类的正确打开方式
同事类(Colleague)是模式的另一半。它做的事情有两件:一是持有中介者的引用,二是把自己的行为暴露成中介者能回调的方法。这里有个关键点,同事类持有的中介者一定要是接口类型,并且通过构造函数注入,而不是自己去new一个具体中介者。一旦同事类里出现new ChatRoom(),这个模式就废了一半——你没法在测试时替换中介者,也没法在不同环境切换到不同协调策略。
同事类与同事类之间,禁止直接互相引用,这是整个模式的纪律核心。A 发送消息给 B,A 不知道 B 的存在,A 只是把消息交给中介者,中介者再掉头调用 B 的回调方法。只有这样,新增一个同事类型时才不用去改老同事类的任何代码,真正把变化收敛到单独一个类里。说白了,同事类要守的规矩就一条:我只认中介者,不认其他同事。
2.3 职责分配:中介者该管什么
职责分配上我吃过亏,一开始容易把业务逻辑全塞到中介者里。后来总结出两条原则。第一条,中介者只负责做路由和规则编排,不负责具体业务实现。“禁言后要把成员的发言按钮置灰”是协调规则,但是按钮具体怎么置灰、怎么恢复,那是同事类自己的事情。第二条,交互规则过多时,中介者内部要做拆分。一个中介者里十几个方法,每个方法里面一大坨 if-else,这已经不是中介者了,是金佛,谁也不敢碰。我常用的做法是把相关的一组交互拆成一个小中介者,比如聊天归聊天室管,权限归权限中心管,两个中介者之间再通过事件总线联动。
另外,如果同事类型比较多,建议在中介者里用Map按角色或者名称注册,而不要用List一遍遍遍历。虽然系统复杂度不高时两者没差别,但一旦同事数量上来,get比 for 循环优雅得多,也能避免很多低级错误。我自己习惯的注册表是Map<Class<? extends Colleague>, Colleague>,按类型取,语义清晰。
3. 实操演练:手写一个聊天室Demo
3.1 需求定义与角色设计
废话不多说,直接上代码。我以一个最简单的聊天室作为例子,因为它最能说明中介者的核心价值:多个人之间互相通信,但没有一个人直接握着别人的引用。需求只有三条:成员注册后能加入聊天室;任何成员发消息,聊天室广播给所有其他成员;支持私聊,只有指定接收人能收到。我再加一条系统公告,方便演示中介者主动广播的能力——这条功能后面解释为什么会很有用。
角色划分很直接:ChatMediator是抽象中介者,定义注册、群发、私聊、公告四个接口;ChatRoom是具体中介者,负责维护成员列表和路由;User是抽象同事,持有中介者引用,提供发送和接收的抽象方法;ChatUser是具体同事,实现发送和接收的动作。整体结构可以看下面这张表。
| 角色 | 类名 | 职责 |
|---|---|---|
| 抽象中介者 | ChatMediator | 定义注册、群发、私聊、公告接口 |
| 具体中介者 | ChatRoom | 维护成员 Map,实现消息路由 |
| 抽象同事 | User | 持有中介者引用,定义收发行为 |
| 具体同事 | ChatUser | 具体发送逻辑与接收回显 |
3.2 核心代码实现
先写中介者接口和具体实现。注册时用Map按名字存,群发遍历时跳过发送者,私聊直接用get定位目标,公告则是中介者主动向所有人广播。
public interface ChatMediator { void register(User user); void sendMessage(String message, User sender); void sendPrivateMessage(String message, User sender, String receiver); void announce(String message); }import java.util.HashMap; import java.util.Map; public class ChatRoom implements ChatMediator { private Map<String, User> users = new HashMap<>(); @Override public void register(User user) { users.put(user.getName(), user); System.out.println(user.getName() + " 加入了聊天室"); } @Override public void sendMessage(String message, User sender) { for (User user : users.values()) { if (user != sender) { user.receive(message, sender.getName()); } } } @Override public void sendPrivateMessage(String message, User sender, String receiver) { User target = users.get(receiver); if (target != null && target != sender) { target.receive("(私聊)" + message, sender.getName()); } } @Override public void announce(String message) { for (User user : users.values()) { user.receive(message, "系统"); } } }接着是同事类的抽象基类和具体实现。注意构造函数里必须传入中介者,这是整个模式能够跑起来的前提。
public abstract class User { protected ChatMediator mediator; protected String name; public User(ChatMediator mediator, String name) { this.mediator = mediator; this.name = name; } public String getName() { return name; } public abstract void send(String message); public abstract void sendPrivate(String message, String receiver); public abstract void receive(String message, String sender); }public class ChatUser extends User { public ChatUser(ChatMediator mediator, String name) { super(mediator, name); } @Override public void send(String message) { System.out.println("[" + name + " 发送]" + message); mediator.sendMessage(message, this); } @Override public void sendPrivate(String message, String receiver) { System.out.println("[" + name + " 私聊 " + receiver + "]" + message); mediator.sendPrivateMessage(message, this, receiver); } @Override public void receive(String message, String sender) { System.out.println("[" + name + " 收到来自 " + sender + " 的消息] " + message); } }最后是调用入口。三个成员接入同一个中介者,之后各自只管发消息,完全不知道其他成员具体是谁。
public class Demo { public static void main(String[] args) { ChatMediator mediator = new ChatRoom(); User alice = new ChatUser(mediator, "Alice"); User bob = new ChatUser(mediator, "Bob"); User charlie = new ChatUser(mediator, "Charlie"); mediator.register(alice); mediator.register(bob); mediator.register(charlie); alice.send("大家好,我是Alice"); bob.sendPrivate("明天下午三点开会", "Alice"); mediator.announce("今晚21:00服务器维护,请提前保存资料"); } }3.3 运行效果与扩展实验
运行这段代码,控制台输出大致如下。
Alice 加入了聊天室 Bob 加入了聊天室 Charlie 加入了聊天室 [Alice 发送]大家好,我是Alice [Bob 收到来自 Alice 的消息] 大家好,我是Alice [Charlie 收到来自 Alice 的消息] 大家好,我是Alice [Bob 私聊 Alice]明天下午三点开会 [Alice 收到来自 Bob 的消息] (私聊)明天下午三点开会 [Alice 收到来自 系统 的消息] 今晚21:00服务器维护,请提前保存资料 [Bob 收到来自 系统 的消息] 今晚21:00服务器维护,请提前保存资料 [Charlie 收到来自 系统 的消息] 今晚21:00服务器维护,请提前保存资料注意几个容易被忽略的细节。第一,群发时用了user != sender判断,避免自己给自己回显,这是符合直觉的;如果产品要求“自己也能看到已发送”,把这个判断去掉或者改成允许回显。第二,私聊时直接通过Map的get定位目标,比遍历整个列表高效得多。第三,公告走mediator.announce,调用者不需要持有任何成员对象,这就是中介者作为中枢的好处:接入方只需要认识中介者,不需要认识所有相关方。
还有一个很值得做的扩展实验。你可以试着加一个“踢人”功能:在ChatRoom里加一个remove(User user)方法,从users里移除并广播一条消息。你会发现,这个功能只动了ChatRoom,其他同事类的代码一行都不用改。当初我在老代码上做类似需求,翻遍了四五个类才理清关系,这就是把交互规则收敛到中介者之后,实实在在节省下来的时间。
4. 模式对比与大型系统中的应用
4.1 中介者 vs 观察者:方向和解耦层级不一样
中介者模式经常和观察者模式混在一起,因为都有点“事件通知”的意思。我见过不少面试者在这道题上翻车,今天把差异点说透。观察者模式是典型的一对多关系:一个 Subject(被观察者)状态变化,通知所有 Observer(观察者)。通信方向是单向的,Subject 不需要知道 Observer 的具体类型,新增一个观察者也不影响被观察者。它的典型实现就是事件监听器、发布订阅。
中介者模式是多对多关系:多个同事对象之间来回通信,通信方向是双向的,而且所有通信都必须经过中介者。观察者的广播是“通知到位就完事”,中介者则要承担路由决策——这条消息该给谁、不该给谁。所以观察者模式实现广播很简单,但做不到精确路由;中介者模式更重,但能承载复杂交互规则。工程上两者经常结合使用,比如 Spring 的事件发布订阅是观察者,而事件从产生到派发到处理器的调度过程,里面的 EventMulticaster 就有点像中介者。
4.2 中介者 vs 门面模式:一个对外,一个对内
门面模式(Facade)也容易和中介者搞混,毕竟都是多了一层封装。区别其实在方向。门面模式的核心是给外部提供一个统一入口,让外部调用若干个子系统变得简单。它关注的是“对外简化”,子系统内部该怎么通信还怎么通信,门面不做中间商。典型例子是 Controller/Service/DAO 架构里的 Controller,它只是把外部请求转发给合适的 Service,Service 之间的调用它不管。
中介者正好相反,它关注的是“内部协调”。它管的是同事对象之间的每一次交互,谁发给谁、谁跳过谁、谁先谁后,都由中介者说了算。用一句口诀记:门面是“统一的入口”,对外;中介者是“协调的中枢”,对内。一个站在边境收门票,一个站在公司里管沟通,谁都替不了谁。
4.3 大型系统里的中介者思想
把视野放大,中介者思想其实到处都在用。前端的表单联动是一个典型例子:选了省份要刷新城市列表,选了城市要刷新详细区域,勾选某个选项要禁用一批控件。如果不封装,这些控件会互相持有引用,最终变成一个前端噩梦。比较好的做法是用一个 Controller 或者 Mediator 统一管理控件的联动事件,这跟聊天室是同一个骨架。
后端这边,消息队列(MQ)就是分布式场景下最典型的“中介者落地形态”之一。多个微服务之间不直接点对点调用,而是把消息发到队列或者主题上,由 broker 负责投递,生产者和消费者互不知道对方地址。这和“同事之间不直接互相引用”是同一个思想,差别只是换成了分布式节点。MVC 里的 Controller 也是典型的中介者实践,它协调 View 和 Model 之间的更新,让这两个角色尽量不直接耦合。所以你看,中介者模式不是停留在教科书里的名词,它已经是无数系统设计里的基础骨架。
5. 常见问题与避坑指南
5.1 中介者沦落成“上帝对象”
第一个坑,也是最常见的坑:中介者慢慢变成了上帝对象。为什么?因为它承载了所有交互规则,大家都依赖它,它就成了全项目最“核心”也最臃肿的类,几千行 if-else 堆在那里,改一行要回归整个系统。我的处理经验是三条。第一,只路由,不实现。交互规则可以在这里编排,但具体的业务逻辑一定要下沉到同事类。第二,按领域拆分。如果发现一个中介者同时管聊天、权限、日志、统计,那就把它拆成多个小中介者,每个只负责一组高内聚的对象。第三,认真考虑用命令模式辅助:把每条交互规则封装成 Command 对象,中介者负责接收 Command 并执行,这样规则可以被替换、被复用、被测试,而不是硬编码在巨大方法里。
判断自己是不是快走上这条歪路的信号也很简单:打开中介者类,如果它已经开始出现多个含义不相关的方法,同时类头注释写着“核心逻辑都在这里”,那就要警惕了。
5.2 同事类“私相授受”导致模式失效
第二种常见问题是队友把模式写歪了:同事类之间偷偷互相引用。表现是,明明中介者已经接好线了,同事 A 为了省两步调用,直接去 new 一个同事 B,或者调 B 的某个静态方法。一开始只是“顺路调一下”,时间久了互相引用的线越来越多,中介者变成装饰品,网状依赖卷土重来。
这个问题的本质是纪律问题,不是技术问题。我的治理办法也很朴素:代码评审时专门检查同事类有没有依赖其他具体同事;测试时用一个 Mock 中介者,重点验证同事 A 只和中介者通信,如果出现了指向其他同事的调用,测试直接就失败。再配合点团队规矩,比如“在同事类里直接 import 另一个具体同事类就请下午茶”,坚持两周基本能扭转风气。
5.3 时序与并发:中介者的性能隐患
中介者把所有交互收敛到了一个点上,这个点自然而然成为性能或并发的集中点。我自己踩过的坑是:聊天室注册列表用普通 ArrayList,高并发下一边广播一边有成员进进出出,时不时抛 ConcurrentModificationException。后来把存储换成 CopyOnWriteArrayList 或者 ConcurrentHashMap 就好了。如果你对消息到达顺序有要求,比如“A 发的消息必须在 B 发的消息之前被处理”,那单机中介者内部要做队列缓冲或同步处理,不能直接开线程乱发。
跨服务场景下,单机中介者通常撑不住大规模通信。此时正确做法是把中介者的角色交给消息中间件,由 MQ 保证可靠投递和顺序性。记住,中介者模式是一种设计思想,不是具体组件;当你发现单机对象已经扛不住,大胆把“中介者”升级成分布式组件就好。
5.4 换语言实现时的思路差异
Java 里我们用接口加抽象类来搭骨架;如果主力是 JavaScript 或者 Python,其实更自由。同事类里完全可以不写抽象基类,直接把 mediator 作为参数传进函数,甚至用回调函数替代 receive 方法。TypeScript 则可以用泛型把同事类型约束得更严格。语言不同,但思想相同:让对象之间不直接说话,统一通过一个协调者。
这种“换了语言思想不变”的特点,也是我建议新手朋友学设计模式时不要死记类图的原因。你把中介者模式的动机记牢——为了降低多对象交互的耦合,把交互规则集中管理——然后不管什么语言都能写出味道正宗的实现。
5.5 给新手的落地建议
最后给刚上手的同学几条接地气的建议。第一,不要为了模式而模式。我见过不少代码,对象就俩,硬套中介者,结果方法调用多绕了两层,调试还得跳来跳去。等代码出现“改一个方法要牵动多个类”的苗头时,再动手重构。第二,从小场景练手。比如自己写一个简化表单联动,或者实现一个小组件版本的小型 IM,把中介者跑顺,比背一百遍类图管用。第三,画辅助图时只要大概画出谁是中介者、哪些对象和它连接就够了,重点验证“任意两个同事类之间没有直接连接”,这个能验证通过,模式就成功了一大半。
做了这么多年开发,我的体会是:设计模式不是背出来的名词,而是解决问题的工具箱。中介者模式的长处,是把一群对象之间的复杂交互,收敛成一条清晰的主干道,让每个人只和总机打交道,不必在乎另一端是谁。但它也不是银弹,用歪了照样让你背上一座“上帝对象”的大山。真遇到多对象交互的痛点时,先冷静分析一下交互规则是否经常变、对象数量是否够多,再决定要不要请这尊总机出山。这样写出来的代码,至少半年后回来看,还能知道当时的自己为什么要这么设计。