跳转到内容

第 11 章 · 外观(Facade)

一句话:把“按顺序调好几个子系统、出错时回滚”这一整套流程收进一个类,调用处只调它的一个方法。 解决的痛:同一套多步流程抄在好几个调用处,流程一变就要逐个去改,漏改一处、顺序错一处,只有那一个入口出事,别的入口测试全过。

你在一家卖手机和配件的电商公司写订单后台。下单这件事背后有四个子系统(subsystem),分别由不同的小组维护:

  • 库存 Inventory:reserve(sku, qty) 锁定库存,库存不够时返回 False;release(sku, qty) 把锁定的库存还回去。
  • 优惠 Promotion:apply(order) 按当前活动算出优惠后的应付金额,写回订单。现在只有一条“满 1000 减 100”。
  • 支付 Payment:create(order) 创建一张支付单,返回单号;金额超过单笔限额时创建失败,返回空。
  • 通知 Notifier:send(user, text) 给用户发一条站内信。

一次下单要依次调这四个:算优惠、锁库存、建支付单、发通知。其中有一条规矩:支付单没建成,锁住的库存要还回去,不然这台手机就一直显示被人占着,别人买不到。

下单的入口不止一个。用户在网页上点“提交订单”,走网页下单接口;客服替打电话来的老客户下单,用后台的代客下单工具;新品预售开始那一刻,一个定时任务把预约过的用户自动下单。三个入口各有各的前后处理(后台要记操作人,预售要批量跑),中间“调四个子系统”那一段是同一件事。

本章用几个只打印的小类模拟这四个子系统。库存里只有一个商品 PHONE-X,开始时 10 台。

三个调用处各自连着四个子系统

图里每个调用处都伸出四根线,一共十二根:每个入口都得知道四个子系统怎么调、按什么顺序调、哪一步失败要收拾什么。

最直接的写法是在每个入口里把这四步依次写出来:

from dataclasses import dataclass
@dataclass
class Order:
order_id: str
user: str
sku: str
qty: int
price: int # 单价,元
amount: int = 0 # 优惠后的应付金额,由 Promotion 填
class Inventory: # 库存子系统(模拟)
def __init__(self) -> None:
self.stock = {"PHONE-X": 10}
def reserve(self, sku: str, qty: int) -> bool:
if self.stock[sku] < qty:
print(f" 库存:{sku} 不足")
return False
self.stock[sku] -= qty
print(f" 库存:锁定 {sku} x{qty}")
return True
def release(self, sku: str, qty: int) -> None:
self.stock[sku] += qty
print(f" 库存:释放 {sku} x{qty}")
class Promotion: # 优惠子系统(模拟):满 1000 减 100
def apply(self, order: Order) -> None:
total = order.price * order.qty
order.amount = total - 100 if total >= 1000 else total
print(f" 优惠:应付 {order.amount} 元")
class Payment: # 支付子系统(模拟):单笔超过 20000 元创建失败
def create(self, order: Order) -> str | None:
if order.amount > 20000:
print(" 支付:超过单笔限额,创建失败")
return None
print(f" 支付:创建支付单 PAY-{order.order_id}")
return f"PAY-{order.order_id}"
class Notifier: # 通知子系统(模拟)
def send(self, user: str, text: str) -> None:
print(f" 通知:{user},{text}")
inventory, promotion, payment, notifier = Inventory(), Promotion(), Payment(), Notifier()
# 调用点一:网页下单接口
def web_checkout(order: Order) -> bool:
promotion.apply(order)
if not inventory.reserve(order.sku, order.qty):
return False
if payment.create(order) is None:
inventory.release(order.sku, order.qty) # 支付单没建成,库存还回去
return False
notifier.send(order.user, f"订单 {order.order_id} 待支付 {order.amount} 元")
return True
# 调用点二:后台代客下单工具
def admin_place_order(order: Order, operator: str) -> bool:
print(f"[后台] {operator} 代客下单")
promotion.apply(order)
if not inventory.reserve(order.sku, order.qty):
return False
if payment.create(order) is None:
inventory.release(order.sku, order.qty)
return False
notifier.send(order.user, f"订单 {order.order_id} 待支付 {order.amount} 元")
return True
# 调用点三:预售开始时定时跑的自动下单
def presale_job(orders: list[Order]) -> list[bool]:
print(f"[预售] 处理 {len(orders)} 单")
results = []
for order in orders:
promotion.apply(order)
if not inventory.reserve(order.sku, order.qty):
results.append(False)
continue
if payment.create(order) is None:
inventory.release(order.sku, order.qty)
results.append(False)
continue
notifier.send(order.user, f"订单 {order.order_id} 待支付 {order.amount} 元")
results.append(True)
return results
def report(order: Order, ok: bool) -> None:
print(f"=> {order.order_id}:{'成功' if ok else '失败'},PHONE-X 剩余库存 {inventory.stock['PHONE-X']}")
if __name__ == "__main__":
w1 = Order("W1", "bot-77", "PHONE-X", 1, 3999)
report(w1, web_checkout(w1))
a1 = Order("A1", "bot-77", "PHONE-X", 1, 3999)
report(a1, admin_place_order(a1, "客服小王"))
p1 = Order("P1", "bot-77", "PHONE-X", 1, 3999)
report(p1, presale_job([p1])[0])
# 输出:
# 优惠:应付 3899 元
# 库存:锁定 PHONE-X x1
# 支付:创建支付单 PAY-W1
# 通知:bot-77,订单 W1 待支付 3899 元
# => W1:成功,PHONE-X 剩余库存 9
# [后台] 客服小王 代客下单
# 优惠:应付 3899 元
# 库存:锁定 PHONE-X x1
# 支付:创建支付单 PAY-A1
# 通知:bot-77,订单 A1 待支付 3899 元
# => A1:成功,PHONE-X 剩余库存 8
# [预售] 处理 1 单
# 优惠:应付 3899 元
# 库存:锁定 PHONE-X x1
# 支付:创建支付单 PAY-P1
# 通知:bot-77,订单 P1 待支付 3899 元
# => P1:成功,PHONE-X 剩余库存 7

