本节摘要:设计模式不该从模式书里抄进代码,而该从红灯里长出来。本节用灯塔小组的三个真实需求做完整演练:叠加优惠逼出策略、多格式报表逼出模板方法、库存预警逼出观察者——每种都从红灯起步,看条件分支在测试压力下如何一步步演化成模式结构。读完你应能判断"什么信号该配什么模式",并按节律把它演练出来。
模式出现的正确姿势是事后命名:结构先在循环里长成那个形状,你再给它贴上模式书里的名字。本节三个演练都遵守这个姿势——红、绿、重构走完,回头看,结构正好是某模式,然后才点破名字。
优惠券平台越接越多。最初的实现诚实但难看:
def coupon_discount(order) -> Decimal: d = Decimal("0") for c in order.coupons: if c.type == "SHOP": d += order.raw_total() * Decimal("0.05") elif c.type == "PLATFORM": d += order.raw_total() * Decimal("0.10") elif c.type == "VIP": d += order.raw_total() * Decimal("0.15") return min(d, order.raw_total())
红灯来了——新规则"平台券与店铺券叠加不超过一成":
def test_platform_and_shop_coupons_cap_at_ten_percent(): order = an_order(raw_total=Decimal("100")).with_coupons("PLATFORM", "SHOP").build() assert coupon_discount(order) == Decimal("10.00")
这条红灯的妙处在于它测的是组合行为:if 链算单项没问题,组合上限根本没地方放。为了绿,你得先把"每种券怎么算"收拢成可独立调用的东西——组合规则才有落脚点。绿灯加重构后:
class CouponRule(Protocol): def discount_for(self, order) -> Decimal: ... @register("SHOP") class ShopCoupon: def discount_for(self, order) -> Decimal: return order.raw_total() * Decimal("0.05") @register("PLATFORM") class PlatformCoupon: def discount_for(self, order) -> Decimal: return order.raw_total() * Decimal("0.10") def coupon_discount(order) -> Decimal: rules = [COUPON_RULES[c.type] for c in order.coupons] total = sum((r.discount_for(order) for r in rules), Decimal("0")) return min(total, order.raw_total() * Decimal("0.10")) # 叠加封顶
每种券一个类、统一协议、汇总器管封顶——策略模式到场。回头看,模式的每个零件都是某条红灯点名要的:单条协议来自"组合规则需要落点",注册表来自"新平台不想改汇总器",封顶来自那条红灯本身。变化点在哪里出现,策略的插槽就开在哪里——这是模式浮现的通则。

新需求:结算日报要出两个格式——控制台文本给运维,Markdown 给飞书群。红灯从共用点写起:
def test_daily_report_has_total_and_top_items(): report = build_daily_report(day=FAKE_MONDAY, ledger=ledger_with_samples()) assert "总流水 1,234.50" in report.text assert report.top_items[0].sku == "BOOK"
绿灯后出现重复信号:两种格式的骨架一样——取数、算总流水、挑前商品、排版——只有排版不同。重构按"骨架上提、差异下放"收拢:
class DailyReport: def build(self, day, ledger) -> Report: rows = ledger.rows_for(day) # 骨架步骤 total = sum(r.amount for r in rows) top = top_n(rows, by="amount", n=3) return Report(text=self.render(total, top), top_items=top) def render(self, total, top) -> str: # 差异步骤,子类填空 raise NotImplementedError class ConsoleReport(DailyReport): def render(self, total, top) -> str: return f"总流水 {total:,.2f} | TOP {len(top)}" class MarkdownReport(DailyReport): def render(self, total, top) -> str: return "\n".join([f"**总流水 {total:,.2f}**"] + [f"- {t.sku}" for t in top])
模板方法到场。它的测试信号很固定:多份实现共享同一套步骤,而步骤顺序有业务含义。红灯逼你先写出"结果该长什么样",重构才把骨架提上去——直接从模板开始写的人,几乎总会把步骤切错。
新需求:库存低于安全线时通知采购群。红灯测的是副作用,替身功夫(3.3)直接兑现:
def test_low_stock_notifies_purchase_channel(mocker): notifier = mocker.Mock() inventory = Inventory(stock={"PEN": 3}, safety=5, notifier=notifier) inventory.deduct("PEN", qty=1) notifier.low_stock.assert_called_once_with(sku="PEN", remaining=2)
为了绿,Inventory.deduct 得在扣减后检查阈值并通知。重构窗口里的关键决策:直接在 deduct 里写通知,还是把"低于阈值"发布成事件、通知方自己订阅?测试给出了偏好的理由——如果下轮需求是"预警还要写审计日志",前者要改 deduct(又开膛),后者只是加个订阅者。于是观察者结构落位:库存只发 LowStock 事件,采购通知、审计日志各自订阅。依赖方向变了:被观察者不再认识任何下游,这是它比回调链高级的地方。
三个模式、同一个剧本:红灯锁住变化的症状(组合无落点、骨架重复、副作用要断言),绿灯用最笨写法通过,重构在测试保护下把结构挪成模式形状,最后才给结构贴名字。反着来——先选模式再套需求——十次有八次过度设计,因为你为还没出现的变化买了保险。让红灯当保险精算师,模式只在变化落地时登场。