如果你写过几年Java后端,大概率对一长串if-else或者switch已经形成了“条件反射式”的厌恶。最近我在订单支付模块里做了一次重构,核心就是用Spring容器配合策略模式(Strategy Pattern)和工厂模式(Factory Pattern),把原本散落在主方法里的支付渠道处理逻辑收拢成一套可插拔的处理器结构。这篇文章不是什么教科书式的模式讲解,我会告诉你这个优化代码结构的实践里,我是怎么设计接口、怎么用Spring完成策略装配、哪些坑是在真实落地时踩出来的,以及什么时候你反而不应该用策略模式。无论你是准备Java面试,还是日常维护Spring Boot项目觉得代码越来越难加新功能,这篇文章应该都能直接拿来参考。
1. 为什么要把if-else换成策略加工厂
1.1 典型臃肿代码的样貌和问题
我接手订单模块时,支付回调逻辑全部写在PaymentService里,代码大概是这样的:
public PayResult handlePay(String channel, PayRequest request) { if ("alipay".equals(channel)) { // 1. 调用支付宝验签 // 2. 更新订单状态 // 3. 生成回执 // 4. 记录流水 } else if ("wechat".equals(channel)) { // 1. 调用微信验签 // 2. 更新订单状态 // 3. 生成回执 // 4. 记录流水 } else if ("bank".equals(channel)) { // 银行卡渠道有单独的特殊处理 } else if ("points".equals(channel)) { // 积分渠道不走支付回调,单独处理冻结和解冻 } else { throw new BizException("不支持的支付渠道:" + channel); } return result; }这代码第一眼看起来“能跑”,实际上问题非常大:
- 每加一个新支付渠道,就要在这个方法里加一条分支,这个方法会越来越长,慢慢变成几百行的“上帝方法”。
- 渠道之间的逻辑互相穿插,有人加了预售校验、有人加了风控字段,真正的主流程反而看不清。
- 单元测试很难写,想测“微信支付回调”得先构造一大堆前置条件,Mock成本非常高。
- 最麻烦的是新人不敢动这段代码,因为一条分支的改动可能同时影响所有渠道,改一行代码要担惊受怕。
这种结构本质上把“策略的选择逻辑”和“策略的具体实现”全部揉进了一个方法。你每新增一个渠道,改动面就是整个方法,而不是一个独立单元。重构的目标很简单:把“会变化的部分”交给独立类,把“选择用谁处理”这个动作独立出去,让主流程稳定下来。
用个生活化的例子比喻一下:你现在有一个文件夹,里面混着几百张不同菜谱,你要找“红烧肉”得从头翻到尾,而且有一次不小心把“糖醋排骨”的步骤写进了“红烧肉”那一页,后面的人照着做就翻车了。策略模式就是把这些菜谱拆成一本本独立小册子,工厂则是那个帮你按菜名找到小册子的服务员。调用方就是食客,看到的只是端上来的菜,不需要自己在吧台后翻乱纸堆。
1.2 两个模式各自解决什么问题
先说策略模式(Strategy Pattern)。它的核心意图是“定义一族算法,把每个算法封装起来,让它们可以互相替换”。在我们这个场景里,每个支付渠道的处理逻辑就是一个算法实现类,调用方只依赖策略接口,不再知道具体类名,也不用关心某个渠道内部到底调了几次数据库、更新了几张表。
策略模式解决的是实现的可替换性。比如支付宝今天验签逻辑变了,我只改AlipayHandler一个类,微信、银行卡、积分都不受影响。换一个渠道实现,外部代码完全无感知。
再看工厂模式(Factory Pattern)。很多人以为工厂就是“帮你创建对象”,在Spring项目里,对象创建已经被容器接管了,工厂真正承担的是“帮调用方找到对应策略”这个职责。一个策略工厂维护“类型标识到策略实例”的映射关系,调用方只需要递过来一个渠道字符串,拿回去一个能处理业务的策略实例。
工厂模式解决的是选择逻辑的集中管理。如果没有工厂,调用方难免在自己代码里再写一个getHandler的私有方法,或者直接用Map映射,一旦映射规则发生了变化,调用方又要改。把选择逻辑收拢到工厂类里,调用方就完全不知道“有多少种策略、策略怎么组织”这些事。
1.3 Spring让这两个模式落地变得极其自然
Spring框架对策略模式最大的加持是“自动发现+自动装配”。以前用传统Java方式实现策略工厂,你得手动new一堆策略对象,自己管理生命周期;现在Spring的扫描机制加上@Component注解,能把容器内所有指定接口的实现类自动收集起来,再通过构造函数注入塞进工厂的Map。最容易被忽略的“注册”步骤直接自动化了。
如果你对Spring的Bean生命周期有深入研究,比如看过三级缓存的源码,你会发现很多“神奇”的注入行为都来自容器的BeanPostProcessor机制。策略模式的自动收集正是这种机制的直接受益者。想深入理解的话,建议去啃一啃Spring容器的创建流程,搞懂之后你会对“为什么Spring的项目里设计模式落地成本这么低”有一个更底层的认知。
2. 整体设计思路:接口、工厂、调用三层分离
2.1 场景设定与目标
我用的真实业务是:订单支付处理,支付渠道包括支付宝(alipay)、微信(wechat)、银行卡(bank)和积分(points)。积分不是真实支付回调,是单独的冻结和解冻逻辑,但为了统一入口,我把它也放进了同一个路由体系。
重构之前,整个处理逻辑就是上一节那段if-else。重构后我给自己定了几个标准:
- 新增支付渠道时,不改动现有支付主流程的任何代码。
- 调用方不需要知道渠道细节,只传一个渠道标识给工厂。
- 类型标识和处理器映射关系最好能从配置层调整,而不是写死在逻辑里。
三个标准都不算激进,但落地后对后续需求的响应速度提升非常明显。
2.2 策略接口如何定义
策略接口是整个重构的“契约”,定义得好不好,直接决定后期所有Handler的编写体验。我第一版接口是这样设计的:
public interface PaymentHandler { // 返回当前处理器支持的渠道类型 String getType(); // 核心处理逻辑,传入统一上下文,返回统一结果 PayResult handle(PayContext context); }有几个设计点值得展开:
getType()是策略向工厂“自报家门”的渠道标识。它必须和前端传入、接口文档约定的渠道编码保持一致。我踩过的坑是有人在支付宝渠道里写"ali_pay",前端传"alipay",找了一下午才发现是下划线问题。所以这个标识建议直接定义成常量类或者枚举,禁止在实现类里手写字符串。handle()参数为什么用一个PayContext而不是直接传PayRequest?因为支付回调场景里,不同渠道请求体差异很大,而且除了请求参数,Handler内部可能还需要查订单、查配置、查商户信息。与其把这些都塞进方法参数列表,不如用上下文对象统一携带,Handler只取自己关心的字段。返回值统一用
PayResult,里面包含响应码、提示信息和业务数据。这样策略实现不用关心HTTP层怎么装配返回结构,web层的代码也能保持稳定。
如果你的业务更复杂,可以考虑把接口拆成“前置校验、核心处理、后置通知”三段式,但我的看法是第一版不要设计得太重,先把“能替换”这个核心做出来,等策略变复杂时再逐步演进。
2.3 工厂类的两种装配方式
策略工厂看起来很简单,但实现方式上有讲究。我最推荐的方式是“自定义键值收集,主动构建Map”,完整代码:
@Component public class PaymentHandlerFactory { private final Map<String, PaymentHandler> handlerMap; public PaymentHandlerFactory(List<PaymentHandler> handlers) { this.handlerMap = handlers.stream() .collect(Collectors.toMap(PaymentHandler::getType, Function.identity())); } public PaymentHandler getHandler(String type) { PaymentHandler handler = handlerMap.get(type); if (handler == null) { throw new BizException("不支持的支付渠道:" + type); } return handler; } }这个写法的优点非常明确:
- 构造器注入
List<PaymentHandler>,Spring容器启动时会把所有PaymentHandler类型的Bean收集成一个List注入进来,你不需要在工厂内部去ApplicationContext里手动getBean。 Collectors.toMap里如果两个策略返回了相同的getType(),容器启动时会直接抛IllegalStateException,这就相当于把一个隐藏的命名冲突问题暴露在启动阶段,而不是等运行时路由出错。getHandler返回的是接口类型,调用方只依赖抽象,不依赖任何具体实现类。
如果团队里有人提出“直接用Spring的Map注入不是更省事吗”,确实可以这样写:
@Autowired private Map<String, PaymentHandler> handlerMap;Spring会把容器里所有PaymentHandler类型的Bean按Bean名称作为key注入Map,代码量很少。但我不推荐把它当主要方案,因为Map的key是Bean名称,默认是类名首字母小写,像alipayHandler。你需要通过@Component("alipay")强制指定Bean名称,才能让key和业务渠道编码一致。这就导致策略实现类和Spring Bean命名强耦合,以后改类名时很容易漏改,而且语义上比不上我们自己用getType()定义的业务路由键。
两种方式的对比如下:
| 对比项 | 自定义Map构建 | Spring BeanName注入 |
|---|---|---|
| Key来源 | getType()业务返回,语义清晰 | Bean名称,与类名耦合 |
| 冲突检测 | toMap重复key直接抛异常 | 覆盖或命名冲突不易发现 |
| 可测试性 | 可手动new List注入工厂 | 依赖Spring容器 |
| 配置灵活性 | 容易扩展动态路由 | 相对固定 |
2.4 调用方改造前后对比
重构前PaymentService的主方法是那个巨型if-else。重构后变成这样:
public PayResult handlePay(String channel, PayRequest request) { PaymentHandler handler = paymentHandlerFactory.getHandler(channel); return handler.handle(new PayContext(request)); }这个对比非常直观。PaymentService不再知道任何渠道细节,它只依赖PaymentHandlerFactory和PaymentHandler接口。之后来了一个“余额支付”渠道,PaymentService一行都不用改,只需要新增一个BalanceHandler,加上@Component注解,工厂在容器下次启动时自动注册进去。主流程的稳定性和可读性都上了一个大台阶。
3. 完整实战代码与关键细节
3.1 上下文与结果对象
先定义PayContext和PayResult。这两个类是策略实现之间的公共通信协议,字段设计上要克制,不要把所有东西都塞进去。
public class PayContext { private PayRequest request; private Order order; private Map<String, Object> extraData = new HashMap<>(); // getter / setter 省略 } public class PayResult { private String code; private String message; private Object data; public static PayResult success(String message) { PayResult result = new PayResult(); result.code = "SUCCESS"; result.message = message; return result; } // getter / setter 省略 }为什么要用PayContext而不是把Order直接传进去?因为数据库实体字段一旦变化,会影响所有策略实现类,哪怕它根本不关心这些字段。用上下文对象包一层,策略之间传递的数据保持最小契约,后续要加个商户费率、风控标识之类的新参数,只要在PayContext里加字段,不破坏已有方法签名。
3.2 四个策略实现类
以支付宝为例,Handler的完整写法:
@Component public class AlipayHandler implements PaymentHandler { @Override public String getType() { return "alipay"; } @Override public PayResult handle(PayContext context) { // 1. 调用支付宝验签接口 boolean valid = alipayService.verifySign(context.getRequest()); if (!valid) { return PayResult.fail("支付宝验签失败"); } // 2. 更新订单状态为已支付 Order order = context.getOrder(); order.setStatus(OrderStatus.PAID); orderService.updateById(order); // 3. 生成支付宝回执 context.getExtraData().put("receipt", alipayService.buildReceipt(order)); // 4. 记录支付流水 payFlowService.record(context.getRequest(), order); return PayResult.success("支付宝支付成功"); } }微信、银行卡、积分三个类的结构完全一致,分别实现自己的getType()和handle():
WechatPayHandler:调用微信验签、处理退款时的反向操作、生成微信回执。BankCardHandler:对接银行回调、更新订单状态、记录银行流水,因为银行渠道的回调格式经常不一致,所以额外做了字段映射。PointsHandler:积分渠道不参与支付回调,只做冻结和解冻,内部可能还会调积分账户服务。
四个类之间互相不引用,互不污染,每个类只需要关心自己的业务逻辑。这不是什么高深技巧,就是把“每个渠道的特殊之处”从一个大方法里拆到了各自的类里。
3.3 工厂统一调度与配置升级
工厂类的代码在上面已经写过,这里重点说说两个实战细节。
第一,为什么用构造器注入而不是@Autowired字段注入?构造器注入能让依赖关系在编译期就固定,配合final关键字防止工厂内部Map被重新赋值。更重要的是单元测试时不需要反射、不需要启动Spring容器,直接new PaymentHandlerFactory(List.of(alipayHandler))就能测。
第二,getHandler方法我建议主动抛出业务异常而不是返回null。返回null会把判断逻辑甩给调用方,每个调用方都得写空值判断,既不规范也容易漏。早失败、快速暴露错误,是后端代码的通用原则。
如果你希望渠道路由能动态调整,比如某个渠道因为支付通道故障暂时下线,这个工厂还能继续升级:
@Component public class PaymentHandlerFactory { private Map<String, PaymentHandler> handlerMap; private final RouteConfig routeConfig; // 配置中心下发 public PaymentHandler getHandler(String type) { if (!routeConfig.isEnabled(type)) { throw new BizException("渠道暂不可用:" + type); } PaymentHandler handler = handlerMap.get(type); if (handler == null) { throw new BizException("不支持的支付渠道:" + type); } return handler; } }这样就把路由策略从“代码写死”变成了“运行时可控”,对接Nacos、Apollo这类配置中心后,运营能直接通过配置把某个渠道临时切走,不需要发版。
3.4 单测怎么写
重构前想测支付逻辑,你得模拟一个业务服务、一个渠道、一堆分支条件;重构后测试变得非常简单:
class PaymentHandlerFactoryTest { @Test void shouldRouteToAlipayHandler() { PaymentHandler alipay = new AlipayHandler(); PaymentHandler wechat = new WechatPayHandler(); PaymentHandlerFactory factory = new PaymentHandlerFactory(List.of(alipay, wechat)); assertSame(alipay, factory.getHandler("alipay")); } }单个策略类的测试也完全可以脱离Spring容器:
class AlipayHandlerTest { @Test void shouldMarkOrderPaidWhenCallbackValid() { AlipayHandler handler = new AlipayHandler(); PayContext context = new PayContext(); // 构造订单、请求对象 PayResult result = handler.handle(context); assertEquals("SUCCESS", result.getCode()); assertEquals(OrderStatus.PAID, context.getOrder().getStatus()); } }这类测试是策略模式带来的隐藏福利:被测对象变小、变纯粹,Mock链条大幅缩短,测试覆盖率自然就上去了。
4. 实际落地中的坑与排查技巧
4.1 策略没有注册进Map怎么办
我实际遇到过工厂getHandler返回null,排查思路基本围绕三个原因:
- 策略实现类没有加
@Component,或者类所在包不在Spring扫描范围内。 - 加了
@Component但类上还有@Primary、@ConditionalOnProperty之类的条件注解,导致容器里根本没有这个Bean。 getType()返回的渠道标识和调用方传入的不一致,比如大小写不同、多了空格、常量名写错。
排查时先确认类有没有被扫描到,再去数据库或日志里看实际传入的渠道值。我最后的经验是:渠道标识用常量类约束,调用方和策略实现类都引用同一个常量,不要各自手写字符串。
4.2 并发场景下的状态安全
策略实现类默认是单例Bean,等同于被所有请求共享。如果任何一个Handler里定义了成员变量来存临时数据,两个请求同时进来数据就会互相污染。这是策略模式在Spring里最容易犯的错。
解决办法极其简单:策略实现类必须是无状态的。所有临时数据都通过方法参数、局部变量、PayContext内部流转,需要跨请求共享的数据放Redis或数据库,不要放在对象成员变量里。判断一个Handler是否合格的标准,就是你把它创建一次后交给一百个线程同时调用,结果应该完全一致,不能有共享的可变状态。
4.3 需求变化时的扩展路径
这个结构上线后,新需求来了怎么扩,我是按这个思路走的:
- 新增一个支付渠道:新增一个Handler实现类,加
@Component,完事。 - 某个渠道处理逻辑要换:直接替换它对应的Handler实现类,其他渠道无感。
- 一个渠道内部要按订单状态做不同处理:可以在Handler内部继续细分策略,或者引入责任链模式。
- 某个渠道要“绕过”通用流程:单独写一个Handler实现类,
handle()里放完全独立的逻辑,外部结构不用动。
这种扩展方式特别适合支付网关、第三方接入、消息处理、文件导入这类“按类型分流且类型持续增加”的业务。
4.4 什么时候不要用策略模式
策略模式虽然好,但用到错误场景就是过度设计。我总结了三种不该硬套的情况:
第一,分支只有两三个,且没有新增可能。比如“是否VIP”这种二元分支,直接用if写清楚更简单,强行拉出几个Handler反而制造抽象。
第二,分支之间强耦合,互相依赖。如果分支A的计算结果会被分支B用到,强行拆开策略类,你会为了传中间数据设计一个臃肿的上下文对象,代码比原来还难读。
第三,团队对设计模式没有基本共识。这种情况下你硬上策略模式,review时大家看不懂,后续维护也容易走样。这种时候真正该做的是先做一次代码结构的内部分享,把模式的价值讲透,再推广到项目里。
另外,如果你发现业务里“不同渠道其实不需要怎么处理,只是想让流程里其他模块做点事”,那更适合Spring事件机制(观察者模式)。策略模式侧重“算法可选”,事件机制侧重“流程解耦”,工具不同,别看到一个switch就想套策略模式。比如订单支付成功之后要发短信、更新统计、推送消息,这些用@TransactionalEventListener监听支付成功事件就非常自然,不需要为每个通知渠道再搞一个策略类。
5. 复盘:这个实践给我的经验
5.1 可观察的收益
重构完成之后,我统计了三个维度的变化:
PaymentService主方法从120多行收敛到20行左右,代码review时一眼扫完核心流程。- 新增支付渠道的改动从“改一个方法”变成“新增一个类”,Git冲突概率大幅下降。
- 单元测试终于容易写了,直接new不同的Handler,针对单渠道逻辑写单测,不需要再模拟前面一长串分支条件。
最让我开心的是第三点。之前团队测试覆盖率上不去,不是大家不会写,而是被测方法太庞大、Mock链条太长。重构之后,覆盖率是靠一个个独立的Handler类堆起来的,每个类很小很纯,测起来顺手,覆盖率自然就上去了。
5.2 模式要服务业务,而不是反过来
整个实践里,我没有为了“模式感”而硬造抽象。订单状态流转的部分,我用的是状态机而不是策略模式;普通通知的部分,我用的是Spring事件而不是再套一层工厂。只有“支付渠道按标识分流,且渠道会持续迭代”这个明显符合“算法族可替换”的场景,才上了策略加工厂的组合。
设计模式的第一原则永远是:把改动隔离在最小范围内。如果一个模式反而让你的代码多了一层无意义的跳转,多半是用错了场景。JDK和Spring源码里到处有策略和工厂的影子,但它们都出现在“确实需要运行时替换”的地方,而不是出现在“为了设计模式而设计模式”的地方。
5.3 后续还能怎么扩展
这个结构稳定之后,如果还要继续优化,我的优先级是这样排的:
- 把Handler签名从
handle(PayContext)改造成更细粒度的“校验-处理-通知”三段式,让每个实现类内部流程更统一。 - 给每个Handler加上监控埋点和超时熔断,渠道处理出问题时能在中间件上直接看到,而不是等业务反馈。
- 如果策略数量继续增多,把渠道路由配置迁移到配置中心,让发布时有能力按渠道灰度。
我个人在实际操作中的一个体会是:真正让策略模式发挥价值的时刻,不是重构当天代码变清爽,而是接下来每一次新需求到来时,你只需要新增一个类、注册一行配置,其他代码一行不动。那种“改动安全感”才是代码结构设计能力最好的体现。