TS 版结构一样,订单是一个接口,用小函数 newOrder 创建:

interface Order {
readonly orderId: string;
readonly user: string;
readonly sku: string;
readonly qty: number;
readonly price: number; // 单价,元
amount: number; // 优惠后的应付金额,由 Promotion 填
}
class Inventory { // 库存子系统(模拟)
readonly stock: Record<string, number> = { "PHONE-X": 10 };
reserve(sku: string, qty: number): boolean {
if (this.stock[sku] < qty) {
console.log(` 库存:${sku} 不足`);
return false;
}
this.stock[sku] -= qty;
console.log(` 库存:锁定 ${sku} x${qty}`);
return true;
}
release(sku: string, qty: number): void {
this.stock[sku] += qty;
console.log(` 库存:释放 ${sku} x${qty}`);
}
}
class Promotion { // 优惠子系统(模拟):满 1000 减 100
apply(order: Order): void {
const total = order.price * order.qty;
order.amount = total >= 1000 ? total - 100 : total;
console.log(` 优惠:应付 ${order.amount} 元`);
}
}
class Payment { // 支付子系统(模拟):单笔超过 20000 元创建失败
create(order: Order): string | null {
if (order.amount > 20000) {
console.log(" 支付:超过单笔限额,创建失败");
return null;
}
console.log(` 支付:创建支付单 PAY-${order.orderId}`);
return `PAY-${order.orderId}`;
}
}
class Notifier { // 通知子系统(模拟)
send(user: string, text: string): void {
console.log(` 通知:${user},${text}`);
}
}
const inventory = new Inventory();
const promotion = new Promotion();
const payment = new Payment();
const notifier = new Notifier();
// 调用点一:网页下单接口
function webCheckout(order: Order): boolean {
promotion.apply(order);
if (!inventory.reserve(order.sku, order.qty)) return false;
if (payment.create(order) === null) {
inventory.release(order.sku, order.qty); // 支付单没建成,库存还回去
return false;
}
notifier.send(order.user, `订单 ${order.orderId} 待支付 ${order.amount} 元`);
return true;
}
// 调用点二:后台代客下单工具
function adminPlaceOrder(order: Order, operator: string): boolean {
console.log(`[后台] ${operator} 代客下单`);
promotion.apply(order);
if (!inventory.reserve(order.sku, order.qty)) return false;
if (payment.create(order) === null) {
inventory.release(order.sku, order.qty);
return false;
}
notifier.send(order.user, `订单 ${order.orderId} 待支付 ${order.amount} 元`);
return true;
}
// 调用点三:预售开始时定时跑的自动下单
function presaleJob(orders: Order[]): boolean[] {
console.log(`[预售] 处理 ${orders.length} 单`);
const results: boolean[] = [];
for (const order of orders) {
promotion.apply(order);
if (!inventory.reserve(order.sku, order.qty)) {
results.push(false);
continue;
}
if (payment.create(order) === null) {
inventory.release(order.sku, order.qty);
results.push(false);
continue;
}
notifier.send(order.user, `订单 ${order.orderId} 待支付 ${order.amount} 元`);
results.push(true);
}
return results;
}
function newOrder(orderId: string, user: string, sku: string, qty: number, price: number): Order {
return { orderId, user, sku, qty, price, amount: 0 };
}
function report(order: Order, ok: boolean): void {
console.log(`=> ${order.orderId}:${ok ? "成功" : "失败"},PHONE-X 剩余库存 ${inventory.stock["PHONE-X"]}`);
}
const w1 = newOrder("W1", "bot-77", "PHONE-X", 1, 3999);
report(w1, webCheckout(w1));
const a1 = newOrder("A1", "bot-77", "PHONE-X", 1, 3999);
report(a1, adminPlaceOrder(a1, "客服小王"));
const p1 = newOrder("P1", "bot-77", "PHONE-X", 1, 3999);
report(p1, presaleJob([p1])[0]);
// 输出:
// 优惠:应付 3899 元
// 库存:锁定 PHONE-X x1
// 支付:创建支付单 PAY-W1
// 通知:bot-77,订单 W1 待支付 3899 元
// => W1:成功,PHONE-X 剩余库存 9
// [后台] 客服小王 代客下单
// 优惠:应付 3899 元
// 库存:锁定 PHONE-X x1
// 支付:创建支付单 PAY-A1
// 通知:bot-77,订单 A1 待支付 3899 元
// => A1:成功,PHONE-X 剩余库存 8
// [预售] 处理 1 单
// 优惠:应付 3899 元
// 库存:锁定 PHONE-X x1
// 支付:创建支付单 PAY-P1
// 通知:bot-77,订单 P1 待支付 3899 元
// => P1:成功,PHONE-X 剩余库存 7

