跳转到内容

第 24 章 · 中介者(Mediator)

一句话:一组互相联动的对象不再互相持有,各自只认识一个中介者;谁变了就告诉它,由它决定通知谁、按什么顺序。 解决的痛:结算页的几个组件你调我、我调你,加一个积分组件要改四个类;漏改一处,改地址时积分没重算,多扣了用户 600 积分。

你在多练网做周边商城的结算页。页面上有四个组件:收货地址、运费、优惠券、总价。它们是联动的:

  • 地址变了,运费要重算:新疆、西藏这类偏远地区 30 元,其余 10 元。
  • 地址变了,可用的优惠券要刷新:满 300 减 40 的券偏远地区不能用。
  • 运费或优惠券变了,总价要重算。用户手动换券,也要重算总价。

顺序也有讲究:总价必须在运费和优惠券都算完之后再算,否则用的是旧数。

谁变了谁就去调别人。地址组件手里拿着运费、优惠券、总价三个组件,按顺序调它们:

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 - 券 20

TS 版一样:

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

四个组件,联动关系一眼看得清。大多数人第一版都这么写。

新需求是:加一个“积分抵扣”组件。用户勾选后,100 积分抵 1 元,最多抵扣运费和优惠券算完之后金额的 10%。它受地址和优惠券影响,又影响总价。

要改的地方:

  1. 新建 PointsWidget,它要持有 TotalWidget,从那里拿“积分前的金额”。
  2. TotalWidget 反过来要持有 PointsWidget:拆出 before_points(),应付里再减掉积分。
  3. 凡是会改变积分前金额的组件,都要在调总价之前先让积分重算: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 是改过的;红色虚线是漏掉的那条:地址变了没有通知积分,右边加粗的框就是这次事故。

加一个 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 积分)

中介者版:五个组件围着 CheckoutMediator,彼此之间没有线

所有的线都连到中间。黄色的 PointsWidget 是新加的,它只连了中介者一条线。

改地址时的联动:中介者依次调运费、优惠券、积分、总价

地址组件只说了一句“我变了”,后面四步的顺序都是中介者定的。

对照 24.3:

  • 第 ① 处:PointsWidget 只持有中介者,不认识总价。
  • 第 ② 处:TotalWidget 不认识积分,数由中介者算好递给它。
  • 第 ③ 处:地址、优惠券组件一行没改。积分重算写在 changed 的公共尾巴里,谁变了都会走到,“改地址忘了通知积分”这种漏法没有了。

加积分时,改的只有 CheckoutMediator:多建一个组件,changed 里多一行。这是代价所在:所有联动规则都堆进中介者。结算页再加发票、礼品卡、分期,changed 会长成几百行的 if,每个人改结算页都要改它,合并代码时容易冲突。规则多了就要拆,比如把“金额怎么算”抽成一个价格计算类,中介者只管“谁变了调谁”。

GoF 对中介者的定义是:用一个中介对象来封装一系列的对象交互。中介者使各对象不需要显式地相互引用,从而使其耦合松散,而且可以独立地改变它们之间的交互。

落到本章:组件之间的线从网状变成星形,“谁影响谁”从散在四个类里变成集中在一个方法里。

中介者的结构:Colleague 只持有 Mediator,ConcreteMediator 认识每个 ConcreteColleague

对照案例图:CheckoutMediator 是 ConcreteMediator,五个组件是 ConcreteColleague。

角色 在本章案例里是谁 职责
中介者(Mediator) Mediator 定义同事通知中介者的接口(changed)
具体中介者(ConcreteMediator) CheckoutMediator 创建并持有各个同事,知道联动规则和顺序
同事(Colleague) Widget 持有中介者,自己变了就通知它
具体同事(ConcreteColleague) AddressWidget、PointsWidget 等 管好自己那一块状态,不认识其他同事

常见场景:

  • 表单联动:省市区三级选择,选了支付方式才显示分期。
  • 聊天室:客户端只和服务端说话,服务端决定转给谁,见 24.6。
  • 塔台式的协调:飞机不互相商量谁先降落,都听塔台的。
  • 多个服务的编排:下单时由一个编排器依次调库存、支付、物流,某一步失败由它决定回滚哪些。

聊天软件的客户端之间不直接连,每个客户端只连服务端:发消息是交给服务端,服务端决定转给房间里的谁,还管禁言这类规则。简化版:

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 Adapter
class 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”,由服务端去找人。

  • 只有两三个对象,关系也简单。地址变了只影响运费,直接调就行。硬加一个中介者,读代码要多跳一层才知道地址变了会发生什么。
  • 中介者已经成了上帝对象(God object)。它认识四十个组件、changed 里几百个分支,改一处联动要读懂全部。症状很具体:任何改动都要改这个文件,测试它要把所有组件都造出来。这时该按业务拆成几个小中介者,或把计算抽出去,而不是继续往里加 if。
模式 意图 区别
中介者(Mediator) 用一个中间对象协调多个对象之间的交互 多对多、双向:组件持有中介者并通知它,中介者认识每个组件,写着“A 变了让 B、C 做什么”的规则
观察者(Observer) 一件事发生后通知所有关注者 一对多、单向广播:主题不关心谁收、收了做什么,订阅者之间互不知道。见第 18 章
外观(Facade) 给一组子系统一个简单入口 单向:只有外观调子系统,子系统不知道外观。见第 11 章

两组都能用一个问题当场判断:

  • 中介者还是观察者:问“中间那个对象里有没有业务规则”。第 18 章的 EventBus 只管转发,是观察者;本章的 changed 写着“地址变了先算运费、再刷券”,是中介者。两者常一起用:组件发“我变了”的事件,中介者订阅所有组件,这是用观察者实现中介者;规则仍在中介者里,它仍是中介者。
  • 中介者还是外观:做 11.8 的删除测试。删掉第 11 章的 OrderFacade,子系统一行不用改;删掉 CheckoutMediator,每个组件里的 self.mediator.changed(self) 都没了着落,因为组件依赖它。

会议室预订表单有四个字段:日期、时间段、会议室、参会人数。它们互相限制:人数决定能选哪些会议室(A 室 4 人、B 室 10 人、C 室 20 人);日期和会议室决定哪些时间段还空着;改了任何一个,已选但不再合法的值要清空。写一个 BookingMediator,四个字段只通知它,由它统一校验、刷新各字段的可选项。依次改人数、日期、会议室、时间段,再把人数改大,打印每一步会议室和时间段的当前值和可选项。Python 和 TS 各写一版。

提示:先用一个字典存已有预订,键是(日期, 会议室),值是已占的时间段;中介者的 changed 按“人数 → 会议室 → 时间段”的顺序刷新,和本章“运费 → 券 → 积分 → 总价”是一个思路。

  • 中介者把一组对象之间的网状引用变成星形:每个对象只认识中介者,变了就通知它。
  • “谁变了通知谁、按什么顺序”集中在中介者的一个方法里,加组件只改中介者,不会漏掉某个组件里的某次调用。
  • 代价是中介者知道一切,规则多了会长成上帝对象,要及时按业务拆开。
  • 和观察者的分别:观察者只广播,中间不含业务规则;中介者协调,规则都在它那里。和外观的分别:外观单向,删了子系统不用改;中介者双向,同事依赖它。