跳转到内容

第 17 章 · 策略(Strategy)

一句话:把一族可以互换的算法各自装进一个类,使用方只认它们共同的接口,选哪一个由一处代码决定。 解决的痛:算法写成一串 if 时,每加一条规则都要改同一个函数和它的全部调用处;哪个调用处少传一个参数,程序不报错,算出来的数却和别处不一样。

先回到第 1 章。1.3 把运费拆成了 SfExpress、StandardDelivery、StorePickup 几个类,它们都有一个 fee 方法,Order 手里拿着其中一个,算运费时交给它。当时说过“第 17 章会正式讲它”,那个写法就是策略模式。第 1 章只讲了一半:每种算法各装一个类。另一半是用哪个算法由谁来定:那里的入口代码写死了 SfExpress(),真实项目里选哪个往往取决于好几样东西,而且好几处都要选。本章把这两半讲完。

你在一家电商公司负责结算页的优惠计算。用户下单时可以用一张券,现在有三种:

  • 满减券:满 200 减 30。
  • 折扣券:九折,最多减 50。
  • 新人券:直接减 10 元。

本章金额都用整数元,折扣向下取整。和第 1 章的运费一样,优惠也要在两个地方算:下单页上的“订单预估”,用户还没付款就要看到应付多少;结算时正式算出实付金额写进账单。两处由不同的同事维护,但必须算出同一个数。

一个函数 calc_discount,按券的类型分三个分支,两个调用点都去调它:

from dataclasses import dataclass
@dataclass
class Order:
order_id: str
amount: int # 商品总价(元)
@dataclass
class 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 元

三种券、三个分支,运营的规则一眼就能在代码里对上,两个调用点也都很短。券的种类不怎么变的时候,这样写清楚直接,大多数人第一版都这么写。

运营一次提了两件事:加会员价,会员下单一律 95 折,和券不叠加(会员用券也按会员价算;真实业务常取较优,那样选择就变难了,见 17.8);同时做一个 A/B 测试(A/B test),同一张满减券,实验组用户按“满 199 减 30”算,对照组仍是满 200 减 30。实验分组由实验平台给出,本章用一个名单 EXPERIMENT_USERS 和函数 ab_group(user) 模拟,返回 "test" 或 "control"。

会员身份和实验分组都不在订单和券上,原来的函数拿不到,要改的地方有三处:

  1. calc_discount:签名多两个参数 user 和 group;开头加一个会员分支;满减分支里按分组决定门槛是 199 还是 200。为了不让还没改的调用处报错,group 给了默认值 "control"。
  2. 调用点一 estimate_price:要拿到当前用户,查出分组,一起传进去。
  3. 调用点二 settle:同样要传用户和分组。

改完的样子(Order、Coupon 和 17.2 相同):

@dataclass
class 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 元。

换个思路:每条优惠规则单独写成一个类,都实现同一个接口 DiscountStrategy,只有一个方法 discount(order),返回减多少。规则需要的参数(满多少、减多少)在创建策略对象时给它,不经过公共的函数签名。“用哪条规则”集中到一个工厂函数 pick_strategy(coupon, user) 里:会员、券类型、实验分组都在这里看,别处不再自己判断。选好的策略交给一个 Checkout 对象,订单预估和结算都只问这同一个 Checkout 要应付金额。

from dataclasses import dataclass
from typing import Protocol
@dataclass
class Order:
order_id: str
amount: int # 商品总价(元)
@dataclass
class Coupon:
type: str
@dataclass
class 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 元

策略版:四个策略实现 DiscountStrategy,Checkout 持有其中一个

看黄色的两块:会员价是新加的一个类 MemberPrice,A/B 实验是 EXPERIMENTS 表里新加的一行,其余的框都没动。pick_strategy 是唯一连着策略的选择处,两个调用点只连着 Checkout。

一次下单: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 看它选了谁,再去那个类里看规则。

GoF 对策略的定义是:定义一系列的算法,把它们一个个封装起来,并且使它们可以相互替换。

换成本章的话:优惠规则就是“一系列的算法”;每条规则一个类,叫“封装起来”;它们都实现 discount(order),Checkout 拿到哪一个都能用,叫“可以相互替换”。使用算法的那个对象叫上下文(Context),它只持有一个策略接口,到了要算的时候把工作交给策略,自己不知道手里具体是哪一个。