三个调用处各自完整,从上往下读就知道一单经过了哪几步、支付失败时怎么收尾。输出里三单都下成功了,库存从 10 台降到 7 台。三个入口、四个子系统、一条回滚规则时,这样写清楚直接,大多数人第一版都这么写:先写网页下单,后台工具和预售任务上线时,把那一段复制过去,再改改前后处理。

上一次预售被一批刷单账号抢走了不少货。风控组上线了风控服务,新需求是:支付前加一步风控检查 RiskControl.check(order),不通过就拒单,释放已经锁住的库存并记一条日志;同时把优惠挪到锁库存之后、支付之前算。挪优惠有两个原因:风控要看的是优惠后的应付金额;库存不够的单子不该先把优惠算掉,以后上了限量券,这样会白白占掉名额。

新的顺序是:锁库存 → 算优惠 → 风控 → 建支付单 → 发通知。回滚变成两条:风控拒绝、支付单没建成,都要释放库存。

要改的地方有四处:

  1. 新增 RiskControl 类和它的黑名单。这一处任何写法都躲不掉。
  2. web_checkout:把 promotion.apply 挪到 reserve 之后;在 payment.create 前插入风控;拒绝时释放库存、记日志。
  3. admin_place_order:同样三件事再做一遍。
  4. presale_job:同样三件事再做一遍,这次是在循环里,失败分支写的是 continue 而不是 return。

第 2、3、4 处是同一段改动抄三遍,每一处都要自己判断风控插在哪一行、拒绝时要不要释放。三个入口还分属三个人:网页接口归前台组,后台工具归客服系统组,预售任务放在定时任务的仓库里。TS 版要改的是同样的四处。

三个入口改完是下面这样(RiskControl 的代码和 11.4 里的相同,risk = RiskControl() 和其他子系统一起创建):

# web_checkout:风控挪到了锁库存之后,拒单分支还是直接 return
def web_checkout(order: Order) -> bool:
if not inventory.reserve(order.sku, order.qty):
return False
promotion.apply(order) # 挪到锁库存之后
if not risk.check(order):
print(f" 日志:{order.order_id} 风控拒绝")
return False # 少了 inventory.release(order.sku, order.qty)
if payment.create(order) is None:
inventory.release(order.sku, order.qty)
return False
notifier.send(order.user, f"订单 {order.order_id} 待支付 {order.amount} 元")
return True
# admin_place_order:打印操作人之后的这一段改对了,后面不变
if not inventory.reserve(order.sku, order.qty):
return False
promotion.apply(order)
if not risk.check(order):
inventory.release(order.sku, order.qty)
print(f" 日志:{order.order_id} 风控拒绝")
return False
# presale_job 的循环体:只挪了优惠,风控没加,后面不变
if not inventory.reserve(order.sku, order.qty):
results.append(False)
continue
promotion.apply(order) # 挪到锁库存之后

后台工具那份改对了,事故出在第 2、4 处。网页接口的同事一开始把风控写在锁库存之前,那时被拒的单还没占库存,拒绝时直接 return False 没有问题;后来风控加了一条规则:同一账号用优惠券低价抢购才算可疑,要看优惠后的实付金额,于是风控挪到了锁库存和算优惠之后,return 那一行原样跟了过去,被拒的单就把锁住的库存带走了。预售任务在另一个仓库里,改它的人只挪了优惠,忘了加风控。把上面三段换进 before.py,main 不动,让同一个刷单账号 bot-77 从三个入口各下一单:

库存:锁定 PHONE-X x1
优惠:应付 3899 元
风控:拒绝 bot-77
日志:W1 风控拒绝
=> W1:失败,PHONE-X 剩余库存 9
[后台] 客服小王 代客下单
库存:锁定 PHONE-X x1
优惠:应付 3899 元
风控:拒绝 bot-77
库存:释放 PHONE-X x1
日志:A1 风控拒绝
=> A1:失败,PHONE-X 剩余库存 9
[预售] 处理 1 单
库存:锁定 PHONE-X x1
优惠:应付 3899 元
支付:创建支付单 PAY-P1
通知:bot-77,订单 P1 待支付 3899 元
=> P1:成功,PHONE-X 剩余库存 8

三单都该被拒。后台那单拒了,库存也还回去了;网页那单拒了,库存却少了 1 台,这台手机会一直锁着,直到有人对账时发现;预售那单根本没经过风控,支付单照常建了出来,库存降到 8 台。线上预售开始那天就是这样:刷单账号从预售任务这个口子进来,而网页上每拦下一个刷单请求,也锁走一台货。

问题的形状是:同一套多步流程(谁先谁后、哪一步失败要回滚什么)在每个调用处各写一份;流程每变一次,就要去每个调用处改一遍,改漏一处或改错顺序,只有那一个入口出事,别的入口测试全过。

朴素写法:四处要改的位置

图中 ①–④ 就是上面列出的四处。边框加粗的是出事的两处:网页接口挪了顺序、回滚没跟上;预售任务那条通往风控的虚线就是漏掉的一步。

三个调用处真正想说的只有一句:“帮我下这一单”。那就写一个类专门说这句话:OrderFacade 持有五个子系统,place_order(order) 按顺序调它们、在失败时回滚,最后返回一个 OrderResult。调用处只调 place_order,各自的前后处理留在自己那里:

