接手过老项目的Java后端都应该有这种体验:当你打开一个订单计算的Service类,往下翻到折扣逻辑那一块,发现一百多行的if-else嵌套,每加一种用户类型或者活动规则就要在这个方法里再插一段,测试的时候还得把老逻辑全部回归一遍。我上个月重构一个电商后台的订单模块时,就遇到了这个场景。今天这篇不聊空泛的原则,直接拉一段真实的“策略 + 工厂设计模式”小实战,把这两者怎么配合、解决什么问题、代码怎么落地说清楚。
这个组合解决的核心问题:把“一个方法是万能判断器”的坏味道,变成“业务规则可独立扩展、调用方无脑选型”的干净结构。适合正在学Java设计模式、准备面试、或者被遗留代码里if-else折磨过的朋友。读完你就能直接照着改你手里的项目。
1. 需求场景:订单折扣模块里那串失控的if-else
1.1 业务需求长什么样
先说清楚我手里的业务。这是一个会员体系的电商项目,订单结算时计算优惠金额。刚开始很简单,按用户类型打折:普通用户95折、VIP用户8折。后来运营加需求,节假日订单打7折,再后来还有满减促销、新人专享价……慢慢就膨胀成了麻烦。
对应的伪代码如下:
public double calculateDiscount(int userType, double totalAmount) { if (userType == 1) { // 普通用户逻辑 return totalAmount * 0.95; } if (userType == 2) { // VIP用户逻辑 return totalAmount * 0.8; } if (userType == 3) { // 节假日逻辑 return totalAmount * 0.7; } // 默认返回原价 return totalAmount; }这种写法在只有两三个分支的时候完全没问题,代码短,也好理解。但需求一旦多起来,这个Service类就开始“膨胀”。我在实际项目里见过一个计算价格的方法,里面的if-else快两百行,条件判断里甚至夹杂着用户等级、订单来源、活动ID、是否首单、是否用了优惠券等等维度。这不是某个新手写出来的,而是一个团队持续往上堆功能堆出来的。
1.2 这段代码的问题到底在哪里
不藏着掖着,直接说问题。
首先,违反了开闭原则。每次新增一种折扣规则,都要打开这个核心计算方法,把else if补上去,改动老代码。改老代码就意味着可能影响已经跑得好好的功能,回归测试的范围被无限拉大。
其次,方法职责过重。一个方法里混入了很多种不同的“计算规则”。从设计角度说,这些规则是相互独立、可以被替换的算法,把它们硬塞进一个方法里,等于把算法实现的细节和调用方的选择逻辑耦合在一起。
第三,可测试性差。想单独测“VIP用户的折扣逻辑”,你得构造各种前置条件让代码走进那个if分支,每次都要带着其他分支一起跑,测试用例写起来别扭。
用一句话总结:问题不在代码书写上,而在结构设计上。当时我加新功能加到吐,决定动手重构。这就是策略 + 工厂模式登场的时候。
2. 策略模式:把“怎么做”与“选择谁来做”拆开
2.1 先看一眼原来的if-else到底在做什么
在动手写策略模式之前,我们得先认清一点:上面那段if-else本质上做了两件事。第一件事是“判断用户类型”,第二件事是“执行对应的折扣算法”。如果把这两件事分开,每一个业务规则就不再是代码里的一小块分支,而是一个独立的对象。
策略模式做的事情就是这样简单:定义一组算法,把每个算法封装成独立类,让它们可以互相替换。为啥要这么做?因为业务的扩展方向是未知的,你也说不准下个月运营又会推出什么新规则。但策略模式能保证:无论未来加多少规则,核心的订单结算方法都不需要被反复修改。
2.2 定义策略接口
定义一个统一的接口,约定好每种策略都要能算出折扣金额。接口是连接“调用方”和“具体算法”的桥梁,依赖接口而不是依赖具体类,这是整个模式的基石。
/** * 折扣策略接口 * 所有折扣规则都要实现这个接口 */ public interface DiscountStrategy { /** * 计算折扣后的金额 * * @param totalAmount 订单原价 * @return 折扣金额 */ double calculateDiscount(double totalAmount); /** * 返回策略类型,用于工厂和调用方识别 */ int getStrategyType(); }这里有两个方法。一个是业务方法,计算折扣金额;一个是元信息方法,用来返回策略的类型编号。为什么需要getStrategyType?因为调用方需要根据用户类型来挑选策略,这个类型编号就是工厂分派时的依据。实战中我用过String类型、Enum类型做这个返回值,都行,但建议与业务字段的类型保持一致,省得调用方再强转。
2.3 落地三个策略类
把原来的三个if分支分别抽取成三个独立类。普通用户的策略:
public class NormalDiscountStrategy implements DiscountStrategy { @Override public double calculateDiscount(double totalAmount) { return totalAmount * 0.95; } @Override public int getStrategyType() { return 1; } }VIP用户的策略:
public class VipDiscountStrategy implements DiscountStrategy { @Override public double calculateDiscount(double totalAmount) { return totalAmount * 0.8; } @Override public int getStrategyType() { return 2; } }节假日的策略:
public class FestivalDiscountStrategy implements DiscountStrategy { @Override public double calculateDiscount(double totalAmount) { return totalAmount * 0.7; } @Override public int getStrategyType() { return 3; } }可能你会觉得这更啰嗦了,一个小小的折扣逻辑拆成了四个文件。别急,这只是策略模式的常规操作,它的价值要配合工厂才好体现。但即使只到这里,我们已经收获了一个好处:每个折扣规则都是独立的类,测试时可以单独new出来单测,不需要关心其他逻辑。
2.4 单独用策略模式依然尴尬的地方
如果只是定义了一堆策略类,那原来的Service里还是要写“根据userType去new哪个实现类”的分支:
if (userType == 1) { strategy = new NormalDiscountStrategy(); } else if (userType == 2) { strategy = new VipDiscountStrategy(); } else if (userType == 3) { strategy = new FestivalDiscountStrategy(); }这其实还是if-else,只是把“计算逻辑”换成了“创建对象的逻辑”。策略模式解决的只是算法封装问题,没有解决选择问题。你依然需要在某个地方去判断到底用哪个策略——而这个“选择逻辑”,才是真正该被统一收编的地方。这也正是工厂模式登场的原因。
3. 工厂模式:给策略找一个统一管家
3.1 工厂模式在组合中扮演什么角色
简单工厂模式的核心思想是:用一个工厂对象来负责创建和提供策略实例,调用方只需要把“想要什么”告诉工厂,工厂把“具体怎么做”的实现类给你。这样“选择逻辑”从业务代码里抽离出来,集中到一个地方。
这样有个很实际的好处:当新增一个策略时,业务调用方代码不需要改动,只需要在工厂里登记一下新策略即可。所有策略实例的管理口径是统一的。
光说不练假把式,直接把工厂代码写出来:
import java.util.HashMap; import java.util.Map; /** * 折扣策略工厂 * 负责持有和提供所有策略实例 */ public class DiscountStrategyFactory { /** * 用一个Map持有所有策略实例 * key是策略类型,value是对应的策略实现 */ private static final Map<Integer, DiscountStrategy> STRATEGY_MAP = new HashMap<>(); /** * 静态块,初始化并注册所有策略 */ static { STRATEGY_MAP.put(1, new NormalDiscountStrategy()); STRATEGY_MAP.put(2, new VipDiscountStrategy()); STRATEGY_MAP.put(3, new FestivalDiscountStrategy()); } /** * 根据类型获取策略 * 如果类型不存在,抛出异常 */ public static DiscountStrategy getStrategy(int userType) { DiscountStrategy strategy = STRATEGY_MAP.get(userType); if (strategy == null) { throw new IllegalArgumentException("未找到对应策略,用户类型为:" + userType); } return strategy; } }注意到没有,这个工厂类做了三件事。一是初始化阶段把所有策略实例都创建好,放进一个Map容器;二是提供一个对外查询方法getStrategy,根据用户类型取出对应的策略;三是维护一个兜底的处理机制——当类型匹配不到策略时,不再默默返回null或者走原价,而是直接抛出异常让你尽早发现问题。这一点很关键,后面我会专门说这个坑。
3.2 客户端代码立刻大变样
原来的订单计算Service,重构之后是这样子的:
public double calculateDiscount(int userType, double totalAmount) { DiscountStrategy strategy = DiscountStrategyFactory.getStrategy(userType); return strategy.calculateDiscount(totalAmount); }整个方法从两百行的if-else嵌套,缩成了两行。两行。
仔细体会一下这个过程:调用方不再关心任何策略类的具体实现,也不再关心策略实例是怎么创建的,它只依赖两样东西——策略接口和工厂。当我们需要调整某个VIP用户折扣率时,只需要去VipDiscountStrategy里面改数值,订单Service不用动;当我们需要增加一种“企业客户策略”时,只需要新增一个类并在工厂里注册,订单Service也不用动。
这就是稳定性的来源。业务在变,核心调用链路的代码却在变少。这在真实项目里有非常大的价值:一行代码都没改过的地方,不会出bug。
3.3 为什么这个场景选“简单工厂”而不是抽象工厂或工厂方法
我知道很多学设计模式的同学会有这个疑问。毕竟23种设计模式里有三种工厂,为什么偏偏选简单工厂?
简单工厂适合的场景:系统内部的策略类数量有限、且结构相似、创建逻辑不复杂。对应到我们的订单场景,三个策略类都是无状态的对象(不存在内部状态,每次调用就根据入参算一遍),它们的创建过程就是一个new,没有复杂的组装逻辑,所以简单工厂足够。
工厂方法模式,适合的场景是“一个策略自身还需要子类去细化”,比如折扣策略还需要分“长期折扣”和“临时折扣”两个子体系,这时候用工厂方法把创建过程下沉到子类,会更合适。抽象工厂模式更重量级,一般用于创建“一组相关的产品对象”,比如为不同肤色的人种创建对应的服装上衣、裤子、鞋子,讲的是产品族概念。我们的需求明显没到这个复杂度。
所以不要被“必须用哪种工厂”束缚住。设计模式是跟你业务匹配,不是让你去套业务。用简单工厂,把Map + 静态方法的结构用熟,绝大多数业务场景都够用了。
3.4 工厂 + 策略组合的本质理解
用一个生活化类比来说:把工厂想成一家外卖平台的调度中心,策略类就是想吃什么菜对应的大厨。点餐时,你只需要告诉平台“我要一份宫保鸡丁”,平台自动找到做这个菜的厨师,把菜做好送到你手里。你不需要亲自去厨房找厨师,也不需要知道他在哪个灶台炒菜。
这样设计后,整个系统的依赖方向非常清晰:
订单Service -> 折扣策略接口 -> 具体策略实现类(由工厂提供)业务代码依赖抽象接口,具体策略类被工厂管理。抽象和实现彻底解耦。这也是面向对象设计中“依赖倒置”原则的一个非常典型的实战案例。
4. 进阶玩法:策略枚举、Spring容器与兜底策略
4.1 策略枚举:把类型和策略绑定得更清晰
当业务规则逐渐增多,工厂里那堆put会变得又臭又长。我后来做另一个项目时改用枚举来管理策略类型。枚举可以把类型编号、策略实例和说明绑在一起,可读性强很多。改造后的写法如下:
public enum DiscountType { NORMAL_USER(1, new NormalDiscountStrategy(), "普通用户折扣"), VIP_USER(2, new VipDiscountStrategy(), "VIP用户折扣"), FESTIVAL(3, new FestivalDiscountStrategy(), "节假日活动折扣"); private final int type; private final DiscountStrategy strategy; private final String description; DiscountType(int type, DiscountStrategy strategy, String description) { this.type = type; this.strategy = strategy; this.description = description; } public static DiscountStrategy getStrategy(int userType) { for (DiscountType discountType : DiscountType.values()) { if (discountType.type == userType) { return discountType.strategy; } } throw new IllegalArgumentException("未匹配到策略,用户类型为:" + userType); } }使用时的代码也变得更干净了,而且枚举的遍历查找效率考虑一下——如果策略数量非常多,枚举遍历有一定的性能损耗,但几十个以内无所谓。我在实际项目中一般用枚举 + 内部Map缓存的方式,把遍历变成O(1)的查询:
private static final Map<Integer, DiscountStrategy> STRATEGY_MAP = new HashMap<>(); static { for (DiscountType type : DiscountType.values()) { STRATEGY_MAP.put(type.type, type.strategy); } } public static DiscountStrategy getStrategy(int userType) { return STRATEGY_MAP.get(userType); }这段代码兼顾了枚举的可读性和Map的查询效率。
4.2 与Spring容器结合:从手动注册到自动收集
如果你的项目用的是Spring框架,策略 + 工厂的组合简直天生为Spring准备的。只要把策略实现类都注册成Spring Bean,再利用Spring的依赖注入能力,自动收集容器里所有策略Bean,工厂就几乎不用手动维护了。
首先,每个策略实现类标注@Component注解:
@Component public class VipDiscountStrategy implements DiscountStrategy { // 实现代码同上 }然后工厂改造为Spring管理下的组件:
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import javax.annotation.PostConstruct; import java.util.HashMap; import java.util.List; import java.util.Map; @Component public class SpringDiscountStrategyFactory { @Autowired private List<DiscountStrategy> strategyList; private static final Map<Integer, DiscountStrategy> STRATEGY_MAP = new HashMap<>(); @PostConstruct public void init() { // 容器启动时,自动把所有策略Bean收集到集合中 for (DiscountStrategy strategy : strategyList) { STRATEGY_MAP.put(strategy.getStrategyType(), strategy); } } public static DiscountStrategy getStrategy(int userType) { return STRATEGY_MAP.get(userType); } }这个版本的好处是完全实现了开闭原则。以后新增策略的时候,只需要新建一个类,标注@Component,实现接口方法,Spring容器会自动把它加入策略集合。工厂类不需要改动,调用方不需要改动,老策略也不用改动。
Spring版本还有个隐藏好处:策略类内部可以注入其他Service了。因为不需要手动new策略对象,而是由Spring容器统一管理Bean的生命周期,策略类中如果需要调用订单查询服务、优惠券服务等,直接使用@Autowired注入即可。手动new的策略类做不到这一点,这是很多新手用策略+工厂模式跑到一半才发现的问题——策略类想依赖别的服务却拿不到,最后只能塞一堆参数进去。
4.3 兜底与降级策略
讲一个真实场景。当时我们上线了一个新渠道,但产品那边在配置中心漏配了用户类型,结果线上用户请求进来,工厂的Map里查不到对应策略,系统直接抛异常。虽然异常信息很明确,但用户直接看到了报错页,体验很糟糕。
所以我在后来的工厂设计中,永远会加一个兜底策略。当类型匹配不到时,不做异常抛出,而是返回一个默认实施的策略,比如按原价处理,或按普通用户折扣处理:
public static DiscountStrategy getStrategy(int userType) { DiscountStrategy strategy = STRATEGY_MAP.get(userType); if (strategy == null) { // 兜底:返回普通用户策略 return new NormalDiscountStrategy(); } return strategy; }有人可能会问:兜底策略会不会掩盖配置错误?我的经验是分阶段处理。在开发测试环境,工厂查不到策略就应该直接抛异常,让你尽早发现问题;在生产环境,如果不想让用户看到报错页,可以走兜底策略,同时把未匹配到的type信息打一条error日志,方便排查。异常和兜底不是互斥的,而是不同环境下的不同策略。这个心得希望大家在实际项目里用上。
5. 实战中容易踩的坑与我的一些取舍标准
5.1 工厂Map注册漏配是最常见的低级坑
这个坑我见了不少次。有人把策略类都写好了,结果忘了在工厂的static块里put进去,运行时查不到策略才反应过来。此时如果工厂没有兜底逻辑,线上就是一片500报错。
解决的方法有几种。一是在工厂的static块里,初始化完成后进行一次完整性校验,比如用断言检查每个策略类型都有对应的实例注册;二是在Spring容器环境下,通过@PostConstruct方法遍历策略List,再对比业务配置中声明的策略类型集合,缺少的从第一步就报出来。这属于“启动时错误尽早暴露”的思路。
我在团队里是这么要求的:工厂是策略的唯一管理入口,注册操作必须和策略类放在一起维护。如果用Spring,直接靠自动收集;如果用手动Map,那么新增策略类的代码审查里必须检查工厂注册项。靠人盯不靠谱,最好加入CI规则或者代码注释提醒。
5.2 策略粒度分裂容易导致类爆炸
有一类同学是学了策略模式之后,恨不得把所有if-else都拆成策略类,结果一个简单项目拆出几十个类,每个类里就一行代码。这种“拆”完全没有带来任何收益,反而让项目结构变得散碎,导航成本直线上升。
我个人的判断标准是一个“三问”:
- 这个分支逻辑未来一年内是否会频繁扩展?
- 每个分支是不是都有自己独立的业务规则?
- 分支之间是否相互独立、不共享状态?
三个问题中至少有两个回答“是”,才值得拆成策略。如果只是三五个永恒不变的分支,用if-else挺好,别为了设计模式而设计模式。退一步说,一个方法三十行和一个方法三十行但分散在十个类里,后者未必就更优秀。设计模式的本质是解决变化和复杂度的,复杂度不够时它就是负资产。
5.3 与模板方法模式、状态模式的边界
写策略模式的时候,经常会混淆它和其他行为型模式。我用大白话区分一下:
策略模式解决的问题是“算法的选择”,同一个动作(比如计算折扣)有多个算法实现,由调用方选一个用。
模板方法模式解决的问题是“算法的骨架固定,部分步骤可变化”。比如订单结算的流程是:校验商品 -> 计算价格 -> 扣减库存 -> 生成订单。这个流程是固定的,但中间的“计算价格”这个步骤可以让子类重写。这种场景不要用策略模式去套,因为整体流程是无法替换的,只能替换其中一段。
状态模式解决的是“对象的行为随着内部状态变化而变化”。比如订单有已创建、已支付、已发货等状态,每个状态下执行操作的行为不同。这跟策略模式很相似,但状态模式是“当前状态决定行为,状态能自动切换”,策略模式则是“调用方主动指定算法”。业务里订单状态机这种场景,更适合状态模式。
搞不清这些边界,代码就会越写越变扭。我建议初学者先把策略 + 工厂组合练熟,它是最常用、收益最明显的组合,然后再慢慢拓展到其他模式。
5.4 一个稍微不常规的扩展思路:策略 + 责任链
如果业务再复杂一点,比如订单价格不是只套一种折扣,而是VIP折扣、满减、优惠券叠加计算,那么策略模式单打独斗就不够用了。我在最新的项目中把策略 + 责任链做了组合:每个策略类内部维护一个“下一个处理器”,多个策略按优先级串成一条链,链路串完,价格就计算好了。
这种结构的核心是:策略的边界是算法,责任链的边界是流程的顺序。它们解决的不是同一层问题,完全可以嵌套使用。如果你现在面对的恰好是“多个折扣叠加”的需求,可以考虑这个演进方向。不过别急着上,先把手里的单策略场景改扎实了再提。
5.5 最后聊聊代码里最值得留意的细节
工厂类如果有状态、或者策略类有内部可变状态,并发环境下容易出问题。我这里的折扣策略是无状态的,每次调用只依赖入参,所以可以放心地把实例放在共享Map里让所有线程共用。如果策略实现里用了成员变量保存每次计算的结果,那就要小心线程安全。
还有一点,工厂方法返回的实例,不要在调用方里面随便转型成具体类。Stack Overflow上有一堆代码教你getStrategy后强转成VipDiscountStrategy再调独有的方法。这样做等于绕过了抽象,让调用方依赖了实现细节,工厂的存在就失去了一半意义。如果发现策略接口表达不了某种需求,优先考虑改造接口,而不是在调用方做强转。
这些细节不会马上出bug,但它决定了这个设计能不能长久地支撑业务变化。如果一开始就把接口设计好,后续维护的人会很舒服;如果接口设计得不够用,他们就会绕开接口直接操作实现类,模式很快就名存实亡。
再说回文章开头的那个订单模块。那次重构后,后续三个月里产品又加了两个新的用户等级折扣规则。我们团队的开发同学只需要新建两个类、注册到工厂,订单结算的核心方法一行没改过。对应的单测也只新增了针对策略类的用例,老的测试完全不受影响。这种“代码不被频繁改动”的安全感,是设计模式带给我的最直观价值。
如果你正准备在自己的项目里落地这套组合,我的建议是:从一个你最头疼的、有着明显if-else计算逻辑的地方入手,别多,先选一个方法试试。按策略接口 -> 策略实现类 -> 工厂类 -> 客户端改造的顺序来走,每走一步都要保证代码能编译、能运行。改动完观察一段时间,感受一下新增需求时的效率变化,再决定要不要继续推广到其他模块。这样你也能对“为什么需要策略加工厂”有切身的体会,而不仅仅停留在背概念、看例子的层面。