第 17 章 · 策略(Strategy)
一句话:把一族可以互换的算法各自装进一个类,使用方只认它们共同的接口,选哪一个由一处代码决定。 解决的痛:算法写成一串
if时,每加一条规则都要改同一个函数和它的全部调用处;哪个调用处少传一个参数,程序不报错,算出来的数却和别处不一样。
17.1 一个场景
Section titled “17.1 一个场景”先回到第 1 章。1.3 把运费拆成了 SfExpress、StandardDelivery、StorePickup 几个类,它们都有一个 fee 方法,Order 手里拿着其中一个,算运费时交给它。当时说过“第 17 章会正式讲它”,那个写法就是策略模式。第 1 章只讲了一半:每种算法各装一个类。另一半是用哪个算法由谁来定:那里的入口代码写死了 SfExpress(),真实项目里选哪个往往取决于好几样东西,而且好几处都要选。本章把这两半讲完。
你在一家电商公司负责结算页的优惠计算。用户下单时可以用一张券,现在有三种:
- 满减券:满 200 减 30。
- 折扣券:九折,最多减 50。
- 新人券:直接减 10 元。
本章金额都用整数元,折扣向下取整。和第 1 章的运费一样,优惠也要在两个地方算:下单页上的“订单预估”,用户还没付款就要看到应付多少;结算时正式算出实付金额写进账单。两处由不同的同事维护,但必须算出同一个数。
17.2 先按直觉写
Section titled “17.2 先按直觉写”一个函数 calc_discount,按券的类型分三个分支,两个调用点都去调它:
from dataclasses import dataclass
@dataclassclass Order: order_id: str amount: int # 商品总价(元)
@dataclassclass Coupon: type: str # "full_reduction" 满减 / "percent" 折扣券 / "newcomer" 新人立减
def calc_discount(order: Order, coupon: Coupon) -> int: if coupon.type == "full_reduction": # 满 200 减 30 return 30 if order.amount >= 200 else 0 elif coupon.type == "percent": # 九折,封顶 50 return min(order.amount // 10, 50) elif coupon.type == "newcomer": # 新人立减 10 元 return 10 raise ValueError(f"未知券类型:{coupon.type}")
# 调用点一:下单页上的订单预估def estimate_price(order: Order, coupon: Coupon) -> str: return f"订单 {order.order_id} 预估应付 {order.amount - calc_discount(order, coupon)} 元"
# 调用点二:结算def settle(order: Order, coupon: Coupon) -> str: return f"订单 {order.order_id} 结算实付 {order.amount - calc_discount(order, coupon)} 元"
if __name__ == "__main__": for order, coupon in [ (Order("A001", 199), Coupon("full_reduction")), (Order("A002", 600), Coupon("percent")), (Order("A003", 300), Coupon("newcomer")), ]: print(estimate_price(order, coupon)) print(settle(order, coupon))
# 输出:# 订单 A001 预估应付 199 元# 订单 A001 结算实付 199 元# 订单 A002 预估应付 550 元# 订单 A002 结算实付 550 元# 订单 A003 预估应付 290 元# 订单 A003 结算实付 290 元TS 版结构一样:
class Order { constructor( readonly orderId: string, readonly amount: number, // 商品总价(元) ) {}}
class Coupon { constructor(readonly type: string) {} // "full_reduction" 满减 / "percent" 折扣券 / "newcomer" 新人立减}
function calcDiscount(order: Order, coupon: Coupon): number { if (coupon.type === "full_reduction") { // 满 200 减 30 return order.amount >= 200 ? 30 : 0; } else if (coupon.type === "percent") { // 九折,封顶 50 return Math.min(Math.floor(order.amount / 10), 50); } else if (coupon.type === "newcomer") { // 新人立减 10 元 return 10; } throw new Error(`未知券类型:${coupon.type}`);}
// 调用点一:下单页上的订单预估function estimatePrice(order: Order, coupon: Coupon): string { return `订单 ${order.orderId} 预估应付 ${order.amount - calcDiscount(order, coupon)} 元`;}
// 调用点二:结算function settle(order: Order, coupon: Coupon): string { return `订单 ${order.orderId} 结算实付 ${order.amount - calcDiscount(order, coupon)} 元`;}
const cases: [Order, Coupon][] = [ [new Order("A001", 199), new Coupon("full_reduction")], [new Order("A002", 600), new Coupon("percent")], [new Order("A003", 300), new Coupon("newcomer")],];for (const [order, coupon] of cases) { console.log(estimatePrice(order, coupon)); console.log(settle(order, coupon));}
// 输出:// 订单 A001 预估应付 199 元// 订单 A001 结算实付 199 元// 订单 A002 预估应付 550 元// 订单 A002 结算实付 550 元// 订单 A003 预估应付 290 元// 订单 A003 结算实付 290 元三种券、三个分支,运营的规则一眼就能在代码里对上,两个调用点也都很短。券的种类不怎么变的时候,这样写清楚直接,大多数人第一版都这么写。
17.3 需求变了
Section titled “17.3 需求变了”运营一次提了两件事:加会员价,会员下单一律 95 折,和券不叠加(会员用券也按会员价算;真实业务常取较优,那样选择就变难了,见 17.8);同时做一个 A/B 测试(A/B test),同一张满减券,实验组用户按“满 199 减 30”算,对照组仍是满 200 减 30。实验分组由实验平台给出,本章用一个名单 EXPERIMENT_USERS 和函数 ab_group(user) 模拟,返回 "test" 或 "control"。
会员身份和实验分组都不在订单和券上,原来的函数拿不到,要改的地方有三处:
calc_discount:签名多两个参数user和group;开头加一个会员分支;满减分支里按分组决定门槛是 199 还是 200。为了不让还没改的调用处报错,group给了默认值"control"。- 调用点一
estimate_price:要拿到当前用户,查出分组,一起传进去。 - 调用点二
settle:同样要传用户和分组。
改完的样子(Order、Coupon 和 17.2 相同):
@dataclassclass User: user_id: str is_member: bool = False
EXPERIMENT_USERS = {"u42"} # 满减实验的实验组名单(模拟实验平台)
def ab_group(user: User) -> str: return "test" if user.user_id in EXPERIMENT_USERS else "control"
# ① 签名多了 user 和 group;group 给了默认值,免得别处的调用报错def calc_discount(order: Order, coupon: Coupon, user: User, group: str = "control") -> int: if user.is_member: # ① 会员价:95 折,与券不叠加 return order.amount * 5 // 100 if coupon.type == "full_reduction": threshold = 199 if group == "test" else 200 # ① 实验组按满 199 减 30 return 30 if order.amount >= threshold else 0 elif coupon.type == "percent": return min(order.amount // 10, 50) elif coupon.type == "newcomer": return 10 raise ValueError(f"未知券类型:{coupon.type}")
# ② 订单预估:加了 user,忘了查分组def estimate_price(order: Order, coupon: Coupon, user: User) -> str: return f"订单 {order.order_id} 预估应付 {order.amount - calc_discount(order, coupon, user)} 元"
# ③ 结算:加了 user,也查了分组def settle(order: Order, coupon: Coupon, user: User) -> str: group = ab_group(user) return f"订单 {order.order_id} 结算实付 {order.amount - calc_discount(order, coupon, user, group)} 元"
if __name__ == "__main__": user = User("u42") # 实验组用户,不是会员 order, coupon = Order("A001", 199), Coupon("full_reduction") print(estimate_price(order, coupon, user)) print(settle(order, coupon, user))TS 版要改的是同样的三处:calcDiscount 加参数 user 和 group = "control"、加会员分支和实验门槛,estimatePrice 和 settle 都加参数往下传。
事故出在第 2 处。维护订单预估的同事加了 user,没注意到还有分组这回事;group 有默认值,少传不报错,测试用的账号又恰好不在实验组。把上面几段换进 before.py,用实验组用户 u42 下一单 199 元、用满减券:
订单 A001 预估应付 199 元订单 A001 结算实付 169 元预估页按对照组算,199 元不够满 200,一分没减;结算按实验组算,减了 30。上线后客服收到投诉:“下单页显示 199 元,结算却是 169 元,到底哪个对?“实验本身也废了:实验组用户在下单页看不到满 199 减 30,测出来的转化率是错的,只能重做。这和第 1 章 1.2 的第 4 处是同一种毛病:参数有默认值,漏传的那个调用处照样能跑,只是算出来的数不对。
问题的形状是:每加一条优惠规则,就要改同一个函数和它的全部调用点;规则需要的输入越来越多,每个调用点都得自己凑齐,漏了哪个就和别处算得不一样。
图中 ①–③ 就是上面三处。边框加粗的是订单预估:它到 ab_group 的线是虚线,分组没查就进了 calc_discount,按默认的对照组算出了 199 元。
17.4 用策略模式重写
Section titled “17.4 用策略模式重写”换个思路:每条优惠规则单独写成一个类,都实现同一个接口 DiscountStrategy,只有一个方法 discount(order),返回减多少。规则需要的参数(满多少、减多少)在创建策略对象时给它,不经过公共的函数签名。“用哪条规则”集中到一个工厂函数 pick_strategy(coupon, user) 里:会员、券类型、实验分组都在这里看,别处不再自己判断。选好的策略交给一个 Checkout 对象,订单预估和结算都只问这同一个 Checkout 要应付金额。
from dataclasses import dataclassfrom typing import Protocol
@dataclassclass Order: order_id: str amount: int # 商品总价(元)
@dataclassclass Coupon: type: str
@dataclassclass User: user_id: str is_member: bool = False
class DiscountStrategy(Protocol): def discount(self, order: Order) -> int: ...
class FullReduction: # 满减:满 threshold 减 off def __init__(self, threshold: int, off: int) -> None: self.threshold, self.off = threshold, off
def discount(self, order: Order) -> int: return self.off if order.amount >= self.threshold else 0
class PercentOff: # 折扣券:九折,封顶 50 def discount(self, order: Order) -> int: return min(order.amount // 10, 50)
class NewcomerOff: # 新人立减 10 元 def discount(self, order: Order) -> int: return 10
class MemberPrice: # 新需求:会员 95 折 def discount(self, order: Order) -> int: return order.amount * 5 // 100
EXPERIMENT_USERS = {"u42"} # 满减实验的实验组名单(模拟实验平台)
def ab_group(user: User) -> str: return "test" if user.user_id in EXPERIMENT_USERS else "control"
STRATEGIES: dict[str, DiscountStrategy] = { # 券类型 → 策略 "full_reduction": FullReduction(200, 30), "percent": PercentOff(), "newcomer": NewcomerOff(),}EXPERIMENTS: dict[tuple[str, str], DiscountStrategy] = { # (券类型, 分组) → 实验里替换用的策略 ("full_reduction", "test"): FullReduction(199, 30), # 新需求:A/B 实验只登记这一行}
def pick_strategy(coupon: Coupon, user: User) -> DiscountStrategy: # 全程序只在这里选策略 if user.is_member: return MemberPrice() # 会员价与券不叠加 if coupon.type not in STRATEGIES: raise ValueError(f"未知券类型:{coupon.type}") return EXPERIMENTS.get((coupon.type, ab_group(user)), STRATEGIES[coupon.type])
class Checkout: # 一次结算:持有订单和选好的策略 def __init__(self, order: Order, strategy: DiscountStrategy) -> None: self.order, self.strategy = order, strategy
def payable(self) -> int: return self.order.amount - self.strategy.discount(self.order)
# 调用点一:下单页上的订单预估def estimate_price(checkout: Checkout) -> str: return f"订单 {checkout.order.order_id} 预估应付 {checkout.payable()} 元"
# 调用点二:结算def settle(checkout: Checkout) -> str: return f"订单 {checkout.order.order_id} 结算实付 {checkout.payable()} 元"
if __name__ == "__main__": for user, order, coupon in [ (User("u42"), Order("A001", 199), Coupon("full_reduction")), # 实验组 (User("u7"), Order("A002", 600), Coupon("percent")), (User("u8", is_member=True), Order("A003", 300), Coupon("newcomer")), # 会员 ]: checkout = Checkout(order, pick_strategy(coupon, user)) # 两个调用点拿的是同一个 checkout print(f"{user.user_id} 用 {type(checkout.strategy).__name__}") print(estimate_price(checkout)) print(settle(checkout))
# 输出:# u42 用 FullReduction# 订单 A001 预估应付 169 元# 订单 A001 结算实付 169 元# u7 用 PercentOff# 订单 A002 预估应付 550 元# 订单 A002 结算实付 550 元# u8 用 MemberPrice# 订单 A003 预估应付 285 元# 订单 A003 结算实付 285 元TS 版结构相同,实验表的键用 "券类型/分组" 拼成一个字符串:
class Order { constructor( readonly orderId: string, readonly amount: number, // 商品总价(元) ) {}}
class Coupon { constructor(readonly type: string) {}}
class User { constructor(readonly userId: string, readonly isMember = false) {}}
interface DiscountStrategy { discount(order: Order): number;}
class FullReduction implements DiscountStrategy { // 满减:满 threshold 减 off constructor(private readonly threshold: number, private readonly off: number) {}
discount(order: Order): number { return order.amount >= this.threshold ? this.off : 0; }}
class PercentOff implements DiscountStrategy { // 折扣券:九折,封顶 50 discount(order: Order): number { return Math.min(Math.floor(order.amount / 10), 50); }}
class NewcomerOff implements DiscountStrategy { // 新人立减 10 元 discount(order: Order): number { return 10; }}
class MemberPrice implements DiscountStrategy { // 新需求:会员 95 折 discount(order: Order): number { return Math.floor(order.amount * 5 / 100); }}
const EXPERIMENT_USERS = new Set(["u42"]); // 满减实验的实验组名单(模拟实验平台)
function abGroup(user: User): string { return EXPERIMENT_USERS.has(user.userId) ? "test" : "control";}
const STRATEGIES: Record<string, DiscountStrategy> = { // 券类型 → 策略 full_reduction: new FullReduction(200, 30), percent: new PercentOff(), newcomer: new NewcomerOff(),};const EXPERIMENTS: Record<string, DiscountStrategy> = { // "券类型/分组" → 实验里替换用的策略 "full_reduction/test": new FullReduction(199, 30), // 新需求:A/B 实验只登记这一行};
function pickStrategy(coupon: Coupon, user: User): DiscountStrategy { // 全程序只在这里选策略 if (user.isMember) return new MemberPrice(); // 会员价与券不叠加 const base = STRATEGIES[coupon.type]; if (base === undefined) throw new Error(`未知券类型:${coupon.type}`); return EXPERIMENTS[`${coupon.type}/${abGroup(user)}`] ?? base;}
class Checkout { // 一次结算:持有订单和选好的策略 constructor(readonly order: Order, readonly strategy: DiscountStrategy) {}
payable(): number { return this.order.amount - this.strategy.discount(this.order); }}
// 调用点一:下单页上的订单预估function estimatePrice(checkout: Checkout): string { return `订单 ${checkout.order.orderId} 预估应付 ${checkout.payable()} 元`;}
// 调用点二:结算function settle(checkout: Checkout): string { return `订单 ${checkout.order.orderId} 结算实付 ${checkout.payable()} 元`;}
const cases: [User, Order, Coupon][] = [ [new User("u42"), new Order("A001", 199), new Coupon("full_reduction")], // 实验组 [new User("u7"), new Order("A002", 600), new Coupon("percent")], [new User("u8", true), new Order("A003", 300), new Coupon("newcomer")], // 会员];for (const [user, order, coupon] of cases) { const checkout = new Checkout(order, pickStrategy(coupon, user)); // 两个调用点拿的是同一个 checkout console.log(`${user.userId} 用 ${checkout.strategy.constructor.name}`); console.log(estimatePrice(checkout)); console.log(settle(checkout));}
// 输出:// u42 用 FullReduction// 订单 A001 预估应付 169 元// 订单 A001 结算实付 169 元// u7 用 PercentOff// 订单 A002 预估应付 550 元// 订单 A002 结算实付 550 元// u8 用 MemberPrice// 订单 A003 预估应付 285 元// 订单 A003 结算实付 285 元看黄色的两块:会员价是新加的一个类 MemberPrice,A/B 实验是 EXPERIMENTS 表里新加的一行,其余的框都没动。pick_strategy 是唯一连着策略的选择处,两个调用点只连着 Checkout。
看顺序图:分组只在 pick_strategy 里查了一次,选出 FullReduction(199, 30);之后订单预估和结算问的是同一个 Checkout,转到同一个策略对象,两次都得到 169。
逐条对照 17.3 的三处:
- 第 1 处,
calc_discount越改越长:现在没有这个函数了。会员价是新加的MemberPrice类,满减、折扣、新人三个类一行没动;实验组的门槛不是在满减分支里加if,而是在EXPERIMENTS里登记一个FullReduction(199, 30)。实验结束时删掉这一行,不用回到满减的代码里拆if。 - 第 2、3 处,两个调用点各自传参数:
estimate_price和settle都只收一个Checkout,不知道有会员、有实验,签名也不会再跟着规则变。 - 事故:分组不再是一个调用处可以漏传的参数,而是在
pick_strategy里面查的。两个调用点拿的是同一个Checkout,里面是同一个策略对象,输出里 u42 的预估和结算都是 169 元。
要说清楚的是,“选哪个策略”并没有消失,它搬进了 pick_strategy,那里照样有一个 if user.is_member 和两次查表。以后加一种券,要新写一个类,再在 STRATEGIES 里登记一行;如果新规则还要看别的条件(比如地区),pick_strategy 也要改。策略模式换来的是两样东西:选择只有一处,每条规则一个类。所有调用点都从同一处拿到策略,就不会各算各的;改会员价只碰 MemberPrice,不会顺手碰坏满减。这条规则要守住:有人在预估页直接写 Checkout(order, STRATEGIES["full_reduction"]) 绕过 pick_strategy,事故就原样回来,所以要在代码评审里约定“只有 pick_strategy 选策略”。代价是原来一个十几行的函数,现在是一个接口、四个类、两张表、一个工厂和一个 Checkout;想知道某一单为什么减了 30,要先去 pick_strategy 看它选了谁,再去那个类里看规则。
17.5 模式定义
Section titled “17.5 模式定义”GoF 对策略的定义是:定义一系列的算法,把它们一个个封装起来,并且使它们可以相互替换。
换成本章的话:优惠规则就是“一系列的算法”;每条规则一个类,叫“封装起来”;它们都实现 discount(order),Checkout 拿到哪一个都能用,叫“可以相互替换”。使用算法的那个对象叫上下文(Context),它只持有一个策略接口,到了要算的时候把工作交给策略,自己不知道手里具体是哪一个。
GoF 图里 Context.contextInterface() 对应本章的 Checkout.payable(),Strategy.algorithmInterface() 对应 discount(order)。图里的 Client 负责挑一个具体策略交给 Context,本章是 pick_strategy 加上下单流程。
| 角色 | 在本章案例里是谁 | 职责 |
|---|---|---|
| 策略(Strategy) | DiscountStrategy;第 1 章是 ShippingStrategy |
所有算法共同的接口,上下文只认它 |
| 具体策略(ConcreteStrategy) | FullReduction、PercentOff、NewcomerOff、MemberPrice;第 1 章是 SfExpress 等 |
实现一种具体算法,自己需要的参数在创建时拿着 |
| 上下文(Context) | Checkout;第 1 章是 Order |
持有一个策略,需要时把工作交给它;对外提供业务方法 |
| 客户(Client) | pick_strategy 和下单流程 |
决定用哪个策略,创建好交给上下文 |
常见场景:
- 计价规则。优惠、运费、税费、会员价,每条规则一个策略,按订单条件选。
- 排序和比较。排序算法本身不变,“谁排在前面”由传进去的比较规则决定,17.6 会看到它。
- 压缩和序列化格式。gzip 还是 zstd、JSON 还是 Protobuf,调用方只说“压缩它”“写出去”。
- 重试退避。失败后等多久再试:固定间隔、指数退避(exponential backoff)、加随机抖动(jitter),17.10 的练习就是它。
- 支付路由。按金额、费率、可用性选走哪个支付通道,每个通道一个策略。
17.6 更简洁的写法
Section titled “17.6 更简洁的写法”本章的策略都只有一个方法。只有一个方法的接口,和“一个函数类型”没有区别:Python 里写成 Callable[[Order], int],TS 里写成 (order: Order) => number。这时不用写类,函数本身就是策略;需要参数的(满多少减多少),用一个返回函数的函数把参数关进闭包(closure):
from collections.abc import Callablefrom dataclasses import dataclass
@dataclassclass Order: order_id: str amount: int
Discount = Callable[[Order], int] # 策略就是一个"收订单、返回优惠额"的函数
def full_reduction(threshold: int, off: int) -> Discount: # 带参数的策略:用闭包记住参数 return lambda order: off if order.amount >= threshold else 0
def percent_off(order: Order) -> int: return min(order.amount // 10, 50)
def newcomer_off(order: Order) -> int: return 10
def member_price(order: Order) -> int: return order.amount * 5 // 100
STRATEGIES: dict[str, Discount] = { "full_reduction": full_reduction(200, 30), "percent": percent_off, "newcomer": newcomer_off, "member": member_price,}
def payable(order: Order, discount: Discount) -> int: # 相当于 Checkout.payable return order.amount - discount(order)
if __name__ == "__main__": order = Order("A002", 600) for name, discount in STRATEGIES.items(): print(f"{name}:应付 {payable(order, discount)} 元") orders = [Order("A001", 199), Order("A002", 600), Order("A003", 300)] by_amount = sorted(orders, key=lambda o: o.amount, reverse=True) # key 也是一个策略 print("、".join(o.order_id for o in by_amount))
# 输出:# full_reduction:应付 570 元# percent:应付 550 元# newcomer:应付 590 元# member:应付 570 元# A002、A003、A001TS 版同样是一张从名字到函数的表:
interface Order { orderId: string; amount: number;}
type Discount = (order: Order) => number; // 策略就是一个"收订单、返回优惠额"的函数
function fullReduction(threshold: number, off: number): Discount { // 带参数的策略:用闭包记住参数 return (order) => (order.amount >= threshold ? off : 0);}
const percentOff: Discount = (order) => Math.min(Math.floor(order.amount / 10), 50);const newcomerOff: Discount = () => 10;const memberPrice: Discount = (order) => Math.floor(order.amount * 5 / 100);
const STRATEGIES: Record<string, Discount> = { full_reduction: fullReduction(200, 30), percent: percentOff, newcomer: newcomerOff, member: memberPrice,};
function payable(order: Order, discount: Discount): number { // 相当于 Checkout.payable return order.amount - discount(order);}
const order: Order = { orderId: "A002", amount: 600 };for (const [name, discount] of Object.entries(STRATEGIES)) { console.log(`${name}:应付 ${payable(order, discount)} 元`);}const orders: Order[] = [ { orderId: "A001", amount: 199 }, { orderId: "A002", amount: 600 }, { orderId: "A003", amount: 300 },];const byAmount = [...orders].sort((a, b) => b.amount - a.amount); // 比较函数也是一个策略console.log(byAmount.map((o) => o.orderId).join("、"));
// 输出:// full_reduction:应付 570 元// percent:应付 550 元// newcomer:应付 590 元// member:应付 570 元// A002、A003、A001结构还是同一个:“调用方只认一个签名,具体算法可以互换”。语言替你省掉的是 DiscountStrategy 接口和每个策略的类定义:一个方法的类就是一个函数,构造参数就是闭包里的变量。你其实天天在用这种写法:Python 的 sorted(orders, key=...)、TS 的 sort((a, b) => ...),sorted 和 sort 是上下文,传进去的 key 或比较函数就是策略,换一个函数就换了一种排法,排序算法本身一行不改。
什么时候函数就够了:策略只有一个方法、没有要单独配置的东西、出了问题也不需要知道它叫什么。什么时候类仍然值得写:
- 策略要带配置。闭包也能记住门槛和减额,但外面看不到
threshold是多少;类把配置露成属性,后台能显示“当前满 199 减 30”,测试也能直接断言。 - 策略要有名字出现在日志里。17.4 的输出打印了
FullReduction、MemberPrice,17.7 的 Passport 策略也带着name = "local",客服查“为什么减了 30”时能对上;full_reduction(199, 30)返回的函数打印出来只有函数名和内存地址,看得出是满减,看不出门槛是 199 还是 200。 - 策略不止一个方法。比如除了
discount,还要一个describe给页面显示“已减 30 元(满 199 减 30)”,两个方法得放在一起,就该是类。17.7 的 Django 哈希器就是这样,有encode和verify两个方法。
17.7 真实系统里的例子
Section titled “17.7 真实系统里的例子”例一:Django 的密码哈希器(Python)
Section titled “例一:Django 的密码哈希器(Python)”Django 存用户密码时不存明文,而是存一个哈希值,算哈希的算法就是策略。settings.PASSWORD_HASHERS 是一个哈希器类的列表,每个类实现 encode(把密码加密成存储格式)和 verify(核对密码)。存下来的值以算法名开头,比如 pbkdf2_sha256$迭代次数$盐$摘要。列表里第一个用来加密新密码;核对时按存储值的前缀找到对应的哈希器(identify_hasher)。如果用户用旧算法存的密码登录成功,check_password 会调传进来的 setter,用第一个哈希器重新加密保存,大致如此:真实的升级条件还包括迭代次数过时等情况。老系统迁移过来时,团队会把 MD5 这类旧哈希器放在列表后面,让老用户在下一次登录时自动升级。下面用 hashlib 模拟:
import hashlibfrom collections.abc import Callablefrom typing import Protocol
class PasswordHasher(Protocol): # 对应 django.contrib.auth.hashers.BasePasswordHasher algorithm: str
def encode(self, password: str, salt: str) -> str: ... def verify(self, password: str, encoded: str) -> bool: ...
class PBKDF2Hasher: # 对应 PBKDF2PasswordHasher;存成 "算法$迭代次数$盐$摘要" algorithm = "pbkdf2_sha256" iterations = 1000 # 真实默认值是几十万次以上、随版本上调,这里取小数字跑得快
def encode(self, password: str, salt: str) -> str: digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), self.iterations) return f"{self.algorithm}${self.iterations}${salt}${digest.hex()}" # 真实的 Django 存 base64,这里用 hex
def verify(self, password: str, encoded: str) -> bool: salt = encoded.split("$")[2] return self.encode(password, salt) == encoded
class MD5Hasher: # 对应 MD5PasswordHasher:老系统留下的格式 "md5$盐$摘要" algorithm = "md5"
def encode(self, password: str, salt: str) -> str: return f"{self.algorithm}${salt}${hashlib.md5((salt + password).encode()).hexdigest()}"
def verify(self, password: str, encoded: str) -> bool: salt = encoded.split("$")[1] return self.encode(password, salt) == encoded
PASSWORD_HASHERS: list[PasswordHasher] = [PBKDF2Hasher(), MD5Hasher()] # 对应 settings.PASSWORD_HASHERS
def make_password(password: str) -> str: # 新密码一律用列表里第一个 return PASSWORD_HASHERS[0].encode(password, salt="fixedsalt") # 真实的盐是随机生成的
def identify_hasher(encoded: str) -> PasswordHasher: # 按存储值的前缀挑策略 algorithm = encoded.split("$", 1)[0] for hasher in PASSWORD_HASHERS: if hasher.algorithm == algorithm: return hasher raise ValueError(f"不认识的哈希格式:{algorithm}")
def check_password(password: str, encoded: str, setter: Callable[[str], None]) -> bool: hasher = identify_hasher(encoded) ok = hasher.verify(password, encoded) if ok and hasher is not PASSWORD_HASHERS[0]: # 验证通过但用的是旧算法:顺手升级 setter(password) return ok
if __name__ == "__main__": stored = {"alice": make_password("s3cret"), "bob": MD5Hasher().encode("hunter2", "ab")}
def save_bob(raw: str) -> None: # 对应 Django 传进来的 setter:用新算法重新加密并保存 stored["bob"] = make_password(raw)
print(f"alice 新注册,存的是 {stored['alice'].split('$')[0]}") print(f"bob 输错密码:{check_password('wrong', stored['bob'], save_bob)}") print(f"bob 登录:{check_password('hunter2', stored['bob'], save_bob)}") print(f"bob 登录后,存的是 {stored['bob'].split('$')[0]}")
# 输出:# alice 新注册,存的是 pbkdf2_sha256# bob 输错密码:False# bob 登录:True# bob 登录后,存的是 pbkdf2_sha256PASSWORD_HASHERS 相当于本章的 STRATEGIES,identify_hasher 就是 pick_strategy:它按存储值的前缀选策略,不是由调用方传一个类型进来,和本章“分组在 pick_strategy 里查”是一个道理。升级也只是“换一个策略再加密一次”,调用 check_password 的登录代码完全不知道。
例二:Passport.js 的认证策略(TypeScript)
Section titled “例二:Passport.js 的认证策略(TypeScript)”Passport.js 是 Node.js 里常用的认证中间件,文档里直接把各种登录方式叫作 strategy:用户名密码是 passport-local,令牌是 passport-http-bearer。启动时 passport.use(new LocalStrategy(verify)) 按名字登记一个策略,路由里写 passport.authenticate("local"),按名字取出策略去认证请求。每个策略只负责取出凭证、交给你写的 verify 核对;把用户挂到 req.user 上是 Passport 统一做的。下面不用这个库,只模拟它的形状;真实的 Strategy 通过调用 this.success、this.fail 报告结果,passport-local 的 verify 是带回调的 (username, password, done),中间件签名是 (req, res, next),这里都简化成返回值:
interface HttpRequest { // 简化的 HTTP 请求 body: Record<string, string>; headers: Record<string, string>; user?: string;}
interface Strategy { // 对应 passport-strategy 的 Strategy:真实的是调 this.success / this.fail name: string; authenticate(req: HttpRequest): string | null; // 认出是谁就返回用户名,否则返回 null}
class LocalStrategy implements Strategy { // 对应 passport-local:从表单取用户名和密码 name = "local"; constructor(private verify: (username: string, password: string) => string | null) {} authenticate(req: HttpRequest): string | null { return this.verify(req.body.username ?? "", req.body.password ?? ""); }}
class BearerStrategy implements Strategy { // 对应 passport-http-bearer:从请求头取令牌 name = "bearer"; constructor(private verify: (token: string) => string | null) {} authenticate(req: HttpRequest): string | null { const header = req.headers.authorization ?? ""; return header.startsWith("Bearer ") ? this.verify(header.slice(7)) : null; }}
class Passport { private strategies = new Map<string, Strategy>();
use(strategy: Strategy): void { // passport.use(new LocalStrategy(...)):按名字登记 this.strategies.set(strategy.name, strategy); }
authenticate(name: string): (req: HttpRequest) => string { // 返回一个中间件;真实的签名是 (req, res, next) return (req) => { const strategy = this.strategies.get(name); if (strategy === undefined) throw new Error(`Unknown authentication strategy "${name}"`); const user = strategy.authenticate(req); if (user === null) return "401 认证失败"; req.user = user; return `200 你好,${user}`; }; }}
const passport = new Passport();passport.use(new LocalStrategy((u, p) => (u === "alice" && p === "s3cret" ? "alice" : null)));passport.use(new BearerStrategy((token) => (token === "tok-123" ? "ci-bot" : null)));
const login = passport.authenticate("local"); // app.post("/login", passport.authenticate("local"), ...)const api = passport.authenticate("bearer"); // app.get("/api/orders", passport.authenticate("bearer"), ...)console.log(login({ body: { username: "alice", password: "s3cret" }, headers: {} }));console.log(login({ body: { username: "alice", password: "wrong" }, headers: {} }));console.log(api({ body: {}, headers: { authorization: "Bearer tok-123" } }));
// 输出:// 200 你好,alice// 401 认证失败// 200 你好,ci-botPassport 相当于本章的 Checkout,只认 Strategy 接口;passport.use 是登记一行,authenticate("local") 是按名字选。加一种登录方式只写一个策略、use 一次,路由里换个名字,Passport 本身不改。
17.8 什么时候别用
Section titled “17.8 什么时候别用”- 只有两种算法,而且不会再加。比如发票只分个人和企业抬头,企业多填一个税号。写成接口加两个类加一个选择函数,读代码的人要在四个地方之间跳,才看清“企业要多填税号”;一个
if invoice.is_company一眼就看完了。 - 选择逻辑本身才是难点。如果
pick_strategy要知道每个策略的细节才能选,比如“满减和折扣都能用时,先各算一遍,取减得多的那个;会员日又是另一套顺序”,那么复杂的不是算法,而是选择。把每条规则拆成类之后,这几十行if原样留在pick_strategy里,什么都没省下。如果选哪个还取决于之前发生过什么(这是第几次下单、上一单退没退款),那是对象在不同状态之间切换,看第 21 章状态。 - 策略之间要共享大量数据。有的规则看库存,有的看用户历史,有的看优惠预算,接口就会长成
discount(order, user, stock, budget, history),每个策略只用一两个,又回到第 1 章 1.2 参数越来越长的老路。这时要么让策略在创建时拿好自己要的东西,要么承认这些规则本该在一起,不拆。
17.9 容易混淆的模式
Section titled “17.9 容易混淆的模式”| 模式 | 意图 | 区别 |
|---|---|---|
| 策略(Strategy) | 把一族可互换的算法各自封装 | 由外面挑好交给上下文,策略之间互不认识 |
| 状态(State) | 对象的行为随内部状态切换 | 结构几乎一样,但换的是对象自己:做完一个操作,当前状态决定下一个是谁。见第 21 章 |
| 模板方法(Template Method) | 父类定流程骨架,子类填具体步骤 | 同样是“一部分会变”,模板方法靠继承:变的那步是子类覆写的方法。策略靠组合:变的那步是持有的对象。见第 20 章 |
| 桥接(Bridge) | 把两个独立变化的维度拆开,用组合连接 | 结构几乎一样,都持有一个接口对象;但策略换的是一个算法(怎么算优惠),桥接的实现部分是一整套平台能力,而且抽象部分自己也有一棵继承树。见第 14 章 |
三组都能用一个问题当场判断:
- 策略还是状态:问“谁来换”。打开具体策略类看一眼:
FullReduction里不提MemberPrice,也从不把Checkout换成别的策略,换是pick_strategy在外面做的,这是策略。如果每个类的方法做完事会把上下文切到下一个类,比如“已付款”状态的ship()里写着“切到已发货”,各个类彼此知道谁接在后面,那是状态。 - 策略还是模板方法:问“换算法要不要新建子类”。本章给
Checkout换一种优惠,传另一个策略对象就行,运行时也能换。如果让Checkout.payable()调自己的discount(),再写class FullReductionCheckout(Checkout)覆写它,一种优惠一个Checkout子类,对象创建出来算法就定死了,那是模板方法。父类有一段多步的固定流程、只有一两步要变时,模板方法更顺手。 - 策略还是桥接:问“被持有的接口有几个方法,持有它的一方有没有自己的子类”。
DiscountStrategy只有一个discount,Checkout没有子类,是策略。第 14 章的消息类型自己有一棵子类树,持有的Channel是一整套发送能力,是桥接。
17.10 练一练
Section titled “17.10 练一练”调用外部接口失败后要重试,每次重试前等一会儿。请定义策略接口 Backoff,只有一个方法 delay_ms(attempt)(TS 叫 delayMs),返回第 attempt 次重试前等多少毫秒,attempt 从 0 开始。实现三个策略:
- 固定间隔:每次都等 1000 毫秒。
- 指数退避:从 100 毫秒起,每次翻倍,最多 10000 毫秒。
- 带抖动的指数退避:在 0 到指数退避值之间随机取一个数,让一大批同时失败的请求不要在同一时刻一起重试。
再写一个只认 Backoff 接口的函数,打印每个策略前 5 次重试的等待毫秒数。Python 和 TS 各写一版。
提示:带抖动的那个要用固定的随机种子。两种语言内置的随机数不一样,想让两版输出对得上,可以自己写一个线性同余生成器(linear congruential generator):每次 state = state * 48271 % 2147483647,再除以 2147483647。随机数函数在创建策略时传进去,和满减门槛在创建时传进去是一个道理。
17.11 小结
Section titled “17.11 小结”- 策略把一族可以互换的算法各自装进一个类,上下文只持有它们共同的接口,第 1 章的运费类就是一例。
- 选哪个策略不会消失,要集中到一处(本章的
pick_strategy);所有调用点从这一处拿,才不会各算各的。新增一条规则是加一个类、登记一行。 - 策略需要的参数在创建时交给它,不要塞进公共的方法签名;签名一长、参数一有默认值,漏传的调用处就会悄悄算错。
- 只有一个方法、不需要配置和名字时,传一个函数就够了,
sorted(key=...)天天在这样用;要带配置、要在日志里露名字、有多个方法时再写成类。 - 和状态的区别看谁来换:调用方挑的是策略,对象自己切的是状态。和模板方法的区别看组合还是继承。