from dataclasses import dataclass
@dataclass
class Order:
order_id: str
user: str
sku: str
qty: int
price: int # 单价,元
amount: int = 0 # 优惠后的应付金额,由 Promotion 填
class Inventory: # 库存子系统(模拟)
def __init__(self) -> None:
self.stock = {"PHONE-X": 10}
def reserve(self, sku: str, qty: int) -> bool:
if self.stock[sku] < qty:
print(f" 库存:{sku} 不足")
return False
self.stock[sku] -= qty
print(f" 库存:锁定 {sku} x{qty}")
return True
def release(self, sku: str, qty: int) -> None:
self.stock[sku] += qty
print(f" 库存:释放 {sku} x{qty}")
class Promotion: # 优惠子系统(模拟):满 1000 减 100
def apply(self, order: Order) -> None:
total = order.price * order.qty
order.amount = total - 100 if total >= 1000 else total
print(f" 优惠:应付 {order.amount} 元")
class Payment: # 支付子系统(模拟):单笔超过 20000 元创建失败
def create(self, order: Order) -> str | None:
if order.amount > 20000:
print(" 支付:超过单笔限额,创建失败")
return None
print(f" 支付:创建支付单 PAY-{order.order_id}")
return f"PAY-{order.order_id}"
class Notifier: # 通知子系统(模拟),以上四个子系统和 11.2 相同
def send(self, user: str, text: str) -> None:
print(f" 通知:{user},{text}")
class RiskControl: # 新增:风控子系统(模拟),黑名单里的刷单账号一律拒绝
BLOCKED = {"bot-77"}
def check(self, order: Order) -> bool:
if order.user in self.BLOCKED:
print(f" 风控:拒绝 {order.user}")
return False
print(" 风控:通过")
return True
@dataclass(frozen=True)
class OrderResult:
ok: bool
message: str # 成功时是支付单号,失败时是原因
class OrderFacade: # 外观:下单的步骤、顺序和回滚只写在这里
def __init__(self, inventory: Inventory, promotion: Promotion, risk: RiskControl,
payment: Payment, notifier: Notifier) -> None:
self.inventory, self.promotion, self.risk = inventory, promotion, risk
self.payment, self.notifier = payment, notifier
def place_order(self, order: Order) -> OrderResult:
if not self.inventory.reserve(order.sku, order.qty):
return OrderResult(False, "库存不足")
self.promotion.apply(order) # 锁库存之后、支付之前
if not self.risk.check(order):
self.inventory.release(order.sku, order.qty)
print(f" 日志:{order.order_id} 风控拒绝")
return OrderResult(False, "风控拒绝")
pay_id = self.payment.create(order)
if pay_id is None:
self.inventory.release(order.sku, order.qty)
return OrderResult(False, "支付单创建失败")
self.notifier.send(order.user, f"订单 {order.order_id} 待支付 {order.amount} 元")
return OrderResult(True, pay_id)
inventory = Inventory() # 子系统照样是普通对象,别处可以直接用
shop = OrderFacade(inventory, Promotion(), RiskControl(), Payment(), Notifier())
# 调用点一:网页下单接口
def web_checkout(order: Order) -> OrderResult:
return shop.place_order(order)
# 调用点二:后台代客下单工具
def admin_place_order(order: Order, operator: str) -> OrderResult:
print(f"[后台] {operator} 代客下单")
return shop.place_order(order)
# 调用点三:预售开始时定时跑的自动下单
def presale_job(orders: list[Order]) -> list[OrderResult]:
print(f"[预售] 处理 {len(orders)} 单")
return [shop.place_order(order) for order in orders]
def report(order: Order, result: OrderResult) -> None: # 直接读 Inventory 的库存
status = "成功" if result.ok else "失败"
print(f"=> {order.order_id}:{status}({result.message}),PHONE-X 剩余库存 {inventory.stock['PHONE-X']}")
if __name__ == "__main__":
w1 = Order("W1", "bot-77", "PHONE-X", 1, 3999)
report(w1, web_checkout(w1))
a1 = Order("A1", "bot-77", "PHONE-X", 1, 3999)
report(a1, admin_place_order(a1, "客服小王"))
p1 = Order("P1", "bot-77", "PHONE-X", 1, 3999)
report(p1, presale_job([p1])[0])
w2 = Order("W2", "alice", "PHONE-X", 1, 3999)
report(w2, web_checkout(w2))
# 输出:
# 库存:锁定 PHONE-X x1
# 优惠:应付 3899 元
# 风控:拒绝 bot-77
# 库存:释放 PHONE-X x1
# 日志:W1 风控拒绝
# => W1:失败(风控拒绝),PHONE-X 剩余库存 10
# [后台] 客服小王 代客下单
# 库存:锁定 PHONE-X x1
# 优惠:应付 3899 元
# 风控:拒绝 bot-77
# 库存:释放 PHONE-X x1
# 日志:A1 风控拒绝
# => A1:失败(风控拒绝),PHONE-X 剩余库存 10
# [预售] 处理 1 单
# 库存:锁定 PHONE-X x1
# 优惠:应付 3899 元
# 风控:拒绝 bot-77
# 库存:释放 PHONE-X x1
# 日志:P1 风控拒绝
# => P1:失败(风控拒绝),PHONE-X 剩余库存 10
# 库存:锁定 PHONE-X x1
# 优惠:应付 3899 元
# 风控:通过
# 支付:创建支付单 PAY-W2
# 通知:alice,订单 W2 待支付 3899 元
# => W2:成功(PAY-W2),PHONE-X 剩余库存 9

