第 11 章 · 外观(Facade)
一句话:把“按顺序调好几个子系统、出错时回滚”这一整套流程收进一个类,调用处只调它的一个方法。 解决的痛:同一套多步流程抄在好几个调用处,流程一变就要逐个去改,漏改一处、顺序错一处,只有那一个入口出事,别的入口测试全过。
11.1 一个场景
Section titled “11.1 一个场景”你在一家卖手机和配件的电商公司写订单后台。下单这件事背后有四个子系统(subsystem),分别由不同的小组维护:
- 库存
Inventory:reserve(sku, qty)锁定库存,库存不够时返回False;release(sku, qty)把锁定的库存还回去。 - 优惠
Promotion:apply(order)按当前活动算出优惠后的应付金额,写回订单。现在只有一条“满 1000 减 100”。 - 支付
Payment:create(order)创建一张支付单,返回单号;金额超过单笔限额时创建失败,返回空。 - 通知
Notifier:send(user, text)给用户发一条站内信。
一次下单要依次调这四个:算优惠、锁库存、建支付单、发通知。其中有一条规矩:支付单没建成,锁住的库存要还回去,不然这台手机就一直显示被人占着,别人买不到。
下单的入口不止一个。用户在网页上点“提交订单”,走网页下单接口;客服替打电话来的老客户下单,用后台的代客下单工具;新品预售开始那一刻,一个定时任务把预约过的用户自动下单。三个入口各有各的前后处理(后台要记操作人,预售要批量跑),中间“调四个子系统”那一段是同一件事。
本章用几个只打印的小类模拟这四个子系统。库存里只有一个商品 PHONE-X,开始时 10 台。
图里每个调用处都伸出四根线,一共十二根:每个入口都得知道四个子系统怎么调、按什么顺序调、哪一步失败要收拾什么。
11.2 先按直觉写
Section titled “11.2 先按直觉写”最直接的写法是在每个入口里把这四步依次写出来:
from dataclasses import dataclass
@dataclassclass 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 剩余库存 7TS 版结构一样,订单是一个接口,用小函数 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 台。三个入口、四个子系统、一条回滚规则时,这样写清楚直接,大多数人第一版都这么写:先写网页下单,后台工具和预售任务上线时,把那一段复制过去,再改改前后处理。
11.3 需求变了
Section titled “11.3 需求变了”上一次预售被一批刷单账号抢走了不少货。风控组上线了风控服务,新需求是:支付前加一步风控检查 RiskControl.check(order),不通过就拒单,释放已经锁住的库存并记一条日志;同时把优惠挪到锁库存之后、支付之前算。挪优惠有两个原因:风控要看的是优惠后的应付金额;库存不够的单子不该先把优惠算掉,以后上了限量券,这样会白白占掉名额。
新的顺序是:锁库存 → 算优惠 → 风控 → 建支付单 → 发通知。回滚变成两条:风控拒绝、支付单没建成,都要释放库存。
要改的地方有四处:
- 新增
RiskControl类和它的黑名单。这一处任何写法都躲不掉。 web_checkout:把promotion.apply挪到reserve之后;在payment.create前插入风控;拒绝时释放库存、记日志。admin_place_order:同样三件事再做一遍。presale_job:同样三件事再做一遍,这次是在循环里,失败分支写的是continue而不是return。
第 2、3、4 处是同一段改动抄三遍,每一处都要自己判断风控插在哪一行、拒绝时要不要释放。三个入口还分属三个人:网页接口归前台组,后台工具归客服系统组,预售任务放在定时任务的仓库里。TS 版要改的是同样的四处。
三个入口改完是下面这样(RiskControl 的代码和 11.4 里的相同,risk = RiskControl() 和其他子系统一起创建):
# web_checkout:风控挪到了锁库存之后,拒单分支还是直接 returndef 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 台。线上预售开始那天就是这样:刷单账号从预售任务这个口子进来,而网页上每拦下一个刷单请求,也锁走一台货。
问题的形状是:同一套多步流程(谁先谁后、哪一步失败要回滚什么)在每个调用处各写一份;流程每变一次,就要去每个调用处改一遍,改漏一处或改错顺序,只有那一个入口出事,别的入口测试全过。
图中 ①–④ 就是上面列出的四处。边框加粗的是出事的两处:网页接口挪了顺序、回滚没跟上;预售任务那条通往风控的虚线就是漏掉的一步。
11.4 用外观模式重写
Section titled “11.4 用外观模式重写”三个调用处真正想说的只有一句:“帮我下这一单”。那就写一个类专门说这句话:OrderFacade 持有五个子系统,place_order(order) 按顺序调它们、在失败时回滚,最后返回一个 OrderResult。调用处只调 place_order,各自的前后处理留在自己那里:
from dataclasses import dataclass
@dataclassclass 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 剩余库存 9TS 版的 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看标红的 RiskControl:它只和 OrderFacade 连着,三个调用处碰不到它。左上方的 report 绕过外观直接读 Inventory,子系统并没有被藏起来。
看顺序图里的 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 加参数,要么在那个入口绕过外观直接调子系统,后一种又回到了自己维护顺序。
11.5 模式定义
Section titled “11.5 模式定义”GoF 对外观的定义是:为子系统中的一组接口提供一个一致的界面,外观模式定义了一个高层接口,这个接口使得这一子系统更加容易使用。
换成本章的话说:库存、优惠、风控、支付、通知这五个类的接口都是为“一步”设计的,place_order 是为“一件事”设计的高层接口。“更加容易使用”指的是调用方不用再知道有几步、谁先谁后、哪一步失败要回滚什么。外观自己通常不干具体的活,它的代码几乎全是“调谁、按什么顺序、出错怎么办”。
对照 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是同一个形状。
11.6 真实系统里的例子
Section titled “11.6 真实系统里的例子”例一:subprocess.run(Python)
Section titled “例一:subprocess.run(Python)”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): ...
@dataclassclass 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 退出码 1Popen 对应本章的子系统,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:中间哪一步出错,收尾都由外观负责,调用方不用记得。
11.7 什么时候别用
Section titled “11.7 什么时候别用”- 子系统只有一个类。比如发短信只有一个
SmsClient.send,没有顺序,也没有要回滚的东西,再包一个SmsFacade.send,调用处多跳一层,看到的还是同一个方法。外观收的是几个子系统之间的顺序和收尾,没有这些就没东西可收。 - 外观开始承载业务规则。下单流程的每一次变化最后都落在
place_order上,它很容易变成什么都往里塞:大客户免风控、某个类目不能用券、预售单多发一条短信……一年后OrderFacade三千行,几十个if互相牵连,改一处要把三个入口都回归一遍,没人敢动。外观该做的只是编排:调谁、什么顺序、失败怎么回滚。“哪个类目不能用券”是优惠的规则,该下沉到Promotion;“大客户免风控”是风控的规则,该下沉到RiskControl。判断一行代码该不该留在外观里,看它是在安排步骤,还是在替某一步做它自己的判断。 - 外观把子系统完全藏起来。比如把
Inventory做成外观的私有成员,再规定“所有库存操作必须走OrderFacade”。库存页面要查库存、仓库要手工调库存、财务要单独查一张支付单,这些都不是“下单”,结果只能给外观一个个加get_stock、adjust_stock、query_payment,外观变成了所有子系统接口的复制品,每个子系统加一个方法,外观就得跟着加一个。外观应该是常用流程的便捷入口,不是唯一入口:子系统照常公开,常用的事走外观,特别的事直接用子系统。
11.8 容易混淆的模式
Section titled “11.8 容易混淆的模式”| 模式 | 意图 | 区别 |
|---|---|---|
| 外观(Facade) | 给一组子系统一个简单入口 | 背后是好几个对象;接口是外观新定义的、比子系统更简单的方法;子系统不知道外观,仍可直接用 |
| 适配器(Adapter) | 让一个接口对不上的类按调用方要的接口工作 | 背后通常是一个对象;接口是调用方事先定好的(第 9 章的 Tracker),适配器去对上它;目的是转换,不是简化 |
| 中介者(Mediator) | 用一个中间对象协调多个对象之间的交互 | 双向:各个对象都持有中介者,自己有变化就通知它,中介者再去调别的对象;外观是单向的,只有外观调子系统,见第 24 章 |
两个能动手做的判别办法:
- 外观还是适配器:问“接口是谁定的、背后有几个对象”。调用方先定好了接口,找一个现成的类去对上它,是适配器;看着一组子系统,替调用方新写一个更省事的方法,是外观。第 9 章那张表说的是同一件事:适配器换接口,外观简化入口。
- 外观还是中介者:做一次删除测试,把这个类删掉,看子系统要不要改。删掉
OrderFacade,Inventory、Payment一行不用改,只是调用处要自己调回那几个子系统;删掉一个中介者,那些对象里调用中介者的代码全都要改(TS 编译不过,Python 一运行到那里就报错),因为它们依赖它。
11.9 练一练
Section titled “11.9 练一练”你在做一个视频剪辑工具的导出功能。导出一段视频要依次调四个子系统:
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?
11.10 小结
Section titled “11.10 小结”- 外观把“按顺序调好几个子系统、失败时回滚”收进一个类的一个方法,调用处只调这个方法。
- 加一步流程,那一步自己的类照样要写,外观里也要加一段;收益是流程只写在一处,几个入口不用各改一遍,写错了也是所有入口一起错,马上能发现。
- 子系统不用改、不知道外观存在,仍然可以直接用;外观是常用流程的便捷入口,不是唯一入口。
- 外观只做编排,不放业务规则;规则下沉到它所属的子系统,否则外观会长成没人敢动的大类。
- 和适配器比:适配器对上一个事先定好的接口,外观新定义一个更简单的接口;和中介者比:外观单向调用子系统,中介者和各个对象互相知道,删掉中介者,那些对象就得改。