ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

中介者模式详解:用聊天室Demo破解多对象通信耦合难题

中介者模式详解:用聊天室Demo破解多对象通信耦合难题 做后端时间长了你会发现一个规律凡是带“多个对象要互相通知”这种需求的功能十有八九最后都会变成一团乱麻。这是咱们设计模式系列的第十八篇今天聊的主角是这个系列里我最常用、也最容易被用歪的一个——中介者模式。我最近在做的多人会议系统就是这么个例子主持人禁言、成员发言、私聊提醒、全员公告一堆功能互相穿插最初图省事让各个对象直接互相调用结果改一个方法要牵连七八个类。后来我老老实实把中介者模式请了回来代码才算消停。中介者模式是行为型设计模式里的老将核心就一句话用一个中介对象封装一系列对象之间的交互让各对象不再显式地相互引用从而把复杂的网状通信改成简洁的星型通信。这篇就结合我的实际重构经历聊聊它怎么拆、怎么用、以及踩过哪些坑。前端后端都适用被对象间通信折磨过的同学应该都会有所共鸣。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(ListUser users, Message msg)然后所有地方都去调它。这确实也是星型结构但它不是一个对象没有状态无法维护成员注册信息更没办法承载复杂的交互规则。中介者必须是一个有状态的对象它知道谁在线、谁被禁言、谁正在私聊中。2.2 同事类的正确打开方式同事类Colleague是模式的另一半。它做的事情有两件一是持有中介者的引用二是把自己的行为暴露成中介者能回调的方法。这里有个关键点同事类持有的中介者一定要是接口类型并且通过构造函数注入而不是自己去new一个具体中介者。一旦同事类里出现new ChatRoom()这个模式就废了一半——你没法在测试时替换中介者也没法在不同环境切换到不同协调策略。同事类与同事类之间禁止直接互相引用这是整个模式的纪律核心。A 发送消息给 BA 不知道 B 的存在A 只是把消息交给中介者中介者再掉头调用 B 的回调方法。只有这样新增一个同事类型时才不用去改老同事类的任何代码真正把变化收敛到单独一个类里。说白了同事类要守的规矩就一条我只认中介者不认其他同事。2.3 职责分配中介者该管什么职责分配上我吃过亏一开始容易把业务逻辑全塞到中介者里。后来总结出两条原则。第一条中介者只负责做路由和规则编排不负责具体业务实现。“禁言后要把成员的发言按钮置灰”是协调规则但是按钮具体怎么置灰、怎么恢复那是同事类自己的事情。第二条交互规则过多时中介者内部要做拆分。一个中介者里十几个方法每个方法里面一大坨 if-else这已经不是中介者了是金佛谁也不敢碰。我常用的做法是把相关的一组交互拆成一个小中介者比如聊天归聊天室管权限归权限中心管两个中介者之间再通过事件总线联动。另外如果同事类型比较多建议在中介者里用Map按角色或者名称注册而不要用List一遍遍遍历。虽然系统复杂度不高时两者没差别但一旦同事数量上来get比 for 循环优雅得多也能避免很多低级错误。我自己习惯的注册表是MapClass? extends Colleague, Colleague按类型取语义清晰。3. 实操演练手写一个聊天室Demo3.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 MapString, 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它只是把外部请求转发给合适的 ServiceService 之间的调用它不管。中介者正好相反它关注的是“内部协调”。它管的是同事对象之间的每一次交互谁发给谁、谁跳过谁、谁先谁后都由中介者说了算。用一句口诀记门面是“统一的入口”对外中介者是“协调的中枢”对内。一个站在边境收门票一个站在公司里管沟通谁都替不了谁。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把中介者跑顺比背一百遍类图管用。第三画辅助图时只要大概画出谁是中介者、哪些对象和它连接就够了重点验证“任意两个同事类之间没有直接连接”这个能验证通过模式就成功了一大半。做了这么多年开发我的体会是设计模式不是背出来的名词而是解决问题的工具箱。中介者模式的长处是把一群对象之间的复杂交互收敛成一条清晰的主干道让每个人只和总机打交道不必在乎另一端是谁。但它也不是银弹用歪了照样让你背上一座“上帝对象”的大山。真遇到多对象交互的痛点时先冷静分析一下交互规则是否经常变、对象数量是否够多再决定要不要请这尊总机出山。这样写出来的代码至少半年后回来看还能知道当时的自己为什么要这么设计。
返回列表