TS 版的 OrderFacade 用构造参数属性持有五个子系统:

interface Order {
readonly orderId: string;
readonly user: string;
readonly sku: string;
readonly qty: number;
readonly price: number; // 单价,元
amount: number; // 优惠后的应付金额,由 Promotion 填
}
class Inventory { // 库存子系统(模拟)
readonly stock: Record<string, number> = { "PHONE-X": 10 };
reserve(sku: string, qty: number): boolean {
if (this.stock[sku] < qty) {
console.log(` 库存:${sku} 不足`);
return false;
}
this.stock[sku] -= qty;
console.log(` 库存:锁定 ${sku} x${qty}`);
return true;
}
release(sku: string, qty: number): void {
this.stock[sku] += qty;
console.log(` 库存:释放 ${sku} x${qty}`);
}
}
class Promotion { // 优惠子系统(模拟):满 1000 减 100
apply(order: Order): void {
const total = order.price * order.qty;
order.amount = total >= 1000 ? total - 100 : total;
console.log(` 优惠:应付 ${order.amount} 元`);
}
}
class Payment { // 支付子系统(模拟):单笔超过 20000 元创建失败
create(order: Order): string | null {
if (order.amount > 20000) {
console.log(" 支付:超过单笔限额,创建失败");
return null;
}
console.log(` 支付:创建支付单 PAY-${order.orderId}`);
return `PAY-${order.orderId}`;
}
}
class Notifier { // 通知子系统(模拟),以上四个子系统和 11.2 相同
send(user: string, text: string): void {
console.log(` 通知:${user},${text}`);
}
}
class RiskControl { // 新增:风控子系统(模拟),黑名单里的刷单账号一律拒绝
static readonly BLOCKED = new Set(["bot-77"]);
check(order: Order): boolean {
if (RiskControl.BLOCKED.has(order.user)) {
console.log(` 风控:拒绝 ${order.user}`);
return false;
}
console.log(" 风控:通过");
return true;
}
}
interface OrderResult {
readonly ok: boolean;
readonly message: string; // 成功时是支付单号,失败时是原因
}
class OrderFacade { // 外观:下单的步骤、顺序和回滚只写在这里
constructor(
private readonly inventory: Inventory,
private readonly promotion: Promotion,
private readonly risk: RiskControl,
private readonly payment: Payment,
private readonly notifier: Notifier,
) {}
placeOrder(order: Order): OrderResult {
if (!this.inventory.reserve(order.sku, order.qty)) return { ok: false, message: "库存不足" };
this.promotion.apply(order); // 锁库存之后、支付之前
if (!this.risk.check(order)) {
this.inventory.release(order.sku, order.qty);
console.log(` 日志:${order.orderId} 风控拒绝`);
return { ok: false, message: "风控拒绝" };
}
const payId = this.payment.create(order);
if (payId === null) {
this.inventory.release(order.sku, order.qty);
return { ok: false, message: "支付单创建失败" };
}
this.notifier.send(order.user, `订单 ${order.orderId} 待支付 ${order.amount} 元`);
return { ok: true, message: payId };
}
}
const inventory = new Inventory(); // 子系统照样是普通对象,别处可以直接用
const shop = new OrderFacade(inventory, new Promotion(), new RiskControl(), new Payment(), new Notifier());
// 调用点一:网页下单接口
function webCheckout(order: Order): OrderResult {
return shop.placeOrder(order);
}
// 调用点二:后台代客下单工具
function adminPlaceOrder(order: Order, operator: string): OrderResult {
console.log(`[后台] ${operator} 代客下单`);
return shop.placeOrder(order);
}
// 调用点三:预售开始时定时跑的自动下单
function presaleJob(orders: Order[]): OrderResult[] {
console.log(`[预售] 处理 ${orders.length} 单`);
return orders.map((order) => shop.placeOrder(order));
}
function newOrder(orderId: string, user: string, sku: string, qty: number, price: number): Order {
return { orderId, user, sku, qty, price, amount: 0 };
}
function report(order: Order, result: OrderResult): void { // 直接读 Inventory 的库存
const status = result.ok ? "成功" : "失败";
console.log(`=> ${order.orderId}:${status}(${result.message}),PHONE-X 剩余库存 ${inventory.stock["PHONE-X"]}`);
}
const w1 = newOrder("W1", "bot-77", "PHONE-X", 1, 3999);
report(w1, webCheckout(w1));
const a1 = newOrder("A1", "bot-77", "PHONE-X", 1, 3999);
report(a1, adminPlaceOrder(a1, "客服小王"));
const p1 = newOrder("P1", "bot-77", "PHONE-X", 1, 3999);
report(p1, presaleJob([p1])[0]);
const w2 = newOrder("W2", "alice", "PHONE-X", 1, 3999);
report(w2, webCheckout(w2));
// 输出:
// 库存:锁定 PHONE-X x1
// 优惠:应付 3899 元
// 风控:拒绝 bot-77
// 库存:释放 PHONE-X x1
// 日志:W1 风控拒绝
// => W1:失败(风控拒绝),PHONE-X 剩余库存 10
// [后台] 客服小王 代客下单
// 库存:锁定 PHONE-X x1
// 优惠:应付 3899 元
// 风控:拒绝 bot-77
// 库存:释放 PHONE-X x1
// 日志:A1 风控拒绝
// => A1:失败(风控拒绝),PHONE-X 剩余库存 10
// [预售] 处理 1 单
// 库存:锁定 PHONE-X x1
// 优惠:应付 3899 元
// 风控:拒绝 bot-77
// 库存:释放 PHONE-X x1
// 日志:P1 风控拒绝
// => P1:失败(风控拒绝),PHONE-X 剩余库存 10
// 库存:锁定 PHONE-X x1
// 优惠:应付 3899 元
// 风控:通过
// 支付:创建支付单 PAY-W2
// 通知:alice,订单 W2 待支付 3899 元
// => W2:成功(PAY-W2),PHONE-X 剩余库存 9

