第 24 章 · 中介者(Mediator)
一句话:一组互相联动的对象不再互相持有,各自只认识一个中介者;谁变了就告诉它,由它决定通知谁、按什么顺序。 解决的痛:结算页的几个组件你调我、我调你,加一个积分组件要改四个类;漏改一处,改地址时积分没重算,多扣了用户 600 积分。
24.1 一个场景
Section titled “24.1 一个场景”你在多练网做周边商城的结算页。页面上有四个组件:收货地址、运费、优惠券、总价。它们是联动的:
- 地址变了,运费要重算:新疆、西藏这类偏远地区 30 元,其余 10 元。
- 地址变了,可用的优惠券要刷新:满 300 减 40 的券偏远地区不能用。
- 运费或优惠券变了,总价要重算。用户手动换券,也要重算总价。
顺序也有讲究:总价必须在运费和优惠券都算完之后再算,否则用的是旧数。
24.2 先按直觉写
Section titled “24.2 先按直觉写”谁变了谁就去调别人。地址组件手里拿着运费、优惠券、总价三个组件,按顺序调它们:
REMOTE = {"新疆", "西藏"} # 偏远地区:运费 30 元,不能用满减券
class ShippingWidget: def __init__(self) -> None: self.fee = 0
def update(self, city: str) -> None: self.fee = 30 if city in REMOTE else 10
class TotalWidget: def __init__(self, subtotal: int, shipping: ShippingWidget) -> None: self.subtotal = subtotal self.shipping = shipping # 直接持有运费组件 self.coupons: CouponWidget # 接线时补上
def recalc(self) -> None: pay = self.subtotal + self.shipping.fee - self.coupons.discount print(f" 应付 {pay} = 商品 {self.subtotal} + 运费 {self.shipping.fee} - 券 {self.coupons.discount}")
class CouponWidget: def __init__(self, total: TotalWidget) -> None: self.total = total # 直接持有总价组件 self.discount = 0
def refresh(self, city: str) -> None: # 可用券随地址变,自动选上能用的那张 self.discount = 0 if city in REMOTE else 40
def select(self, discount: int) -> None: # 用户手动换券 print(f"换成减 {discount} 的券") self.discount = discount self.total.recalc()
class AddressWidget: def __init__(self, shipping: ShippingWidget, coupons: CouponWidget, total: TotalWidget) -> None: self.shipping, self.coupons, self.total = shipping, coupons, total
def change(self, city: str) -> None: print(f"地址改为{city}") self.shipping.update(city) # 顺序要对:先运费、再券、最后总价 self.coupons.refresh(city) self.total.recalc()
if __name__ == "__main__": shipping = ShippingWidget() total = TotalWidget(300, shipping) coupons = CouponWidget(total) total.coupons = coupons address = AddressWidget(shipping, coupons, total) address.change("新疆") address.change("上海") coupons.select(20)
# 输出:# 地址改为新疆# 应付 330 = 商品 300 + 运费 30 - 券 0# 地址改为上海# 应付 270 = 商品 300 + 运费 10 - 券 40# 换成减 20 的券# 应付 290 = 商品 300 + 运费 10 - 券 20TS 版一样:
const REMOTE = new Set(["新疆", "西藏"]); // 偏远地区:运费 30 元,不能用满减券
class ShippingWidget { fee = 0;
update(city: string): void { this.fee = REMOTE.has(city) ? 30 : 10; }}
class TotalWidget { coupons!: CouponWidget; // 接线时补上
constructor(readonly subtotal: number, readonly shipping: ShippingWidget) {} // 直接持有运费组件
recalc(): void { const pay = this.subtotal + this.shipping.fee - this.coupons.discount; console.log(` 应付 ${pay} = 商品 ${this.subtotal} + 运费 ${this.shipping.fee} - 券 ${this.coupons.discount}`); }}
class CouponWidget { discount = 0;
constructor(readonly total: TotalWidget) {} // 直接持有总价组件
refresh(city: string): void { // 可用券随地址变,自动选上能用的那张 this.discount = REMOTE.has(city) ? 0 : 40; }
select(discount: number): void { // 用户手动换券 console.log(`换成减 ${discount} 的券`); this.discount = discount; this.total.recalc(); }}
class AddressWidget { constructor(readonly shipping: ShippingWidget, readonly coupons: CouponWidget, readonly total: TotalWidget) {}
change(city: string): void { console.log(`地址改为${city}`); this.shipping.update(city); // 顺序要对:先运费、再券、最后总价 this.coupons.refresh(city); this.total.recalc(); }}
const shipping = new ShippingWidget();const total = new TotalWidget(300, shipping);const coupons = new CouponWidget(total);total.coupons = coupons;const address = new AddressWidget(shipping, coupons, total);address.change("新疆");address.change("上海");coupons.select(20);
// 输出:// 地址改为新疆// 应付 330 = 商品 300 + 运费 30 - 券 0// 地址改为上海// 应付 270 = 商品 300 + 运费 10 - 券 40// 换成减 20 的券// 应付 290 = 商品 300 + 运费 10 - 券 20四个组件,联动关系一眼看得清。大多数人第一版都这么写。
24.3 需求变了
Section titled “24.3 需求变了”新需求是:加一个“积分抵扣”组件。用户勾选后,100 积分抵 1 元,最多抵扣运费和优惠券算完之后金额的 10%。它受地址和优惠券影响,又影响总价。
要改的地方:
- 新建
PointsWidget,它要持有TotalWidget,从那里拿“积分前的金额”。 TotalWidget反过来要持有PointsWidget:拆出before_points(),应付里再减掉积分。- 凡是会改变积分前金额的组件,都要在调总价之前先让积分重算:
CouponWidget.select要加,AddressWidget.change也要加。
同事改了前两处和 CouponWidget,地址组件那个文件没打开:
class TotalWidget: # ② 改 def __init__(self, subtotal: int, shipping: ShippingWidget) -> None: self.subtotal = subtotal self.shipping = shipping self.coupons: CouponWidget self.points: PointsWidget # 新引用
def before_points(self) -> int: return self.subtotal + self.shipping.fee - self.coupons.discount
def recalc(self) -> None: d = self.points.deduct print(f" 应付 {self.before_points() - d} = 商品 {self.subtotal} + 运费 {self.shipping.fee}" f" - 券 {self.coupons.discount} - 积分 {d}(扣 {d * 100} 积分)")
class PointsWidget: # ① 新组件 def __init__(self, total: TotalWidget, balance: int) -> None: self.total, self.balance = total, balance self.enabled, self.deduct = False, 0 # deduct:抵扣多少元
def recalc(self) -> None: base = self.total.before_points() self.deduct = min(self.balance // 100, base // 10) if self.enabled else 0
def use(self) -> None: # 用户勾选"用积分抵扣" print("勾选用积分") self.enabled = True self.recalc() self.total.recalc()CouponWidget 加一个 self.points 引用,select 里在 self.total.recalc() 之前加一行 self.points.recalc()。AddressWidget 原样没动。入口改成:
if __name__ == "__main__": shipping = ShippingWidget() total = TotalWidget(300, shipping) coupons = CouponWidget(total) points = PointsWidget(total, balance=5000) total.coupons, total.points, coupons.points = coupons, points, points address = AddressWidget(shipping, coupons, total) address.change("新疆") points.use() address.change("上海")运行结果如下(TS 版改的是同样的地方,输出相同):
地址改为新疆 应付 330 = 商品 300 + 运费 30 - 券 0 - 积分 0(扣 0 积分)勾选用积分 应付 297 = 商品 300 + 运费 30 - 券 0 - 积分 33(扣 3300 积分)地址改为上海 应付 237 = 商品 300 + 运费 10 - 券 40 - 积分 33(扣 3300 积分)最后一行错了。改到上海后积分前是 270 元,最多抵 27 元、扣 2700 积分;积分组件没被通知,还按新疆时的 330 元算,多扣了 600 积分。程序不报错,测试只测了“先填地址再勾积分”的顺序。用户下单后发现积分不对,投诉到客服才查出来。
问题的形状是:每加一个组件,所有和它有关的组件都要互相加引用;“谁变了要通知谁、按什么顺序”散在各个组件里,漏一处不报错。
黄色是新加的 PointsWidget 和 ①②③ 三处新连线,浅红的 TotalWidget 是改过的;红色虚线是漏掉的那条:地址变了没有通知积分,右边加粗的框就是这次事故。
24.4 用中介者模式重写
Section titled “24.4 用中介者模式重写”加一个 CheckoutMediator。它创建并持有所有组件;组件只持有中介者,自己变了就调 mediator.changed(self),谁也不认识谁。“变了之后通知谁、按什么顺序”全写在 changed 里:
from typing import Protocol
REMOTE = {"新疆", "西藏"}
class Mediator(Protocol): def changed(self, sender: "Widget") -> None: ...
class Widget: def __init__(self, mediator: Mediator) -> None: self.mediator = mediator # 组件只认识中介者
class AddressWidget(Widget): city = ""
def change(self, city: str) -> None: print(f"地址改为{city}") self.city = city self.mediator.changed(self)
class ShippingWidget(Widget): fee = 0
def update(self, city: str) -> None: self.fee = 30 if city in REMOTE else 10
class CouponWidget(Widget): discount = 0
def refresh(self, city: str) -> None: self.discount = 0 if city in REMOTE else 40
def select(self, discount: int) -> None: print(f"换成减 {discount} 的券") self.discount = discount self.mediator.changed(self)
class PointsWidget(Widget): # 新组件:只认识中介者 enabled, deduct, balance = False, 0, 5000
def use(self) -> None: print("勾选用积分") self.enabled = True self.mediator.changed(self)
def recalc(self, base: int) -> None: self.deduct = min(self.balance // 100, base // 10) if self.enabled else 0
class TotalWidget(Widget): def show(self, subtotal: int, fee: int, discount: int, deduct: int) -> None: pay = subtotal + fee - discount - deduct print(f" 应付 {pay} = 商品 {subtotal} + 运费 {fee} - 券 {discount} - 积分 {deduct}(扣 {deduct * 100} 积分)")
class CheckoutMediator: def __init__(self, subtotal: int) -> None: self.subtotal = subtotal self.address, self.shipping = AddressWidget(self), ShippingWidget(self) self.coupons, self.points, self.total = CouponWidget(self), PointsWidget(self), TotalWidget(self)
def changed(self, sender: Widget) -> None: # 所有联动规则都在这里 if sender is self.address: self.shipping.update(self.address.city) self.coupons.refresh(self.address.city) # 不管谁变了,后面都按同一个顺序算:积分 → 总价 self.points.recalc(self.subtotal + self.shipping.fee - self.coupons.discount) self.total.show(self.subtotal, self.shipping.fee, self.coupons.discount, self.points.deduct)
if __name__ == "__main__": page = CheckoutMediator(300) page.address.change("新疆") page.points.use() page.address.change("上海") page.coupons.select(20)
# 输出:# 地址改为新疆# 应付 330 = 商品 300 + 运费 30 - 券 0 - 积分 0(扣 0 积分)# 勾选用积分# 应付 297 = 商品 300 + 运费 30 - 券 0 - 积分 33(扣 3300 积分)# 地址改为上海# 应付 243 = 商品 300 + 运费 10 - 券 40 - 积分 27(扣 2700 积分)# 换成减 20 的券# 应付 261 = 商品 300 + 运费 10 - 券 20 - 积分 29(扣 2900 积分)TS 版一样:
const REMOTE = new Set(["新疆", "西藏"]);
interface Mediator { changed(sender: Widget): void;}
class Widget { constructor(protected readonly mediator: Mediator) {} // 组件只认识中介者}
class AddressWidget extends Widget { city = "";
change(city: string): void { console.log(`地址改为${city}`); this.city = city; this.mediator.changed(this); }}
class ShippingWidget extends Widget { fee = 0;
update(city: string): void { this.fee = REMOTE.has(city) ? 30 : 10; }}
class CouponWidget extends Widget { discount = 0;
refresh(city: string): void { this.discount = REMOTE.has(city) ? 0 : 40; }
select(discount: number): void { console.log(`换成减 ${discount} 的券`); this.discount = discount; this.mediator.changed(this); }}
class PointsWidget extends Widget { // 新组件:只认识中介者 enabled = false; deduct = 0; balance = 5000;
use(): void { console.log("勾选用积分"); this.enabled = true; this.mediator.changed(this); }
recalc(base: number): void { this.deduct = this.enabled ? Math.min(Math.floor(this.balance / 100), Math.floor(base / 10)) : 0; }}
class TotalWidget extends Widget { show(subtotal: number, fee: number, discount: number, deduct: number): void { const pay = subtotal + fee - discount - deduct; console.log(` 应付 ${pay} = 商品 ${subtotal} + 运费 ${fee} - 券 ${discount} - 积分 ${deduct}(扣 ${deduct * 100} 积分)`); }}
class CheckoutMediator implements Mediator { readonly address = new AddressWidget(this); readonly shipping = new ShippingWidget(this); readonly coupons = new CouponWidget(this); readonly points = new PointsWidget(this); readonly total = new TotalWidget(this);
constructor(readonly subtotal: number) {}
changed(sender: Widget): void { // 所有联动规则都在这里 if (sender === this.address) { this.shipping.update(this.address.city); this.coupons.refresh(this.address.city); } // 不管谁变了,后面都按同一个顺序算:积分 → 总价 this.points.recalc(this.subtotal + this.shipping.fee - this.coupons.discount); this.total.show(this.subtotal, this.shipping.fee, this.coupons.discount, this.points.deduct); }}
const page = new CheckoutMediator(300);page.address.change("新疆");page.points.use();page.address.change("上海");page.coupons.select(20);
// 输出:// 地址改为新疆// 应付 330 = 商品 300 + 运费 30 - 券 0 - 积分 0(扣 0 积分)// 勾选用积分// 应付 297 = 商品 300 + 运费 30 - 券 0 - 积分 33(扣 3300 积分)// 地址改为上海// 应付 243 = 商品 300 + 运费 10 - 券 40 - 积分 27(扣 2700 积分)// 换成减 20 的券// 应付 261 = 商品 300 + 运费 10 - 券 20 - 积分 29(扣 2900 积分)所有的线都连到中间。黄色的 PointsWidget 是新加的,它只连了中介者一条线。
地址组件只说了一句“我变了”,后面四步的顺序都是中介者定的。
对照 24.3:
- 第 ① 处:
PointsWidget只持有中介者,不认识总价。 - 第 ② 处:
TotalWidget不认识积分,数由中介者算好递给它。 - 第 ③ 处:地址、优惠券组件一行没改。积分重算写在
changed的公共尾巴里,谁变了都会走到,“改地址忘了通知积分”这种漏法没有了。
加积分时,改的只有 CheckoutMediator:多建一个组件,changed 里多一行。这是代价所在:所有联动规则都堆进中介者。结算页再加发票、礼品卡、分期,changed 会长成几百行的 if,每个人改结算页都要改它,合并代码时容易冲突。规则多了就要拆,比如把“金额怎么算”抽成一个价格计算类,中介者只管“谁变了调谁”。
24.5 模式定义
Section titled “24.5 模式定义”GoF 对中介者的定义是:用一个中介对象来封装一系列的对象交互。中介者使各对象不需要显式地相互引用,从而使其耦合松散,而且可以独立地改变它们之间的交互。
落到本章:组件之间的线从网状变成星形,“谁影响谁”从散在四个类里变成集中在一个方法里。
对照案例图:CheckoutMediator 是 ConcreteMediator,五个组件是 ConcreteColleague。
| 角色 | 在本章案例里是谁 | 职责 |
|---|---|---|
| 中介者(Mediator) | Mediator |
定义同事通知中介者的接口(changed) |
| 具体中介者(ConcreteMediator) | CheckoutMediator |
创建并持有各个同事,知道联动规则和顺序 |
| 同事(Colleague) | Widget |
持有中介者,自己变了就通知它 |
| 具体同事(ConcreteColleague) | AddressWidget、PointsWidget 等 |
管好自己那一块状态,不认识其他同事 |
常见场景:
- 表单联动:省市区三级选择,选了支付方式才显示分期。
- 聊天室:客户端只和服务端说话,服务端决定转给谁,见 24.6。
- 塔台式的协调:飞机不互相商量谁先降落,都听塔台的。
- 多个服务的编排:下单时由一个编排器依次调库存、支付、物流,某一步失败由它决定回滚哪些。
24.6 真实系统里的例子
Section titled “24.6 真实系统里的例子”例一:聊天室服务端(Python)
Section titled “例一:聊天室服务端(Python)”聊天软件的客户端之间不直接连,每个客户端只连服务端:发消息是交给服务端,服务端决定转给房间里的谁,还管禁言这类规则。简化版:
class ChatRoom: # 中介者:真实的服务端还管登录、离线消息、存聊天记录 def __init__(self, name: str) -> None: self.name = name self.members: dict[str, Client] = {} self.muted: set[str] = set()
def join(self, client: "Client") -> None: self.members[client.name] = client client.room = self
def send(self, sender: str, text: str) -> None: # 转给谁、拦不拦,都在这里决定 if sender in self.muted: self.members[sender].receive("系统", "你已被禁言") return for name, member in self.members.items(): if name != sender: member.receive(sender, text)
class Client: # 同事:只认识房间,不认识其他客户端 def __init__(self, name: str) -> None: self.name = name self.room: ChatRoom | None = None
def say(self, text: str) -> None: if self.room is not None: self.room.send(self.name, text)
def receive(self, sender: str, text: str) -> None: print(f"[{self.name} 收到] {sender}:{text}")
if __name__ == "__main__": room = ChatRoom("第 24 章讨论") alice, bob, carol = Client("alice"), Client("bob"), Client("carol") for c in (alice, bob, carol): room.join(c) alice.say("中介者和观察者有啥区别?") room.muted.add("bob") bob.say("广告:低价代写作业")
# 输出:# [bob 收到] alice:中介者和观察者有啥区别?# [carol 收到] alice:中介者和观察者有啥区别?# [bob 收到] 系统:你已被禁言ChatRoom 对应 CheckoutMediator,Client 对应组件。加一条“新人入群 10 分钟内不能发链接”的规则,只改 send。
例二:Socket.IO 的房间(TypeScript)
Section titled “例二:Socket.IO 的房间(TypeScript)”Socket.IO 是 Node.js 常用的实时通信库。服务端为每条客户端连接建一个 socket,socket.join(room) 把它放进房间,io.to(room).emit(...) 发给房间里所有人,socket.to(room).emit(...) 发给除自己以外的人。浏览器之间不直接连,消息都经服务端转发,大致如此。简化版:
// 对应服务端的 socket:真实的 Socket.IO 经 WebSocket 或 HTTP 长轮询把消息推给浏览器,这里直接打印class Socket { constructor(readonly id: string, private readonly server: Server) {}
join(room: string): void { // 对应 socket.join(room) this.server.addToRoom(room, this); }
to(room: string) { // 对应 socket.to(room):发给房间里除自己以外的人 return { emit: (event: string, data: string) => this.server.broadcast(room, event, data, this) }; }
deliver(event: string, data: string): void { // 浏览器里 socket.on(event, ...) 收到 console.log(`[${this.id}] ${event}:${data}`); }}
// 对应 io(Server):真实的房间成员记在 Adapter 里,多台服务器时可换成 Redis Adapterclass Server { private readonly rooms = new Map<string, Set<Socket>>();
connect(id: string): Socket { return new Socket(id, this); }
addToRoom(room: string, socket: Socket): void { if (!this.rooms.has(room)) this.rooms.set(room, new Set()); this.rooms.get(room)!.add(socket); }
to(room: string) { // 对应 io.to(room):发给房间里所有人 return { emit: (event: string, data: string) => this.broadcast(room, event, data) }; }
broadcast(room: string, event: string, data: string, except?: Socket): void { for (const s of this.rooms.get(room) ?? []) if (s !== except) s.deliver(event, data); }}
const io = new Server();const [a, b, c] = ["a", "b", "c"].map((id) => io.connect(id));a.join("ch24");b.join("ch24");c.join("ch23");io.to("ch24").emit("notice", "第 24 章更新了");a.to("ch24").emit("message", "a:我先看完了");
// 输出:// [a] notice:第 24 章更新了// [b] notice:第 24 章更新了// [b] message:a:我先看完了Server 是中介者,三个 Socket 是同事:a 不知道房间里有谁,只说“发给 ch24”,由服务端去找人。
24.7 什么时候别用
Section titled “24.7 什么时候别用”- 只有两三个对象,关系也简单。地址变了只影响运费,直接调就行。硬加一个中介者,读代码要多跳一层才知道地址变了会发生什么。
- 中介者已经成了上帝对象(God object)。它认识四十个组件、
changed里几百个分支,改一处联动要读懂全部。症状很具体:任何改动都要改这个文件,测试它要把所有组件都造出来。这时该按业务拆成几个小中介者,或把计算抽出去,而不是继续往里加 if。
24.8 容易混淆的模式
Section titled “24.8 容易混淆的模式”| 模式 | 意图 | 区别 |
|---|---|---|
| 中介者(Mediator) | 用一个中间对象协调多个对象之间的交互 | 多对多、双向:组件持有中介者并通知它,中介者认识每个组件,写着“A 变了让 B、C 做什么”的规则 |
| 观察者(Observer) | 一件事发生后通知所有关注者 | 一对多、单向广播:主题不关心谁收、收了做什么,订阅者之间互不知道。见第 18 章 |
| 外观(Facade) | 给一组子系统一个简单入口 | 单向:只有外观调子系统,子系统不知道外观。见第 11 章 |
两组都能用一个问题当场判断:
- 中介者还是观察者:问“中间那个对象里有没有业务规则”。第 18 章的
EventBus只管转发,是观察者;本章的changed写着“地址变了先算运费、再刷券”,是中介者。两者常一起用:组件发“我变了”的事件,中介者订阅所有组件,这是用观察者实现中介者;规则仍在中介者里,它仍是中介者。 - 中介者还是外观:做 11.8 的删除测试。删掉第 11 章的
OrderFacade,子系统一行不用改;删掉CheckoutMediator,每个组件里的self.mediator.changed(self)都没了着落,因为组件依赖它。
24.9 练一练
Section titled “24.9 练一练”会议室预订表单有四个字段:日期、时间段、会议室、参会人数。它们互相限制:人数决定能选哪些会议室(A 室 4 人、B 室 10 人、C 室 20 人);日期和会议室决定哪些时间段还空着;改了任何一个,已选但不再合法的值要清空。写一个 BookingMediator,四个字段只通知它,由它统一校验、刷新各字段的可选项。依次改人数、日期、会议室、时间段,再把人数改大,打印每一步会议室和时间段的当前值和可选项。Python 和 TS 各写一版。
提示:先用一个字典存已有预订,键是(日期, 会议室),值是已占的时间段;中介者的 changed 按“人数 → 会议室 → 时间段”的顺序刷新,和本章“运费 → 券 → 积分 → 总价”是一个思路。
24.10 小结
Section titled “24.10 小结”- 中介者把一组对象之间的网状引用变成星形:每个对象只认识中介者,变了就通知它。
- “谁变了通知谁、按什么顺序”集中在中介者的一个方法里,加组件只改中介者,不会漏掉某个组件里的某次调用。
- 代价是中介者知道一切,规则多了会长成上帝对象,要及时按业务拆开。
- 和观察者的分别:观察者只广播,中间不含业务规则;中介者协调,规则都在它那里。和外观的分别:外观单向,删了子系统不用改;中介者双向,同事依赖它。