SpringBoot实战:策略模式+自动装配优雅消灭if-else
很多后台项目最后都会长成这个样子一个 Controller 里摆着十来个 if-else每种支付渠道、通知渠道或者业务类型都单独调一个 service看起来“业务逻辑清晰”实际上每加一种渠道就得把老代码翻个底朝天改完还得担心会不会影响已有分支。今天分享一套我用了很久的 SpringBoot 实战方案用“策略模式 Spring 自动装配”优雅消灭 if-else。我会从业务痛点、具体实现、Spring 注入原理到高频排障一层层拆开讲适合已经会用 SpringBoot 写 CRUD、但想在分支设计上更进一步的同学。1. 为什么 if-else 像雪球一样越滚越大1.1 一段再常见不过的“坏味道”代码先看一段很典型的代码。一个支付入口根据 channel 参数走不同渠道public String pay(String channel, BigDecimal amount) { if (alipay.equals(channel)) { return alipayService.pay(amount); } else if (wechat.equals(channel)) { return wechatPayService.pay(amount); } else if (unionpay.equals(channel)) { return unionpayService.pay(amount); } else if (balance.equals(channel)) { return balanceService.pay(amount); } else { throw new IllegalArgumentException(未知支付方式); } }问题不在“多写了几行”而在于每加一个渠道这个 if-else 就多一层类越来越长。更麻烦的是支付前后的校验、日志、订单处理往往也会跟着一起复制粘贴等到渠道超过五个这段代码基本没人敢动了。新人不敢碰老人改得烦这种状态在业务系统里非常普遍。1.2 策略模式到底解决什么问题策略模式的核心是把一组可以互相替换的行为封装成一个个独立的类让“使用行为”的代码和“实现行为”的代码解耦。放到刚才的例子里就是把“支付宝支付”“微信支付”“银行卡支付”分别封装成独立的处理器每个处理器只负责自己的逻辑。但这里有个常被忽略的点策略模式本身并不能消除“选择”的过程。业务方法里最终还是要根据 channel 决定用哪个处理器如果你用多个 if-else 或者一个 switch 来做这个选择那只是换了个地方写分支并没有本质改善。真正有价值的是模式和时间点这些策略对象都由 Spring 容器统一管理选择逻辑交给一个轻量的分发器在初始化时完成映射调用方只认“面向接口”的输入输出。1.3 为什么需要 Spring 自动装配来配合如果不用 Spring 自动装配比较直接的做法是在分发器里自己维护一个 HashMap初始化时手动 new 出所有策略对象MapString, PayHandler map new HashMap(); map.put(alipay, new AlipayPayHandler()); map.put(wechat, new WechatPayHandler());这种做法虽然消灭了 if-else但带来了两个新问题。第一每次新增策略都要来改分发器违反了“对修改关闭、对扩展开放”的原则。第二手动 new 出来的对象不在 Spring 容器里无法使用依赖注入、事务、AOP 这些能力如果策略实现里需要注入别的 Bean这套方案立刻崩盘。Spring 自动装配的价值就在这里容器启动时把所有 PayHandler 类型的 Bean 收集起来按业务类型建立映射调用时直接按 key 取。新增策略时只需要实现接口并注册成 Bean老代码一行不用动。2. 一套能落地的策略模式 Spring 自动装配实现2.1 先定义稳定接口不给后续实现留坑接口是所有策略类的契约定义得好不好直接影响后续扩展。以支付渠道为例我一般会这样写public interface PayHandler { String type(); String pay(PayRequest request); }这里有两个方法type()用来告诉分发器当前处理器对应哪个业务类型pay()是真正的业务处理方法。有的团队会把 type 定义成接口里的常量或者用枚举统一管理后面我会讲更进阶的做法但基础版本建议先把接口收敛好参数对象也不要散落成多个基本类型不然以后加了四五个参数会很难看。2.2 把不同渠道封装成独立 Spring Bean现在为每个渠道写一个实现类都交给 Spring 管理Component public class AlipayPayHandler implements PayHandler { Override public String type() { return alipay; } Override public String pay(PayRequest request) { // 这里可以注入支付宝 SDK、保存订单、做风控等 return 支付宝支付成功金额 request.amount(); } }其他渠道类似Component public class WechatPayHandler implements PayHandler { Override public String type() { return wechat; } Override public String pay(PayRequest request) { return 微信支付成功金额 request.amount(); } }你这么写完就会发现每个实现类都很小只包含自己渠道的特有逻辑。将来要加一个“银联支付”不碰支付宝和微信的任何代码直接新建一个 UnionpayPayHandler 即可。2.3 写一个策略分发器让 if-else 从入口彻底消失分发器是整个方案的核心它利用 Spring 的自动装配能力把所有 PayHandler 类型的 Bean 收集到一个 Map 中Component public class PayHandlerDispatcher { private final MapString, PayHandler handlerMap; public PayHandlerDispatcher(ListPayHandler handlers) { MapString, PayHandler temp new HashMap(); for (PayHandler handler : handlers) { PayHandler previous temp.put(handler.type(), handler); if (previous ! null) { throw new IllegalStateException(重复的支付类型: handler.type()); } } this.handlerMap Collections.unmodifiableMap(temp); } public String dispatch(String channel, BigDecimal amount) { PayHandler handler handlerMap.get(channel); if (handler null) { throw new IllegalArgumentException(不支持的支付通道: channel); } return handler.pay(new PayRequest(channel, amount)); } }关键点在于构造方法的参数ListPayHandler handlers。Spring 在创建 PayHandlerDispatcher 这个 Bean 时会扫描容器中所有实现 PayHandler 接口的 Bean然后把它们装进一个 List 注入进来。分发器只需要在构造阶段把它们按 type 放到 Map 里以后每次调用都是 O(1) 的查找不需要遍历也没有任何 if-else。2.4 调用方只面向分发器编程Controller 或者 Service 只需要依赖分发器不需要知道具体实现RestController RequestMapping(/pay) public class PayController { private final PayHandlerDispatcher dispatcher; public PayController(PayHandlerDispatcher dispatcher) { this.dispatcher dispatcher; } PostMapping public String pay(RequestBody PayRequest request) { return dispatcher.dispatch(request.channel(), request.amount()); } }调用方非常干净它只关心“我要支付渠道是什么钱是多少”至于具体走哪个渠道、怎么走统统交给分发器。这就是依赖倒置上层模块不再依赖底层具体实现而是依赖一个稳定接口底层实现可以在运行时被灵活替换。3. 为什么 autowire 一个 List 能拿到全部策略聊聊 Spring 自动装配原理3.1Autowired是怎么处理“多个同类型 Bean”的很多第一次看到ListPayHandler注入的同学都会疑惑Spring 怎么知道要塞哪些 Bean 进来会不会打包成一个对象这里要稍微了解一下依赖注入的底层逻辑。Spring 容器在启动时会扫描并注册所有 BeanAutowiredAnnotationBeanPostProcessor负责处理Autowired、构造器注入、集合注入等。当你声明ListPayHandler这样的参数时Spring 会把它识别为“集合依赖”然后去容器中找到所有类型为 PayHandler 的 Bean全部收集起来塞进去。如果你给某个实现类加了Order注解或者实现了Ordered接口Spring 还会按照顺序排列后再注入。用一个生活化的类比你只要说你想要一盒“笔”Spring 会把抽屉里所有符合“笔”这个分类的东西都给你拿出来而且可以按你要求的顺序排好。这跟MapString, PayHandler的注入不同Map 的 key 是 Bean 名称不是业务类型所以我们不直接用 Map而是自己在构造器里转换。3.2 构造器注入与字段注入哪个更值得推荐我强烈推荐构造器注入理由有三个。第一构造器注入的属性可以用final修饰对象创建之后依赖不可变不会有中间状态也天然规避了多线程安全问题。第二构造器让依赖关系一目了然别人看到这个类的构造方法就知道它到底需要什么。第三在写单元测试时你可以直接new PayHandlerDispatcher(handlerList)不需要启动完整的 Spring 容器非常方便。Spring Boot 官方文档这些年也一直推荐构造器注入。当然构造器注入也有绕不开的场景。如果策略类之间有循环依赖Spring 3.x 之后的构造器循环依赖无法自动解决那时候再考虑Lazy或者 setter 注入。不过出现循环依赖本身就是设计要警惕的信号能做的是重构而不是为了注入而强行绕开。3.3 其他几种自动装配收集策略的方式对比除了构造器注入 List常见做法还有三种我也都试过各有取舍方式代码思路优点缺点构造器注入 List注入后遍历转 Map依赖明确、可测试性好、不依赖 ApplicationContext收集顺序可能需要额外控制ApplicationContext.getBeansOfType实现ApplicationContextAware从容器获取可以在任何时机动态获取与容器耦合测试时 mock 麻烦Autowired MapString, PayHandler直接注入 Mapkey 是 Bean 名最省事Bean 名与业务 type 不一定一致还要额外转换我的习惯是优先第一张表里的“构造器注入 List”。它既不和你自己的业务 Bean 名称纠缠又能保留 Spring 的所有增强能力。ApplicationContext的方案我一般只在动态扩展、运行期才知道类型清单的插件化场景里用。4. 实操搭一个 SpringBoot 项目并跑通整个流程4.1 项目结构与 Maven 依赖为了让你能直接抄作业我把项目结构完整列出来springboot-strategy-demo └── src/main/java/com/example/demo ├── DemoApplication.java ├── controller/PayController.java ├── dispatcher/PayHandlerDispatcher.java ├── handler/PayHandler.java ├── handler/impl/AlipayPayHandler.java ├── handler/impl/WechatPayHandler.java ├── handler/impl/UnionpayPayHandler.java └── handler/impl/BalancePayHandler.javaMaven 依赖只需要一个 Web 包。Spring Boot 版本我建议根据 JDK 选如果项目还停在 Java 8就用 Spring Boot 2.7.x如果已经是 Java 17 及以上直接用 Spring Boot 3.x底层包名变化我在后面单独讲。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency工具上我不额外加 Lombok避免把你带偏万一环境不一致反而报错。4.2 核心代码清单PayRequest 我用一个 record 表示够用且简洁public record PayRequest(String channel, BigDecimal amount) { }四个实现类里支付宝和微信上面的代码已经给过这里再补银联和余额的示意Component public class UnionpayPayHandler implements PayHandler { Override public String type() { return unionpay; } Override public String pay(PayRequest request) { return 银联支付成功金额 request.amount(); } } Component public class BalancePayHandler implements PayHandler { Override public String type() { return balance; } Override public String pay(PayRequest request) { return 余额支付成功金额 request.amount(); } }Dispatcher 和 Controller 沿用第 2 节的代码加上启动类SpringBootApplication public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }整体跑起来后可以直接用 curl 验证curl -X POST http://localhost:8080/pay \ -H Content-Type: application/json \ -d {channel:alipay,amount:100.00} # 返回支付宝支付成功金额100.00把 channel 换成微信、银联、余额也会各有响应。如果传一个不存在的渠道则会得到清晰的异常信息。4.3 再写一个集成测试确认扩展不改老代码我习惯在关键节点补一个SpringBootTest保证容器装配没问题SpringBootTest class PayHandlerDispatcherTest { Autowired private PayHandlerDispatcher dispatcher; Test void should_dispatch_to_alipay() { String result dispatcher.dispatch(alipay, new BigDecimal(88)); Assertions.assertEquals(支付宝支付成功金额88, result); } Test void should_throw_when_unknown_channel() { Assertions.assertThrows(IllegalArgumentException.class, () - dispatcher.dispatch(unknown, new BigDecimal(1))); } }这种测试跑一遍基本能确定容器注入、分发逻辑没有跑偏。接下来尝试新增一个渠道比如新增“礼品卡支付”只需要新建一个 GiftCardPayHandler加上Component然后启动测试或者再跑一次接口。老代码一根手指头都不用动这就是这套设计最爽的地方。5. 更进一步用自定义注解 模板方法让策略模式更顺手5.1 用PayType注解代替 type() 方法基础版本中每个实现类都要写type()和pay()type 本身就是一种“业务元数据”。我更推荐直接定义注解把它标在实现类上分发器根据注解建立映射Target(ElementType.TYPE) Retention(RetentionPolicy.RUNTIME) Documented public interface PayType { String value(); }实现类变成这样Component PayType(alipay) public class AlipayPayHandler implements PayHandler { Override public String pay(PayRequest request) { return 支付宝支付成功金额 request.amount(); } }接口里就不需要 type() 了分发器也更有目的性Component public class PayHandlerDispatcher { private final MapString, PayHandler handlerMap; public PayHandlerDispatcher(ListPayHandler handlers) { MapString, PayHandler temp new HashMap(); for (PayHandler handler : handlers) { PayType payType AnnotationUtils.findAnnotation(handler.getClass(), PayType.class); if (payType null) { throw new IllegalStateException(handler.getClass().getSimpleName() 缺少 PayType 注解); } temp.put(payType.value(), handler); } this.handlerMap Collections.unmodifiableMap(temp); } public String dispatch(String channel, BigDecimal amount) { PayHandler handler handlerMap.get(channel); if (handler null) { throw new IllegalArgumentException(不支持的支付通道: channel); } return handler.pay(new PayRequest(channel, amount)); } }用AnnotationUtils.findAnnotation而不是handler.getClass().getAnnotation()是因为要兼容类上被其他注解修饰、或者接口继承的边界情况。实际开发中注解方案比 type() 方法更不容易漏写也更容易做代码扫描。5.2 用枚举管理业务类型避免魔法值散落基础版本里“alipay”这样的字符串是散落在各个文件中的魔法值。如果渠道名称不统一比如一处写“alipay”另一处写“AliPay”分发器就会在运行期抛出异常。一个很自然的改进是引入渠道枚举public enum Channel { ALIPAY(alipay, 支付宝), WECHAT(wechat, 微信支付), UNIONPAY(unionpay, 银联支付), BALANCE(balance, 余额支付); private final String code; private final String desc; Channel(String code, String desc) { this.code code; this.desc desc; } public String getCode() { return code; } }不过要注意Java 注解的 value 必须是编译期常量所以PayType(alipay)可以PayType(Channel.ALIPAY.getCode())不一定合法。如果要严格绑定枚举可以把注解的 value 类型定义为 Channelpublic interface PayType { Channel value(); } Component PayType(Channel.ALIPAY) public class AlipayPayHandler implements PayHandler { }这样连字符串都省了分发器可以直接根据 enum 建立映射。缺点是代码里到处引用枚举它既是领域模型又承担映射职责要看团队是否接受这种“强类型”风格。5.3 结合模板方法把每个策略里的重复动作收拢策略类多了以后你会发现每个 Handler 的流程其实都差不多先校验参数再执行支付最后保存订单、写日志、发消息。如果这些代码在每个实现类里各写一遍等到第六个策略时又是一坨重复。这时候把模板方法叠加进来是非常顺手的事。定义一个抽象类public abstract class AbstractPayHandler implements PayHandler { Override public final String pay(PayRequest request) { valid(request); String result doPay(request); saveOrder(request); return result; } protected abstract String doPay(PayRequest request); protected void valid(PayRequest request) { if (request.amount() null || request.amount().compareTo(BigDecimal.ZERO) 0) { throw new IllegalArgumentException(支付金额不合法); } } protected void saveOrder(PayRequest request) { // 这里可以由子类覆盖也可以统一处理 } }具体实现类只关心doPay其他公共逻辑放在抽象层。注意我把pay()定义为final避免子类乱覆盖流程这就把“模板方法模式”和“策略模式”组合起来了策略负责可变的业务差异模板负责不变的处理骨架。6. 我在实践中踩过的坑常见问题与排查实录6.1 明明写了好几个策略List 却为空或少了几个最常见的原因是组件扫描范围没覆盖到实现类。如果启动类在com.example.demo而策略实现类放在com.example.strategy.handlerSpring Boot 默认扫描的只是主类所在包及子包自然一个都扫不到。排查方式也很简单在分发器构造器里打断点或者启动时加一行日志看看到底注入了几个 Bean。还有一种情况是策略类上忘了加Component只实现了接口。Spring 不会把你普通类自动变成 Bean必须显式标注或者通过 Java 配置注册。遇到 List 为空时优先去启动日志里搜PayHandler相关字样看每个类的 Bean 定义是否都在。6.2 策略 type 重复导致静默覆盖或启动失败Spring 本身不会识别 type 是否重复如果你在分发器里直接map.put(handler.type(), handler)后注入的那个会把前面的覆盖掉而且不报错这是最隐蔽的坑。我更建议在初始化时就做重复检查也就是第 2 节代码里那一段PayHandler previous temp.put(handler.type(), handler); if (previous ! null) { throw new IllegalStateException(重复的支付类型: handler.type()); }这样重复 type 会在启动阶段直接炸出来而不是等到某个渠道请求进来才发现走了错误的实现。生产环境最宝贵的就是“早失败”启动失败永远比线上诡异行为好。6.3 Spring Boot 2.x 与 3.x 的差异版本选型要注意最近很多人看新教程会直接上 Spring Boot 3.x结果项目 JDK 还是 8编译都过不了。Spring Boot 3.x 和 Spring Framework 6 都要求 Java 17如果你被困在 JDK8 的旧项目老老实实用 Spring Boot 2.7.x 就好。版本升级不只是依赖版本变了javax.annotation.PostConstruct这类注解在 3.x 里变成了jakarta.annotation.PostConstruct很多旧代码会报“包不存在”。遇到这类错误时先检查项目 JDK 版本再统一替换包名别在 pom 里盲目升版本。还有一个比较常见的“版本太高”问题是 IDE 版本和 Spring Boot 版本不匹配导致导入失败。我的建议是不要追求最新稳定优先。公司老项目如果跑得挺好没必要为了新特性承担迁移成本。6.4 策略类内部自调用导致 AOP 不生效Spring 的 AOP 是基于代理的。如果外部调用分发器的 dispatch再通过 handler 引用调用 pay()此时 pay() 方法会经过代理事务、缓存、日志注解都能生效。但如果策略类内部某个方法自己调用另一个带Transactional的方法比如 doPay 里调用this.saveOrder()两行代码自调用走的是 this 对象代理不会介入事务切面就会失效。解决办法是把这些需要 AOP 的操作拆到独立的 Service Bean 中让策略类通过注入的方式调用。手动 new 策略对象也会同样丢失代理这也是我强调一定用 Spring Bean 管理策略实例的原因。6.5 别为了“消灭 if-else”而过度设计策略模式 自动装配确实漂亮但它不是银弹。如果一个方法里只有两三个固定分支需求也基本不新增那直接写 if-else 反而是最清晰的方案。引入策略模式至少多出接口 N 个实现类 一个分发器团队结构越小这点复杂度越可能是负担。我自己的判断标准是未来会不会稳定地增加“同一类行为”的新实现如果会而且超过 4 个这组合拳性价比就很高如果不会先把代码写简单才是第一追求。这套组合我在多个真实项目里用过最直观的体感是新增渠道时不再焦虑了新同学照着已有 Handler 复制一个写注解、补逻辑启动后就能用。后来我又在分发器里加上了重复校验、兜底策略和注解扫描团队扩展起来更顺手。最后再给你一个小建议做这类重构时先把所有分支入口统一到一个 gateway再逐个把实现拆出去分阶段验证对比比一次性大刀阔斧地改要稳得多。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →