设计模式预计阅读 17 分钟6591 字1550 次浏览

设计模式修炼之路 ㉒ 中介者模式——网状依赖变星型

网状依赖变星型、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 条——依赖从平方级降到线性级,这是这个模式全部价值的数学表达。

二、优点与缺点

优点:

  1. 网状变星型:依赖条数从 N×(N-1) 降为 N,"来一个新人全员改代码"的惨剧从机制上根除——新人只注册给群主,老成员零改动。
  2. 交互规则集中:"谁该收到什么"的知识全部住在群主家。改路由规则(比如"群主消息不发给发送者本人")只改一处,而不是改遍所有成员。
  3. 同事类可复用:CommonUser 对中介者内部一无所知,只认识一个抽象接口——把聊天室换成公会频道、客服系统,同事类一行不改。
  4. 类间解耦:张三和李四互相不知道对方存在,删掉李四,张三毫无感知。

缺点:

  1. 上帝对象风险:所有交互逻辑都往 ConcreteMediator 里堆,群主类从 30 行膨胀到 3000 行,变成无所不知无所不管的上帝对象——GoF 原书都专门警告了这一点,这是该模式最大的坑。
  2. 中介者单点:所有消息都过它,它挂了全群哑火;高并发场景下,中心节点就是性能瓶颈。
  3. 同事与中介者双向依赖: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 里,漏一处就是灵异事件。

五、在框架里找它

  1. MVC 里的 Controller:GoF 原书就把 MVC 列为中介者模式的应用——View 不直接改 Model,Model 数据变化也不直接命令 View 刷新,Controller 居中协调两者。这是日常开发里最常见的"隐形中介者",㉖ 框架模式篇会展开细讲。

  2. Spring 的事件机制 / Guava EventBus:ApplicationEventPublisher.publishEvent() 本质是"中介者接口泛化成事件流"——同事连中介者有哪些具体方法都不用知道,只会发事件和听事件。这是中介者与观察者的混血,第六节细说。

  3. MQ 消息中间件(Kafka / RocketMQ):生产者和消费者互不认识,都只连 broker——跨进程的中介者。broker 按主题路由消息,和群主按名单转发消息,干的是同一件事。区别只是中介者从进程内的对象变成了独立部署的基础设施。

  4. 微服务注册中心(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,让判空彻底从代码里消失?——空对象模式,下一篇见。

花

花开空白

开发者 · 西安

相关文章

评论(0)

还没有评论,来抢沙发吧

发表评论