外观版:三个调用处都只连着 OrderFacade

看标红的 RiskControl:它只和 OrderFacade 连着,三个调用处碰不到它。左上方的 report 绕过外观直接读 Inventory,子系统并没有被藏起来。

一次 place_order:风控拒绝时先释放库存再返回

看顺序图里的 alt 块:风控拒绝和风控通过是两条路,拒绝那条在返回之前先 release、再记日志。这段顺序只写在 place_order 里,三个入口走的是同一份。

逐条对照 11.3 的四处改动:

  • 第 1 处,RiskControl 类:还是要写,这是风控自己的逻辑。另外 OrderFacade 的构造参数里要加一个、place_order 里要加一段。任何写法都得在某个地方把新的一步接进流程,外观省不掉这一段,它只是让这一段只写一次。
  • 第 2、3、4 处,三个调用处:一行不动。它们只调 place_order,里面没有“先调谁后调谁”,挪优惠、插风控、补释放都发生在 OrderFacade 的一个方法里。
  • 事故:输出里三个入口的刷单都被拒了,每次库存都回到 10 台。这不是因为外观让人写不错,而是只剩一个地方可写:要是 place_order 里拒绝分支忘了 release,三个入口会一起少库存,第一个测拒单的人就能看到,改一行全好,不会再有“后台对、网页错、预售根本没拦”这种只在某个入口出现的问题。
  • 下一次改流程,比如支付后加一步“记积分”:还是只改 place_order。

还有两点要说清楚。第一,五个子系统一个都没改,仍是普通的类,仍然可以直接用:report 直接读库存,财务对账脚本也可以直接调 Payment。外观是“下单”这个常用流程的方便入口,不是访问子系统的唯一入口。第二,代价是多了一层:想知道风控在第几步,要跳进 OrderFacade 看;某个入口如果要一套不一样的流程(比如后台下单不发通知),要么给 place_order 加参数,要么在那个入口绕过外观直接调子系统,后一种又回到了自己维护顺序。

GoF 对外观的定义是:为子系统中的一组接口提供一个一致的界面,外观模式定义了一个高层接口,这个接口使得这一子系统更加容易使用。

换成本章的话说:库存、优惠、风控、支付、通知这五个类的接口都是为“一步”设计的,place_order 是为“一件事”设计的高层接口。“更加容易使用”指的是调用方不用再知道有几步、谁先谁后、哪一步失败要回滚什么。外观自己通常不干具体的活,它的代码几乎全是“调谁、按什么顺序、出错怎么办”。

外观的结构:Client 走 Facade,Facade 按顺序调各个子系统

对照 11.4 的案例图:OrderFacade 是 Facade,五个子系统类是 Subsystem。箭头只从外观指向子系统:Inventory 里没有一行代码提到 OrderFacade,删掉外观,子系统照样能跑。

角色 在本章案例里是谁 职责
外观(Facade) OrderFacade 知道一件事要调哪些子系统、按什么顺序、失败时怎么回滚,对外只给一个高层方法
子系统类(Subsystem classes) Inventory、Promotion、RiskControl、Payment、Notifier 各自完成一步;不知道外观存在,也可以被直接调用
客户(Client) web_checkout、admin_place_order、presale_job 常用流程通过外观完成,特殊需要时也可以直接用子系统

常见场景:

  • 封装一组老接口。老系统开户要依次调建档、开账户、发卡三个接口,新代码只想调一个“开户”方法。
  • SDK 的“一行调用”入口。比如 AWS 的 Python SDK boto3 里,upload_file 会按文件大小决定要不要走分段上传(multipart upload),分段上传本身要调创建、逐段上传、完成这几个接口,调用方只写一行。
  • 给复杂的库做简化层。11.6 的两个例子都是:标准库自己给底层接口包了一层常用入口。
  • 分层架构里的应用服务(application service)。OrderService.place_order 这类方法负责调领域对象、仓储、消息队列,控制器只调它,和本章的 OrderFacade 是同一个形状。

Python 跑外部命令的底层接口是 subprocess.Popen:创建它就启动了子进程,之后要自己调 communicate() 读完输出并等子进程结束;传了超时,还要自己接住 TimeoutExpired、杀掉子进程再收尾;最后看 returncode 判断成没成功。Python 3.5 加入的 subprocess.run() 把这一串收成一次调用:它在内部创建 Popen,调用带可选超时的 communicate();超时就杀掉子进程,再把 TimeoutExpired 抛给你;check=True 时退出码不为 0 就抛 CalledProcessError;否则返回一个 CompletedProcess,里面是参数、退出码和输出。Popen 仍然公开,文档的说法是能用 run() 的场合用 run(),更复杂的用法(比如边运行边读输出)直接用底层的 Popen。简化版:

from dataclasses import dataclass
FAKE = {"echo hi": (0, "hi\n", 0.1), "sleep 5": (0, "", 5.0), "false": (1, "", 0.1)} # 命令 → (退出码, 输出, 耗时秒)
class TimeoutExpired(Exception): ...
class CalledProcessError(Exception): ...
@dataclass
class CompletedProcess: # 和 subprocess.CompletedProcess 的字段相同
args: list[str]
returncode: int
stdout: str
stderr: str
class Popen: # 子系统:模拟 subprocess.Popen,真实版本会启动子进程、接好管道
def __init__(self, args: list[str]) -> None:
self.args, self.returncode = args, -1
self.code, self.out, self.cost = FAKE[" ".join(args)]
print(f" Popen:启动 {' '.join(args)}")
def communicate(self, timeout: float | None = None) -> tuple[str, str]:
if timeout is not None and self.cost > timeout: # 读输出、等退出,超时就抛
raise TimeoutExpired(f"{' '.join(self.args)} 超过 {timeout} 秒")
self.returncode = self.code
print(f" communicate:读完输出,退出码 {self.returncode}")
return self.out, ""
def kill(self) -> None:
print(" kill:结束子进程")
def run(args: list[str], timeout: float | None = None, check: bool = False) -> CompletedProcess:
process = Popen(args) # 外观:模拟 subprocess.run,真实版本还用 with 保证管道关闭
try:
stdout, stderr = process.communicate(timeout=timeout)
except TimeoutExpired:
process.kill() # 真实版本 kill 之后还会再等子进程退出
raise
if check and process.returncode != 0:
raise CalledProcessError(f"{' '.join(args)} 退出码 {process.returncode}")
return CompletedProcess(args, process.returncode, stdout, stderr)
if __name__ == "__main__":
print(run(["echo", "hi"]))
for args, kwargs in ((["sleep", "5"], {"timeout": 1.0}), (["false"], {"check": True})):
try:
run(args, **kwargs)
except (TimeoutExpired, CalledProcessError) as error:
print(f"{type(error).__name__}:{error}")
# 输出:
# Popen:启动 echo hi
# communicate:读完输出,退出码 0
# CompletedProcess(args=['echo', 'hi'], returncode=0, stdout='hi\n', stderr='')
# Popen:启动 sleep 5
# kill:结束子进程
# TimeoutExpired:sleep 5 超过 1.0 秒
# Popen:启动 false
# communicate:读完输出,退出码 1
# CalledProcessError:false 退出码 1

Popen 对应本章的子系统,run 对应 OrderFacade.place_order:启动、读输出、等退出、超时就杀掉、按 check 决定要不要抛错,这串顺序和收尾只写在 run 里。模拟版总是读回输出,相当于真实的 run 传了 capture_output=True;真实的 run 还用 with Popen(...) 保证管道关闭,支持 input 等参数,这里都省略了,命令的结果用 FAKE 这张表代替。

例二:fs.promises.readFile(TypeScript)

Section titled “例二:fs.promises.readFile(TypeScript)”

Node 的 fs.promises 里有一组底层接口:open 打开文件得到一个 FileHandle,可以用它 stat 查大小、read 一块块读进缓冲区,最后 close。读一个配置文件要是全自己写,就得管缓冲区开多大、怎么判断读到了末尾、读到一半出错时别忘了关文件。fs.promises.readFile(path, "utf8") 一次做完:大致是打开文件、查大小、循环读完、关闭,再按编码转成字符串。不同版本的 Node 内部细节有出入,比如文件报告的大小是 0 时会一直读到读不出数据为止。open 和 FileHandle 仍然可以直接用,只想读一个几 GB 日志里的某一段时就该用它们。简化版:

const DISK: Record<string, string> = { "config.json": `{"port":8080,"debug":false}` }; // 模拟磁盘
class FileHandle { // 子系统:模拟 fs.promises.FileHandle,真实版本背后是一个文件描述符
private constructor(private readonly bytes: Uint8Array) {}
static async open(path: string): Promise<FileHandle> { // 对应 fs.promises.open
const text = DISK[path];
if (text === undefined) throw new Error(`ENOENT: no such file or directory, open '${path}'`);
console.log(` open ${path}`);
return new FileHandle(new TextEncoder().encode(text));
}
async stat(): Promise<{ size: number }> {
console.log(` stat:${this.bytes.length} 字节`);
return { size: this.bytes.length };
}
async read(buffer: Uint8Array, offset: number, length: number): Promise<{ bytesRead: number }> {
const chunk = this.bytes.subarray(offset, offset + length);
buffer.set(chunk, offset);
console.log(` read:${chunk.length} 字节`);
return { bytesRead: chunk.length };
}
async close(): Promise<void> { // 对应 filehandle.close()
console.log(" close");
}
}
// 外观:模拟 fs.promises.readFile(path, "utf8"),打开、查大小、分块读、关闭都在里面
async function readFile(path: string): Promise<string> {
const handle = await FileHandle.open(path);
try {
const { size } = await handle.stat();
const buffer = new Uint8Array(size);
let offset = 0;
while (offset < size) {
const { bytesRead } = await handle.read(buffer, offset, Math.min(16, size - offset)); // 真实的块大得多
if (bytesRead === 0) break;
offset += bytesRead;
}
return new TextDecoder("utf-8").decode(buffer.subarray(0, offset));
} finally {
await handle.close(); // 读到一半出错也要关
}
}
void readFile("config.json").then((text) => console.log(`读到:${text}`));
// 输出:
// open config.json
// stat:27 字节
// read:16 字节
// read:11 字节
// close
// 读到:{"port":8080,"debug":false}