策略的结构:Context 持有一个 Strategy,具体策略各自实现 algorithmInterface

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 的练习就是它。
  • 支付路由。按金额、费率、可用性选走哪个支付通道,每个通道一个策略。

本章的策略都只有一个方法。只有一个方法的接口,和“一个函数类型”没有区别:Python 里写成 Callable[[Order], int],TS 里写成 (order: Order) => number。这时不用写类,函数本身就是策略;需要参数的(满多少减多少),用一个返回函数的函数把参数关进闭包(closure):

from collections.abc import Callable
from dataclasses import dataclass
@dataclass
class 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、A001

TS 版同样是一张从名字到函数的表:

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 两个方法。

例一:Django 的密码哈希器(Python)

Section titled “例一:Django 的密码哈希器(Python)”

Django 存用户密码时不存明文,而是存一个哈希值,算哈希的算法就是策略。settings.PASSWORD_HASHERS 是一个哈希器类的列表,每个类实现 encode(把密码加密成存储格式)和 verify(核对密码)。存下来的值以算法名开头,比如 pbkdf2_sha256$迭代次数$盐$摘要。列表里第一个用来加密新密码;核对时按存储值的前缀找到对应的哈希器(identify_hasher)。如果用户用旧算法存的密码登录成功,check_password 会调传进来的 setter,用第一个哈希器重新加密保存,大致如此:真实的升级条件还包括迭代次数过时等情况。老系统迁移过来时,团队会把 MD5 这类旧哈希器放在列表后面,让老用户在下一次登录时自动升级。下面用 hashlib 模拟:

import hashlib
from collections.abc import Callable
from 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_sha256

PASSWORD_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-bot

Passport 相当于本章的 Checkout,只认 Strategy 接口;passport.use 是登记一行,authenticate("local") 是按名字选。加一种登录方式只写一个策略、use 一次,路由里换个名字,Passport 本身不改。

  • 只有两种算法,而且不会再加。比如发票只分个人和企业抬头,企业多填一个税号。写成接口加两个类加一个选择函数,读代码的人要在四个地方之间跳,才看清“企业要多填税号”;一个 if invoice.is_company 一眼就看完了。
  • 选择逻辑本身才是难点。如果 pick_strategy 要知道每个策略的细节才能选,比如“满减和折扣都能用时,先各算一遍,取减得多的那个;会员日又是另一套顺序”,那么复杂的不是算法,而是选择。把每条规则拆成类之后,这几十行 if 原样留在 pick_strategy 里,什么都没省下。如果选哪个还取决于之前发生过什么(这是第几次下单、上一单退没退款),那是对象在不同状态之间切换,看第 21 章状态。
  • 策略之间要共享大量数据。有的规则看库存,有的看用户历史,有的看优惠预算,接口就会长成 discount(order, user, stock, budget, history),每个策略只用一两个,又回到第 1 章 1.2 参数越来越长的老路。这时要么让策略在创建时拿好自己要的东西,要么承认这些规则本该在一起,不拆。
模式 意图 区别
策略(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 是一整套发送能力,是桥接。

调用外部接口失败后要重试,每次重试前等一会儿。请定义策略接口 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。随机数函数在创建策略时传进去,和满减门槛在创建时传进去是一个道理。

  • 策略把一族可以互换的算法各自装进一个类,上下文只持有它们共同的接口,第 1 章的运费类就是一例。
  • 选哪个策略不会消失,要集中到一处(本章的 pick_strategy);所有调用点从这一处拿,才不会各算各的。新增一条规则是加一个类、登记一行。
  • 策略需要的参数在创建时交给它,不要塞进公共的方法签名;签名一长、参数一有默认值,漏传的调用处就会悄悄算错。
  • 只有一个方法、不需要配置和名字时,传一个函数就够了,sorted(key=...) 天天在这样用;要带配置、要在日志里露名字、有多个方法时再写成类。
  • 和状态的区别看谁来换:调用方挑的是策略,对象自己切的是状态。和模板方法的区别看组合还是继承。