ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

设计模式之策略模式:从if-else重构到生产级代码示例

设计模式之策略模式:从if-else重构到生产级代码示例 1. 被一段八百行的 if-else 逼到重构现场先说一个我自己经手的场景。几年前我接手一个电商结算模块最初需求特别朴素下单时支持两种优惠一种是新人立减另一种是满减。写这段逻辑的同事用了最直接的办法一个calcDiscount方法里嵌套两层if二十几行就搞定了。半年之后营销团队开始发力会员折扣、限时秒杀价、阶梯满减、优惠券核销、积分抵扣、渠道专属价、组合套餐价陆续进场同一个方法膨胀到八百多行里面塞了十几个分支每个分支里还藏着各自的边界判断和小数点处理。那段时间我把网上讲策略模式的文章翻了个遍发现大多数内容停留在「简介 / 适用场景 / 优缺点 / 一段 Hello World 级别的代码示例」这个四件套上读完你会点头但回到工位打开 IDE还是不知道第一刀该切在哪里。所以这篇我想换个写法不从定义开始从一段真实的重构过程开始把设计模式里最常被提起、也最常被用歪的策略模式拆开揉碎配上能直接抄走的完整代码示例顺带把「策略模式到底该不该配简单工厂」「C 和 Java 写起来差在哪」这些经常在评论区打架的问题一并说清楚。如果你现在手里正有一段长得离谱的条件分支、正被「加一个渠道就要改一次核心方法」折磨或者你在准备面试、做大作业需要一份讲得明白的材料这篇内容应该对你有用。不讲玄学只讲我改过、跑过、被线上问题教育过的那部分。1.1 需求是怎么从两种折扣长成七种的先复盘一下膨胀过程因为理解「为什么会失控」比记住定义重要得多。第一版代码大致是这样public BigDecimal calcDiscount(BigDecimal price, String type) { if (NEW_USER.equals(type)) { return price.subtract(new BigDecimal(10)); } else if (FULL_REDUCE.equals(type)) { if (price.compareTo(new BigDecimal(200)) 0) { return price.subtract(new BigDecimal(30)); } return price; } return price; }这段代码在每个阶段都是「合理」的。加会员折扣的时候不过是在后面再接一个else if加秒杀价的时候再加一个。问题不在于某一次修改有多难而在于每次修改都要重新读一遍整个方法。你要确认新增的分支不会和上面分支的条件重叠要确认价格计算顺序没被改变要确认没有某个分支提前return导致后面的逻辑被跳过。当方法长到八百行一次改动需要读八百行代码这个阅读成本会随着分支数量线性甚至指数上升。更隐蔽的问题是逻辑耦合。满减的边界判断满 200 减 30、满 500 减 100和秒杀的时间窗口判断只在活动时段生效本质上是两件事但它们被塞在同一个方法体里共享局部变量共享返回路径。某次上线后我发现「满减叠加会员折扣」算出负数排查了两个小时最后定位到会员折扣分支里漏了一个Math.max(0, ...)而满减分支里有。这种不一致在长分支里几乎是必然出现的——因为人类没法同时记住七个分支的所有细节。1.2 继续加 if 的代价很多人低估了有人会说加分支就加分支能跑就行。短期确实能跑但代价会在三个地方集中爆发。第一个是测试成本。每加一个分支回归测试的组合数就翻倍。七个折扣类型两两组合就是二十一种情况如果还涉及活动时段、用户等级这些维度用例数量会失控。而如果每种折扣是独立的类你只需要为每个类写独立的单元测试互不干扰。第二个是协作成本。多个开发同时改这一个方法冲突是家常便饭。Git 合并冲突本身不难解决难的是解决完之后没人能确认语义是否正确。第三个是复用成本。某个业务方提出「把这个折扣算法用在另一个下单入口」你会发现这个方法绑定了当前的参数结构和返回值类型抽不出来。算法被数据和调用方式焊死了。这三个代价叠加起来就是策略模式真正要解决的问题把「变化的算法」从「不变的流程」里剥离出来让它们各自独立演化。注意这句话里的关键词是「变化的算法」和「不变的流程」而不是「消除 if-else」。很多人把策略模式当成 if-else 清除器这是后面所有误用的源头。2. 策略模式封装的到底是什么角色、契约与一次完整调用GoF 对策略模式的原始定义是定义一系列算法把它们一个个封装起来并且使它们可以相互替换使得算法可以独立于使用它的客户而变化。定义很精炼但信息量集中在「封装」和「可相互替换」这两个短语上。封装好理解难的是后半句——什么叫可相互替换可相互替换意味着所有策略必须对外呈现完全一致的契约同样的输入含义、同样的输出含义、同样的异常语义。如果你写的两个「策略」一个返回含税价、一个返回不含税价那它们就不是可相互替换的调用方没法在不了解实现细节的前提下切换它们。这是判断一次重构是否成功的最硬标准比类名起得好不好、包结构漂不漂亮重要得多。2.1 Context、Strategy、ConcreteStrategy 三件套的职责边界标准结构里通常讲三个角色但实际落地时我会把它们理解成四块职责因为「谁负责选策略」这件事经常被含糊过去。Strategy抽象策略一个接口或抽象类声明所有具体策略必须实现的方法。它的价值在于定义契约同时充当编译期的类型约束。这里有个经验接口方法签名尽量只暴露「必要的输入」不要把整个订单对象、整个请求上下文都塞进去。塞得越多策略之间的耦合越隐蔽可测试性越差。ConcreteStrategy具体策略每个具体算法一个类只关心自己的计算逻辑不关心自己被谁调用、什么时候被调用。理想状态下这个类应该是「纯函数式」的——同样的输入必然产生同样的输出不依赖外部可变状态。Context上下文持有策略引用负责在合适的时机把请求委派给策略。它不关心算法怎么算只关心「调用哪个」和「什么时候调用」。很多实现把 Context 写成一个大而全的类既管选策略又管算价格还管写日志这是最常见的误区。Context 的职责应该尽量薄。策略的选择逻辑这一块在很多教程里被省略了实践中却最容易出问题。谁来根据业务类型选出对应的策略如果调用方自己if-else选那你只是把 if-else 从算法内部搬到了外面没解决问题。这部分通常交给工厂、注册表或者依赖注入容器第 5 章会专门讲。2.2 一次调用的完整走查把上面的角色串起来一次典型调用的链路是这样的调用方比如订单服务拿到业务标识例如渠道号channelAPP、优惠类型discountTypeMEMBER调用方把这个标识交给策略选择器工厂 / 注册表拿回一个DiscountStrategy实例调用方把业务数据原价、订单信息传给策略的apply方法策略内部完成计算返回结果调用方对返回值做统一的后续处理比如写入订单明细、打日志。这条链路里有几个值得注意的点。第一调用方完全不认识MemberDiscount这个类它只认识DiscountStrategy接口这意味着新增一种折扣对调用方是零改动的。第二策略实例通常是无状态的可以缓存复用这意味着选择器可以返回同一个单例避免每次请求都 new 一个对象。第三第 5 步的统一处理由 Context 负责这是策略模式带来的额外红利所有折扣的共同逻辑比如结果不能为负、结果要保留两位小数只写一遍不会像 if-else 版本那样在每个分支里重复。我见过一些项目把 Context 层省掉调用方直接拿到策略对象就调用短期看着简洁长期就会退化成「散弹式修改」——某天要求所有折扣结果统一加日志你得改七个地方。多一个薄薄的 Context换来的是横切逻辑的统一入口这笔账是划算的。3. 什么时候该动刀什么时候别硬套判断该不该用策略模式我总结出一套比较土但很准的信号清单。它不是理论推导出来的是被代码 review 和线上故障喂出来的。3.1 出现这五个信号基本可以考虑重构信号一同一个方法内出现三层以上的条件嵌套且每个分支处理的业务语义明显不同。如果分支之间只是数值不同比如「满 100 减 10」「满 200 减 20」那用配置表或者数据驱动就够了不值得上策略模式。只有当分支代表的是不同的业务规则时拆成类才有意义。信号二分支数量在过去三个月内增长过。如果一个方法的分支数量在稳定增长说明业务本身就在多维度演化把算法独立出去是顺应趋势。反过来如果一个方法写完就没再动过那它就让它待着。信号三某个分支的代码行数是其他分支的五倍以上。这种「重量级分支」会稀释整体的可读性把它单独抽成类主流程立刻清爽。我上面提到的会员折扣叠加秒杀价的复杂判断就属于这一类。信号四需要为不同分支写不同的单元测试但测试之间互相污染。比如为了测秒杀逻辑你必须先构造一个满减的场景因为共享了局部变量。这种情况说明算法的边界已经被破坏拆分是必然。信号五算法需要在运行时动态切换而不是编译期确定。这是最典型的场景——同一个订单用户可以选择用优惠券还是用积分抵扣同一个网关请求根据内容类型选择不同的序列化器同一个线程池根据队列饱和程度选择不同的拒绝策略。这类「运行期选行为」的需求策略模式几乎是天然解法。3.2 这三种情况别硬上策略模式情况一分支数量固定且极少且没有扩展预期。三种以内的固定分支写一个switch配合清晰注释可读性不比七个类加一个工厂差反而更省代码。为「可能有的扩展」提前设计是典型的过度设计。情况二算法之间需要共享大量中间状态。如果几个算法在计算过程中要互相读取对方的中间结果、要频繁通信那它们本质上是一个协作的整体硬拆成策略会导致你不得不在接口上开一堆口子反而更乱。这种情况考虑状态模式或者把整个流程建模成一个步骤链。情况三简单的查表就能解决。折扣规则如果是「满 X 减 Y」这种纯参数化的东西直接存一张配置表加一个公式引擎就够了写七个策略类是自找麻烦。策略模式适合「算法逻辑本身不同」的场景不适合「参数不同」的场景。这里有个判断小技巧我个人常用把每个分支用一句话描述出来如果这些话的句式相同、只是名词不同那大概率是数据差异用配置如果句式完全不同、逻辑结构不同那才是算法差异值得用策略模式。比如「满 200 减 30」和「满 500 减 100」句式相同属于数据而「会员按等级线性打折」和「秒杀按库存递减定价」句式不同属于算法。4. Java 实现四次演进从最土写到能进生产下面这部分是重头戏我按演进顺序给出四个版本每个版本都说明它解决了什么问题、又留下了什么坑。整个过程围绕同一个业务场景订单折扣计算折扣类型包括新人立减、满减、会员折扣。4.1 第一版if-else 直写作为对照组public BigDecimal calc(String discountType, BigDecimal price, int memberLevel) { if (NEW_USER.equals(discountType)) { return price.subtract(new BigDecimal(10)).max(BigDecimal.ZERO); } if (FULL_REDUCE.equals(discountType)) { if (price.compareTo(new BigDecimal(200)) 0) { return price.subtract(new BigDecimal(30)); } return price; } if (MEMBER.equals(discountType)) { BigDecimal rate BigDecimal.valueOf(1).subtract( BigDecimal.valueOf(memberLevel).multiply(new BigDecimal(0.02))); return price.multiply(rate).setScale(2, RoundingMode.HALF_UP); } return price; }这段代码不算差甚至比很多真实项目里的版本干净。它的问题在于memberLevel这个参数只有会员分支用得上但所有分支都被迫接受它折扣类型用字符串硬编码写错一个字母要等运行时才发现三种折扣的返回结果精度处理方式不统一新人立减没有setScale会员折扣有。最后一点在我改这个模块的时候就埋过一个精度相关的线上问题——金额少了一分钱财务对账对不上排查了半天。4.2 第二版接口加具体策略类标准写法public interface DiscountStrategy { String code(); BigDecimal apply(DiscountContext ctx); }public class DiscountContext { private final BigDecimal originalPrice; private final int memberLevel; // 构造方法、getter 省略 }public class MemberDiscount implements DiscountStrategy { Override public String code() { return MEMBER; } Override public BigDecimal apply(DiscountContext ctx) { BigDecimal rate BigDecimal.ONE.subtract( BigDecimal.valueOf(ctx.getMemberLevel()).multiply(new BigDecimal(0.02))); return ctx.getOriginalPrice().multiply(rate) .setScale(2, RoundingMode.HALF_UP); } }这里有个设计决策值得展开参数传递用了一个DiscountContext包装类而不是直接在接口方法上堆参数。原因很简单策略数量一多参数就会膨胀——会员折扣要等级、秒杀要活动时间、优惠券要券信息如果都写在方法签名上接口会变成一个巨型参数列表每加一种策略都要改接口所有实现类跟着改。用一个上下文对象携带数据接口签名就冻结了。但这么做有个前提上下文对象里的字段必须都是只读的。我用final修饰并且不提供 setter就是防止某个策略偷偷修改上下文影响后续策略的计算。策略之间不应该有任何隐式通信这是铁律。code()方法是个小技巧给每个策略一个稳定的字符串标识方便后续注册表按 code 查找避免用getClass().getSimpleName()这种反射方式——后者在类重命名时会静默失效。4.3 第三版Java 8 之后用枚举加函数式写法减负策略类数量多了之后七个类各自成立文件包结构会很碎。如果某个策略的逻辑只有三五行为它单独建一个类有点重。Java 8 之后可以用枚举或者Map类型, 函数的方式压缩体积。public enum DiscountStrategyEnum { NEW_USER(NEW_USER, (price, level) - price.subtract(new BigDecimal(10)).max(BigDecimal.ZERO)), FULL_REDUCE(FULL_REDUCE, (price, level) - price.compareTo(new BigDecimal(200)) 0 ? price.subtract(new BigDecimal(30)) : price), MEMBER(MEMBER, (price, level) - price.multiply(BigDecimal.ONE.subtract( BigDecimal.valueOf(level).multiply(new BigDecimal(0.02)))) .setScale(2, RoundingMode.HALF_UP)); private final String code; private final BiFunctionBigDecimal, Integer, BigDecimal func; DiscountStrategyEnum(String code, BiFunctionBigDecimal, Integer, BigDecimal func) { this.code code; this.func func; } public BigDecimal apply(BigDecimal price, int level) { return func.apply(price, level); } public static DiscountStrategyEnum of(String code) { for (DiscountStrategyEnum e : values()) { if (e.code.equals(code)) return e; } throw new IllegalArgumentException(unknown discount code: code); } }这个版本的优势是集中、紧凑of方法顺带解决了「根据字符串找策略」的问题并且对未知 code 明确抛异常而不是静默返回原价——静默兜底是我最反对的做法之一它会让配置错误在生产环境悄无声息地造成资损。劣势也很明显所有逻辑挤在一个枚举里稍微复杂一点的方法体就会让枚举文件变长而且它把「策略」和「选择」耦合在了一起如果策略需要注入 Spring 的依赖比如查数据库、调远程服务枚举写法就不太合适了。我的经验是无状态的纯计算策略用枚举或函数式需要依赖注入或者逻辑超过三十行的策略用独立的类。混着用完全没问题不要追求形式统一。4.4 第四版注册表把选择逻辑彻底从调用方拿走真正进生产的版本通常还要再加一层注册表让调用方连「有哪些策略」都不需要知道。public final class DiscountStrategyRegistry { private static final MapString, DiscountStrategy MAP new HashMap(); static { register(new NewUserDiscount()); register(new FullReduceDiscount()); register(new MemberDiscount()); } private DiscountStrategyRegistry() {} private static void register(DiscountStrategy strategy) { MAP.put(strategy.code(), strategy); } public static DiscountStrategy get(String code) { DiscountStrategy s MAP.get(code); if (s null) { throw new IllegalArgumentException(no strategy for code: code); } return s; } }调用方变成一行BigDecimal finalPrice DiscountStrategyRegistry.get(dto.getDiscountType()) .apply(context);到这一步新增一种折扣需要做的事情只有两件写一个实现类在静态块里注册一行。调用方、上下文、其他策略全都不用动。这就是开闭原则在这个场景下的具体落地——对扩展开放对修改关闭。注意我用的是「具体落地」而不是空谈原则因为很多文章讲开闭原则时只讲概念你听完还是不知道那一行注册代码该写在哪。5. 策略模式配简单工厂那个被问烂了的问题热词里有个高频问题策略模式和简单工厂到底什么关系能不能一起用。我的回答是它们解决的是两个正交的问题经常一起出现但完全可以各用各的。5.1 两者解决的问题根本不同策略模式解决的是「行为怎么组织和替换」——它关心的是把多个算法抽象成同一个契约让调用方能不关心具体实现。简单工厂解决的是「对象怎么创建」——它关心的是把new的过程收敛到一个地方调用方只传参数拿实例。为什么它们总是同时出现因为策略模式落地时会自然产生一个需求从字符串标识到策略实例的映射。如果不做这层映射调用方就得自己写if-else去 new 对应策略等于把问题搬了个家。而「根据参数创建对象」恰好就是简单工厂的职责。所以严格来说第 4.4 节的DiscountStrategyRegistry就是一个带缓存的简单工厂。叫它注册表是因为它采用「主动注册」的方式比传统的switch式工厂更符合开闭原则——新增策略不需要改工厂方法。5.2 一个容易踩的坑把复杂创建逻辑塞进工厂我见过有人为了「统一」把折扣的资格校验、库存扣减、日志记录全都塞进工厂方法里理由是「反正都在一个地方」。结果工厂从二十行变成三百行变成一个更难维护的上帝类。工厂只做一件事根据标识返回实例。资格校验属于策略内部的职责或者属于 Context 的前置校验不该混进创建逻辑。判断标准很简单如果工厂方法里出现了业务规则判断比如「该用户是否符合该折扣的使用条件」那就越界了。还有一种情况需要处理策略需要按优先级组合执行。比如「先满减再会员折扣」这不是选一个而是依次执行多个。这种场景我会用一段独立的组合逻辑来处理public BigDecimal applyAll(ListString codes, DiscountContext ctx) { BigDecimal price ctx.getOriginalPrice(); for (String code : codes) { DiscountContext step ctx.withPrice(price); price DiscountStrategyRegistry.get(code).apply(step); } return price; }这里每次迭代都用当前价格生成一个新的上下文避免策略之间通过上下文产生隐式耦合。顺序由调用方传入的codes列表决定明确、可测、可配置。如果顺序规则本身很复杂那就要考虑用责任链模式了那是另一个话题。6. C 里的写法差异虚函数、智能指针与 std::function同样的思路换到 C细节差别不小。C 没有统一的依赖注入容器没有注解对象生命周期要手动管理所以写法更「赤裸」但也更灵活。6.1 传统虚函数加智能指针版本class DiscountStrategy { public: virtual ~DiscountStrategy() default; virtual std::string code() const 0; virtual double apply(const DiscountContext ctx) const 0; }; class MemberDiscount : public DiscountStrategy { public: std::string code() const override { return MEMBER; } double apply(const DiscountContext ctx) const override { double rate 1.0 - ctx.memberLevel * 0.02; return std::round(ctx.originalPrice * rate * 100) / 100.0; } };值得注意的几点。第一基类析构函数必须声明为虚函数否则通过基类指针删除派生类对象时行为未定义这是 C 里最经典的坑之一。第二接口方法声明为const因为策略本身不应该修改自身状态这个约束能在编译期帮你挡住一部分误用。第三用std::unique_ptrDiscountStrategy管理生命周期避免手工delete。上下文持有策略的写法class PriceCalculator { public: explicit PriceCalculator(std::unique_ptrDiscountStrategy s) : strategy_(std::move(s)) {} double calc(const DiscountContext ctx) const { return strategy_-apply(ctx); } private: std::unique_ptrDiscountStrategy strategy_; };这里有两个细节。explicit防止隐式转换构造std::move明确所有权转移语义。如果你的策略是无状态的、可以在多个计算器之间共享那就改用std::shared_ptrconst DiscountStrategy语义上更准确。6.2 更轻的写法std::function 与 lambda对于无状态的纯计算策略C 用std::function会比继承体系简洁得多using DiscountFn std::functiondouble(const DiscountContext); std::unordered_mapstd::string, DiscountFn registry { {NEW_USER, [](const DiscountContext c) { return std::max(0.0, c.originalPrice - 10.0); }}, {MEMBER, [](const DiscountContext c) { double rate 1.0 - c.memberLevel * 0.02; return std::round(c.originalPrice * rate * 100) / 100.0; }}, };这种写法的开销在于std::function的类型擦除会有轻微的性能损耗通常是一次间接调用加可能的堆分配在高频路径上要留意。如果是每秒百万次调用的核心计算优先用虚函数或者模板策略把策略类型作为模板参数来消除运行时开销。模板策略会把策略在编译期绑定失去运行时切换能力所以只适用于编译期就确定的场景。另外补充一点C 里做金额计算千万别用double。上面为了简洁用了double实际项目应该用整数分或者定点数类型否则精度问题和 Java 用double算钱是一个下场。这是我用真实资损换来的教训。7. 优缺点摊开算账别被教程糊弄讲优点的地方太多我重点说说代价因为代价才是决定你要不要用的依据。7.1 优点哪些是真的哪些被夸大了优点是否名副其实说明消除多重条件判断部分成立只消除调用方的选择判断内部逻辑判断依然存在只是被分散到各策略类中算法可自由替换成立前提是所有策略契约一致这是设计上的硬要求符合开闭原则成立新增策略不改已有代码但需要在注册表加一行严格说不是零修改提高可测试性成立每个策略独立成类可以单独 mock 依赖、单独断言这是最实在的收益复用性提升视情况只有在策略本身被多个场景调用时才有价值否则只是搬了地方「消除多重条件判断」这条被夸大得最厉害。策略模式并没有消灭条件判断它只是把「选择哪个算法」的判断从核心业务方法里挪到了注册表或工厂里。真正的收益在于职责分离而不是「没有 if」。7.2 代价三条必须算清的账类数量膨胀。七种折扣七个类加上接口、上下文、注册表一共十个文件。对于小项目这个文件数量本身就是维护负担。缓解办法是前面讲的枚举和函数式写法但那只适用于简单策略。调用方需要知道策略标识。调用方虽然不认识策略类但必须认识策略的 code。这意味着 code 的命名变成了一种需要长期维护的契约改名要同步改配置、改数据库字段。我建议把 code 定义成枚举常量并且写单元测试校验唯一性避免出现两个策略用同一个 code 导致注册时互相覆盖——这种 bug 极其隐蔽注册表用的是put后注册的会静默覆盖先注册的。策略与上下文的通信成本。如果某个策略需要的数据特别多、特别杂上下文对象会变成一个什么字段都往里塞的「杂物箱」最终失去封装意义。这时候要反思是不是这个策略和其他策略本质上不属于同一类问题硬塞进一个体系是错的。7.3 和简单工厂、状态模式的区别维度策略模式简单工厂状态模式解决的问题算法的组织与替换对象的创建状态驱动的行为迁移关注点行为创建状态流转是否感知彼此策略之间互不感知不涉及状态之间知道下一个状态典型触发方式调用方显式选择调用方传入参数内部条件自动迁移常见误用用它消除所有 if把业务逻辑塞进工厂用策略模式硬扛状态流转策略模式和状态模式的代码结构非常像几乎是一模一样的类图区别在于状态之间的迁移是否由状态自己驱动。订单状态从「待支付」到「已支付」这个迁移是支付成功这个事件触发的而且状态对象通常需要知道下一个状态是谁而折扣策略之间没有先后关系选哪个完全由外部决定。分不清这两者的人往往会把状态流转硬写成策略然后在策略内部塞一堆「如果当前是 A 状态就返回 B 策略」这就走歪了。8. 六个真正踩过才知道的坑8.1 策略类数量失控从七个涨到四十个最常见的失控方式是「一个变体一个类」。渠道 A 的满减、渠道 B 的满减、渠道 A 的会员折扣、渠道 B 的会员折扣……本来三个算法因为渠道差异被复制成了六个类逻辑几乎一样只是某个阈值不同。正确的做法是把差异参数化策略只保留算法的结构差异把数值差异通过配置注入。渠道 A 和渠道 B 的满减如果只是阈值不同那就应该是一个FullReduceDiscount类接受不同的配置对象。判断标准是如果两个类的方法体逐行相同、只有常量不同那就是复制粘贴不是策略模式。8.2 上下文对象变成参数垃圾桶缓解办法是按场景拆分上下文比如PriceContext只放金额相关字段如果某个策略需要额外的信息比如活动信息让它自己通过依赖注入去拿而不是往上下文里加字段。上下文应该是「所有策略都需要的公共数据」而不是「某个策略碰巧需要的数据」。8.3 有状态策略引发的并发问题如果策略对象持有可变状态比如统计调用次数、缓存中间结果那它就不能被多线程共享。我遇到过有人为了「性能优化」在策略里加了一个HashMap做缓存结果在并发环境下偶发死循环排查了一整天。原则很简单策略对象要么设计成严格无状态的单例要么每次调用新建。我倾向于前者因为无状态对象天然可共享、可缓存、可测试。如果真的需要缓存把缓存放到策略外面的独立组件里用线程安全的实现。8.4 Spring 环境下怎么自动收集策略Spring 有个特性特别好用如果你向构造函数注入MapString, SomeInterface容器会自动把所有实现类的 bean 按 bean 名称作为 key 注入进来。这意味着你不用手写注册表Component public class DiscountStrategyResolver { private final MapString, DiscountStrategy strategies; public DiscountStrategyResolver(MapString, DiscountStrategy strategies) { this.strategies strategies; } public DiscountStrategy resolve(String code) { DiscountStrategy s strategies.get(code); if (s null) { throw new IllegalArgumentException(no strategy: code); } return s; } }只要把每个策略类用Component(MEMBER)命名或者让 bean 名和 code 保持一致即可。这个写法我在多个项目里用过省掉了手写注册表的样板代码。要注意的是 bean 名称默认是类名首字母小写如果你想用业务 code 作为 key得显式指定。另外要提防 bean 名称冲突两个类如果重名会被容器覆盖最好在启动时加个校验。8.5 未知标识要报错不要静默兜底我在 8.4 的示例里用的是抛异常。有人会问能不能返回一个「默认策略」让流程不中断我的建议是对于配置驱动的标识未知值必须报错。因为未知标识通常意味着配置写错或者代码和配置不同步这是一个必须暴露的问题。静默兜底会让资金计算错误在几周后才被发现那时候损失已经产生了。如果确实需要一个「不做任何处理」的策略比如没有优惠那就显式注册一个NONE策略让它在注册表里占一个位置而不是把它当成「找不到就原价返回」的兜底逻辑。显式优于隐式这条在资金相关的代码里没有例外。8.6 单元测试怎么写才划算每个策略类单独测试重点覆盖三类用例正常输入、边界值金额刚好等于阈值、金额为零、金额为负、精度是否按预期舍入。注册表也要单独测验证所有已知 code 都能取到非空策略、验证未知 code 会抛异常、验证没有重复 code。我额外会写一个「全量遍历测试」遍历所有策略 code用一组固定输入跑一遍断言结果不为负、不抛异常。这种测试不能验证业务正确性但能挡住「新加的策略把金额算成负数」这类低级事故成本极低收益很高。最后一个心得关于 code 的唯一性注册表用Map存的时候如果两个策略用了同一个 code后面的会覆盖前面的而且不报错。我会在静态注册的最后加一行断言检查MAP.size()是否等于注册次数。看着有点土但它真的帮我抓过一次复制粘贴导致的 code 重复。策略模式这东西用对了是手术刀用错了就是给自己加了七个需要维护的文件。判断标准始终是那句话变化的算法是不是真的在变契约是不是真的能统一。这两条成立放心用不成立老老实实写 if 反而更省心。
返回列表