FileHandle 是子系统,readFile 是外观。finally 里的 close 相当于 place_order 里的 release:中间哪一步出错,收尾都由外观负责,调用方不用记得。

  • 子系统只有一个类。比如发短信只有一个 SmsClient.send,没有顺序,也没有要回滚的东西,再包一个 SmsFacade.send,调用处多跳一层,看到的还是同一个方法。外观收的是几个子系统之间的顺序和收尾,没有这些就没东西可收。
  • 外观开始承载业务规则。下单流程的每一次变化最后都落在 place_order 上,它很容易变成什么都往里塞:大客户免风控、某个类目不能用券、预售单多发一条短信……一年后 OrderFacade 三千行,几十个 if 互相牵连,改一处要把三个入口都回归一遍,没人敢动。外观该做的只是编排:调谁、什么顺序、失败怎么回滚。“哪个类目不能用券”是优惠的规则,该下沉到 Promotion;“大客户免风控”是风控的规则,该下沉到 RiskControl。判断一行代码该不该留在外观里,看它是在安排步骤,还是在替某一步做它自己的判断。
  • 外观把子系统完全藏起来。比如把 Inventory 做成外观的私有成员,再规定“所有库存操作必须走 OrderFacade”。库存页面要查库存、仓库要手工调库存、财务要单独查一张支付单,这些都不是“下单”,结果只能给外观一个个加 get_stock、adjust_stock、query_payment,外观变成了所有子系统接口的复制品,每个子系统加一个方法,外观就得跟着加一个。外观应该是常用流程的便捷入口,不是唯一入口:子系统照常公开,常用的事走外观,特别的事直接用子系统。
模式 意图 区别
外观(Facade) 给一组子系统一个简单入口 背后是好几个对象;接口是外观新定义的、比子系统更简单的方法;子系统不知道外观,仍可直接用
适配器(Adapter) 让一个接口对不上的类按调用方要的接口工作 背后通常是一个对象;接口是调用方事先定好的(第 9 章的 Tracker),适配器去对上它;目的是转换,不是简化
中介者(Mediator) 用一个中间对象协调多个对象之间的交互 双向:各个对象都持有中介者,自己有变化就通知它,中介者再去调别的对象;外观是单向的,只有外观调子系统,见第 24 章

两个能动手做的判别办法:

  • 外观还是适配器:问“接口是谁定的、背后有几个对象”。调用方先定好了接口,找一个现成的类去对上它,是适配器;看着一组子系统,替调用方新写一个更省事的方法,是外观。第 9 章那张表说的是同一件事:适配器换接口,外观简化入口。
  • 外观还是中介者:做一次删除测试,把这个类删掉,看子系统要不要改。删掉 OrderFacade,Inventory、Payment 一行不用改,只是调用处要自己调回那几个子系统;删掉一个中介者,那些对象里调用中介者的代码全都要改(TS 编译不过,Python 一运行到那里就报错),因为它们依赖它。

你在做一个视频剪辑工具的导出功能。导出一段视频要依次调四个子系统:

  • Decoder.open(path):打开源文件,解码出画面 Frames(带宽和高)。
  • Scaler.resize(frames, width, height):缩放到目标尺寸。
  • Encoder.encode(frames, bitrate, temp_path):按码率编码,边编码边写临时文件;源文件损坏时抛 EncodeError,这时临时文件已经写了一半。
  • Muxer.write(temp_path, output_path):把临时文件封装成最终的 .mp4。

请用外观实现 VideoExporter.export(path, preset):

  • 预设 "720p" 是 1280x720、2500 kbps,"1080p" 是 1920x1080、5000 kbps;未知的预设直接报错。
  • 输出文件名由源文件名加预设得到,比如 trip.mov 导出 720p 是 trip-720p.mp4;临时文件是输出文件名后面加 .part。
  • 编码失败时删掉临时文件,再把错误抛给调用方。
  • 用一个集合模拟磁盘。先导出一个正常文件,再导出一个名字里带 broken 的文件(让 Encoder 在这里失败),最后打印磁盘上剩下的文件,里面不应该有 .part。Python 和 TS 各写一版。

提示:四个子系统里不要出现预设的名字,"720p" 对应什么参数只该写在外观这一层。做完想一想:剪辑工具的高级模式要让用户自己填码率,它应该调 VideoExporter,还是直接调 Encoder?

  • 外观把“按顺序调好几个子系统、失败时回滚”收进一个类的一个方法,调用处只调这个方法。
  • 加一步流程,那一步自己的类照样要写,外观里也要加一段;收益是流程只写在一处,几个入口不用各改一遍,写错了也是所有入口一起错,马上能发现。
  • 子系统不用改、不知道外观存在,仍然可以直接用;外观是常用流程的便捷入口,不是唯一入口。
  • 外观只做编排,不放业务规则;规则下沉到它所属的子系统,否则外观会长成没人敢动的大类。
  • 和适配器比:适配器对上一个事先定好的接口,外观新定义一个更简单的接口;和中介者比:外观单向调用子系统,中介者和各个对象互相知道,删掉中介者,那些对象就得改。