接手过一个多商户订单模块,那段时间是我对“Java设计模式应用实战”这个词理解最深的时候。公司里其实很少有人天天把23种GoF设计模式挂在嘴边,但代码烂起来的时候,你几乎能闻到坏味道:同一个创建订单的逻辑散落在七八个Service里,支付渠道的if-else越叠越厚,订单支付成功后的连锁动作把核心方法撑到几百行。后来我把23种经典模式逐个过了一遍,又对照业务代码做了几次重构,才真正想明白一件事——设计模式不是面试八股文,而是“你的代码将来要长成什么样”的提前决策。这篇文章我不会按教科书把23种模式平铺直叙地讲一遍,那等于没讲。我只挑业务开发里真正高频、真正能救命的那些模式,结合一个虚拟的多商户商城订单场景,告诉你它们解决什么问题、怎么一步一步落到代码里、哪些坑我替你先踩过了。零基础的读者也不用怕,我会把前置知识点用大白话补上,你看完能知道“这段烂代码到底该怎么动手改”。
1. 先搞清楚一件事:23种模式不是都值得学
1.1 业务开发里翻来覆去用的,其实就那么几个
很多人一听到“设计模式”就头皮发麻,觉得要背23种定义、UML类图、适用场景和优缺点。我的建议是,先把这种心态放下来。我自己统计过六个后端项目里与设计模式相关的重构图谱,80%以上的改动集中在工厂、策略、模板方法、建造者、观察者、责任链这六个模式上。剩下的像解释器模式,除非你要自研规则引擎,否则几年都碰不到一次;外观模式被Controller层天然替代了大半;迭代器模式你用的ArrayList里面就实现了,根本不需要自己写。
业务代码的痛点通常很集中:对象构造太复杂、if-else分支爆炸、流程步骤散落、事件触发后到处硬编码调用、老系统接口不兼容。这几个痛点恰好对应上述六个模式。所以零基础入门设计模式,第一课不是“从抽象类和接口开始背”,而是“先看到你的代码哪里在痛”。有痛感再学模式,效率会高一个量级。
1.2 按使用频率给23种模式分个梯队,学习顺序就有了
我把23种模式按“业务开发中的实际出场率”分了三个梯队,你可以对照自己的水平决定学到什么深度:
| 梯队 | 模式 | 学习目标 |
|---|---|---|
| 第一梯队(写核心逻辑就靠它们) | 策略、工厂、建造者、模板方法 | 能识别使用场景,能在自己的项目里独立完成一次重构 |
| 第二梯队(解耦流程和兼容历史代码) | 观察者、责任链、代理、适配器 | 理解原理,知道Spring和MyBatis里哪些功能底层是它们 |
| 第三梯队(面试能说清楚就行) | 单例、状态、外观、组合等 | 知道是什么、什么场景引入、有什么优缺点 |
说实话,第三梯队里不少模式我也不会在业务里主动引入,比如状态模式写订单状态机确实优雅,但大多数项目里状态流转就是几个if判断,硬上状态模式反而把简单问题复杂化。这种“克制”也是实战经验的一部分。零基础读者我建议这样走:先找自己项目里最烂的一段代码,用策略模式或工厂模式重构一遍,体感比看书一个月都强。等你重构过两三个模块,再回头读GoF的《设计模式》,你会发现那些“抽象”的概念忽然全变成你已经踩过的路了。
2. 对象创建不失控:工厂加建造者拯救一个多商户订单模块
2.1 散落的new和有十几个字段的对象是第一个要处理的坏味道
多商户商城的订单场景特别能说明问题。订单类有二三十个字段:订单号、商户ID、购买用户ID、商品明细、支付金额、发票信息、收货地址、附属优惠信息等等。初版代码里,每个Service都是直接new Order()然后一个字段一个字段set,有的地方set十几个字段,有的地方漏掉两个字段,线上问题排查到半夜。更麻烦的是不同商户类型有不同订单创建逻辑,普通订单、秒杀订单、跨境订单各有各的差异。某次版本要加“发票信息”字段,所有创建订单的地方全得改一遍,全量回归测试跑了一整天,还漏了一个入口,线上脏数据修了两天。
问题本质是创建逻辑和业务使用逻辑完全耦合在了一起。“订单对象怎么被正确构建出来”这件事实在没有理由散落在各处。这里第一个要引入的就是建造者模式。
2.2 用建造者模式把“Order对象怎么建”这件事单独收拢
建造者模式解决的核心问题是:对象字段太多,构造器参数列表长得没法读,setter又容易漏。它的做法是让Order类自带一个内部Builder,通过链式调用来组装对象。
public class Order { private String orderId; private Long merchantId; private Long buyerId; private List<OrderItem> items; private BigDecimal payableAmount; private InvoiceInfo invoiceInfo; // 省略其他字段…… private Order() {} public static OrderBuilder builder() { return new OrderBuilder(); } public static class OrderBuilder { private final Order order = new Order(); public OrderBuilder orderId(String orderId) { order.orderId = orderId; return this; } public OrderBuilder merchantId(Long merchantId) { order.merchantId = merchantId; return this; } public OrderBuilder buyerId(Long buyerId) { order.buyerId = buyerId; return this; } public OrderBuilder items(List<OrderItem> items) { order.items = items; return this; } public OrderBuilder payableAmount(BigDecimal amount) { order.payableAmount = amount; return this; } public Order build() { Objects.requireNonNull(order.orderId, "orderId不能为空"); Objects.requireNonNull(order.merchantId, "merchantId不能为空"); if (order.items == null || order.items.isEmpty()) { throw new IllegalArgumentException("商品明细不能为空"); } return order; } } }这里有两个细节值得注意。第一,必填字段我建议放在build()里校验,而不是允许调用方自由漏掉;第二,如果你用的是Lombok,@Builder注解就是这套机制的框架版落地,但原理就是建造者模式。使用侧长这样:
Order order = Order.builder() .orderId(generateOrderId()) .merchantId(merchantId) .buyerId(buyerId) .items(itemList) .payableAmount(calcAmount(itemList)) .build();链式调用可读性好,不需要十几个参数的构造器,也不会只set一半就忘了另一半。这一步做完,订单对象的“组装”就从散落各处收拢成了“必须经过Builder的约束路径”。
2.3 工厂加策略处理“不同类型订单创建路径不同”的问题
建造者解决了单个对象的组装,但业务里更复杂的是顺序。普通订单、秒杀订单、跨境订单的创建流程差异很大。秒杀订单要校验秒杀资格,跨境订单要走报关校验和汇率换算,普通订单就是常规校验加库存扣减。初版代码把这些逻辑全写在了一个createOrder()方法里,前端传一个orderType进来,方法内部就if-else分叉,越加越长。
正确的做法是先抽象一个OrderCreator接口,把“如何创建一个订单”定义成统一入口:
public interface OrderCreator { OrderType supportType(); Order create(CreateOrderContext context); }然后每个订单类型一个实现类,分别对应普通、秒杀、跨境。最后用一个工厂类做类型分发:
@Component public class OrderCreatorFactory { private final Map<OrderType, OrderCreator> creatorMap; public OrderCreatorFactory(List<OrderCreator> creators) { this.creatorMap = creators.stream() .collect(Collectors.toMap(OrderCreator::supportType, Function.identity())); } public OrderCreator getCreator(OrderType orderType) { return Optional.ofNullable(creatorMap.get(orderType)) .orElseThrow(() -> new IllegalArgumentException("不支持的订单类型: " + orderType)); } }这里用的是Spring的依赖注入特性:实现OrderCreator接口的Bean会被自动收集进List,再转成Map。以后要加一种“团购订单”,只需要新写一个GroupBuyOrderCreator实现类和对应的工厂注册,已有的普通订单、秒杀订单逻辑一行都不用动。改造前后对比非常明显:
| 对比项 | 改造前 | 改造后 |
|---|---|---|
| 新增一种订单类型 | 修改老方法加if分支 | 新增一个实现类即可 |
| 老订单类型回归风险 | 改共用方法容易影响全部分支 | 老类完全没动,风险隔离 |
| 代码可读性 | Service方法几百行,要来回滚屏 | 每个订单类型一个文件,逻辑内聚 |
| 排查问题 | 在巨大方法里逐步跟踪 | 直接定位到对应Creator |
这个模式的精髓在于,它把“变化点”从“杂糅在一起”变成了“每个变化独立成类”,新增需求时不再改动旧代码,而是扩展新代码。
2.4 有一些场景真的不需要工厂,别为了模式而模式
说了这么多工厂的好处,我也要说反例。如果一段创建逻辑只有一个分支,未来一年也看不到第二个变化点,那就直接new,不需要工厂。判断依据很简单:你的业务里是否真的会新增变体,新增频率多久一次。“可能以后会加”不等于“现在就要设计”。过度设计的代码比没有设计还难维护,因为抽象层会掩盖真实的业务流阅读成本。我在代码评审时经常问一个问题:这里你能说出一个确定会发生、但还没发生的具体变化点吗?说不出来,就别上模式。
3. 消灭业务里的“if-else大魔王”:策略模式加模板方法
3.1 支付渠道接入场景:三个if分支开始,七个if分支崩溃
如果说对象创建是第一个坏味道,那if-else大魔王绝对是业务代码里最普遍的顽疾。拿支付场景来说:商城要接支付宝、微信、余额三种支付渠道,每种渠道的参数签名方式不一样,发起支付的方式不一样,回调验签逻辑也不一样。初版代码里一个pay()方法长这样:
public PayResult pay(PayOrder payOrder) { if ("alipay".equals(payOrder.getChannel())) { // 50行支付宝专属逻辑:组装请求、签名、调用渠道API } else if ("wechat".equals(payOrder.getChannel())) { // 50行微信专属逻辑:组装请求、签名、调用渠道API } else if ("balance".equals(payOrder.getChannel())) { // 50行余额专属逻辑:扣减余额、写流水 } else { throw new IllegalArgumentException("不支持的支付渠道"); } }这种代码前三个if还能看,等到第七个渠道进来,问题就爆发了:不是所有渠道的逻辑都长一样,有的要短信验证,有的要回调重试,有的要附带营销参数。你改A渠道的签名逻辑,同一段代码里B渠道的变量被不小心动了一下,全量回归没覆盖到,线上资损事故直接找上门。
3.2 策略模式重构:把算法收敛到各自实现类里
策略模式的核心思想是:定义一组算法,把它们各自封装成类,让它们可以互相替换。放在支付场景里就是定义PaymentStrategy接口,不同渠道各自实现。
public interface PaymentStrategy { String channelCode(); PayOrderResult pay(PayOrder payOrder); CallbackResult handleCallback(CallbackRequest request); }@Component public class AlipayStrategy implements PaymentStrategy { @Override public String channelCode() { return "alipay"; } @Override public PayOrderResult pay(PayOrder payOrder) { // 支付宝专属逻辑:组装请求、签名、调用API return null; } @Override public CallbackResult handleCallback(CallbackRequest request) { // 支付宝回调验签逻辑 return null; } }微信策略、余额策略同理。调用侧怎么选到合适的策略?和前面工厂的套路一样,在Spring里把策略注入成Map,按渠道编码直接取:
@Component public class PaymentStrategyContext { private final Map<String, PaymentStrategy> strategyMap; public PaymentStrategyContext(List<PaymentStrategy> strategies) { this.strategyMap = strategies.stream() .collect(Collectors.toMap(PaymentStrategy::channelCode, Function.identity())); } public PaymentStrategy getStrategy(String channelCode) { PaymentStrategy strategy = strategyMap.get(channelCode); if (strategy == null) { throw new IllegalArgumentException("未支持的支付渠道: " + channelCode); } return strategy; } }这是策略模式和工厂模式的典型组合。新增一个支付渠道,就是新增一个@Component实现类,老的pay()方法一行代码都不用动。哪个渠道出问题,直接看对应策略类的代码,不用在巨大分支里上下找。
3.3 模板方法固定流程骨架,策略只负责差异部分
策略模式把不同渠道的算法隔离了,但很快又会发现另一个问题:虽然渠道有差异,但支付流程的骨架高度相似——先组装参数,再签名,再发起支付,再处理回调,最后更新支付单。如果每个策略类都把这个流程从头到尾写一遍,会有大量复制粘贴。这时候就该让模板方法出场。
模板方法的思想是:在抽象父类里定义好不可变的主流程骨架,把可变步骤留成抽象方法让子类填充。
public abstract class AbstractPaymentStrategy implements PaymentStrategy { @Override public final PayOrderResult pay(PayOrder payOrder) { Map<String, String> params = buildParams(payOrder); String sign = sign(params); params.put("sign", sign); String channelResp = callChannel(params); // 统一记录请求和响应日志 log.info("channel={}, payOrderId={}, response={}", channelCode(), payOrder.getOrderId(), channelResp); return parseResult(channelResp); } protected abstract Map<String, String> buildParams(PayOrder payOrder); protected abstract String sign(Map<String, String> params); protected abstract String callChannel(Map<String, String> params); protected abstract PayOrderResult parseResult(String channelResp); }子类只需要实现四个抽象方法。这样公共骨架统一管理,日志、监控、异常兜底都写在父类里,不会漏掉。还可以加钩子方法,比如某些渠道在发起支付前需要校验用户是否绑卡,父类默认返回true,子类按需覆盖:
protected boolean needCardBindCheck() { return false; }余额渠道覆盖这个方法返回true,支付宝就不需要。这比在流程里写if判断灵活得多。
3.4 什么时候别这样重构
必须说清楚,策略加模板方法不是银弹。如果项目里只有两个支付渠道,而且未来明确不打算接新渠道,那if-else反而比抽象一层更直接。我见过很多团队,一上来就搭好五个渠道的策略框架,实际只跑了两个渠道,剩下的三个抽象类躺在代码库里吃灰,每个新人都要花额外时间理解这层“架构”。设计模式的取舍永远要放在“当前确定的变化点”前考虑,而不是“可能的未来”。脱离需求的架构设计,本质上是一种欠债。
4. 事件通知与审批流:观察者模式加责任链模式
4.1 订单支付成功后的连锁反应,如何从僵尸代码变成事件驱动
订单支付成功这个节点,典型的“一石激起千层浪”:要扣减库存、给用户发短信、送优惠券、记财务流水、更新统计报表、给商户发送订单通知。初版写法很直接,在支付成功的Service方法里按顺序调用:
orderService.markPaid(order); stockService.deductStock(order.getItems()); smsService.sendPaidNotification(order); couponService.sendCoupon(order); financeService.writeFlow(order.getBuyerId(), order.getPayableAmount()); // 以后每加一个动作,这个方法就多一行这种写法最大的问题是:支付成功这个核心业务被一堆“衍生业务”包围了。每加一个积分动作,你就要改这个核心方法,核心方法会越来越长。而且如果发短信的接口超时,库存扣减可能也受影响,甚至把核心流程拖垮。
观察者模式就是处理这个问题的:核心服务只负责发布“订单支付成功事件”,不关心谁在听、听者做什么。在Spring里实现极其简单,发布方注入ApplicationEventPublisher,监听方写@EventListener方法就行。
// 发布方:订单服务 @Component public class OrderService { private final ApplicationEventPublisher eventPublisher; public void markPaid(Order order) { // 核心逻辑:更新订单状态 orderRepository.save(order); // 发布事件,其他监听器各自处理 eventPublisher.publishEvent(new OrderPaidEvent(order.getOrderId(), order.getMerchantId(), order.getPayableAmount())); } }// 监听方:库存服务 @Component public class StockListenser { @EventListener public void onOrderPaid(OrderPaidEvent event) { // 扣减库存 } }订单服务只认识“事件”,不再认识库存服务、短信服务、优惠券服务。以后加一个“推荐有礼积分发放”,只需要新增一个监听器类,订单服务保持稳定。
这里有几个坑我必须提醒。第一,默认情况下@EventListener是同步执行的,如果其中一个监听器抛异常,后续监听器就不会执行,甚至事务会回滚。我的建议是每个监听器内部把异常捕获住并打日志,或者单独配置线程池执行器把监听器改成异步,不要因为一个通知类业务拖垮核心流程。第二,事件对象尽量用轻量的DTO,不要直接把Order实体传过去,因为监听器里可能并发访问这个对象,改乱了这个字段会影响发布方。第三,监听器方法命名要有业务含义,比如sendPaidSms,别都叫handleEvent,不然半年后你自己都分不清这串事件流是怎么回事。
4.2 下单校验动辄五六步,责任链模式让校验流可编排
另一个典型场景是下单前的校验。一个正常的商城下单要过好几道关:库存是否充足、用户是否有风控风险、是否触发限购、优惠券是否有效。初版写法是在一个validate()方法里嵌套if:
public ValidateResult validate(CreateOrderContext context) { if (!stockService.checkStock(context.getItems())) { return ValidateResult.fail("库存不足"); } if (riskControlService.isRiskUser(context.getBuyerId())) { return ValidateResult.fail("账户存在风控风险"); } if (limitService.isOverLimit(context.getBuyerId(), context.getSkuIds())) { return ValidateResult.fail("触发限购规则"); } if (!couponService.isCouponValid(context.getCouponId())) { return ValidateResult.fail("优惠券不可用"); } return ValidateResult.success(); }问题在于,校验逻辑越长,这个方法的“顺序感”越重要。比如风控校验应该放在库存校验前面,这样可以先拦截风险用户,避免浪费库存查询;限购校验在不同促销活动里规则不同。用责任链模式改造后,每个校验器是一个独立节点,由链把它们串起来:
public interface OrderValidateNode { ValidateResult validate(CreateOrderContext context); }@Component public class StockValidateNode implements OrderValidateNode { @Override public ValidateResult validate(CreateOrderContext context) { // 库存校验逻辑 } }链条组装可以在配置层完成:
@Component public class OrderValidationChain { private final List<OrderValidateNode> nodes; public OrderValidationChain(List<OrderValidateNode> nodes) { this.nodes = nodes; } public ValidateResult execute(CreateOrderContext context) { for (OrderValidateNode node : nodes) { ValidateResult result = node.validate(context); if (!result.isSuccess()) { return result; } } return ValidateResult.success(); } }这里“链”的顺序由Spring的Bean加载顺序控制,或者显式指定@Order注解。改造后最大的收益是:新增一种校验规则(比如“跨境订单必须通过实名认证校验”),只加一个节点类,老节点不放动;单个校验逻辑可以单独写单元测试,不需要为了测库存校验去初始化整个下单流程。多商户场景下,不同商户还能配置不同的校验链,比如高等级商户跳过风控或放宽限购,链条的组装逻辑可以按商户级别做策略化配置。
4.3 这两个模式的坑:解耦过头了,排障就成了噩梦
观察者和责任链都是优秀的解耦工具,但也很容易被用成“新的迷宫”。我见过一个项目,订单事件发出去之后有二十几个监听器,排障时你要在IDE里一个事件一个事件地点花半天时间理清调用链,比不用模式还累。责任链同理,如果校验节点拆到七八个,每个节点逻辑却只写了两行,那就纯属于碎成渣。我的经验是:事件监听器和校验节点要有明确的分工边界,每个类名要能直接从名字看出业务含义,最好在日志里带同一个链路ID或订单号,方便把整条事件链串起来排查。如果项目本身不大,事件通知只有两三个场景,别强行拆出十个监听器,有时候直接在方法里依序调用反而更清晰。
5. 框架天天在用,你却可能没意识到的设计模式:单例、适配器、代理
5.1 Spring容器本身就是最大规模的单例模式实践
很多人写单例模式时还在纠结“双重检查锁怎么写”,但在实际业务开发里,你每天都在用的是Spring默认管理的单例Bean。Spring容器默认每个Bean只有一个实例,所有注入这个Bean的地方指向同一个对象。无状态的Service这样一来节省了大量对象创建开销,线程也安全,这就是为什么业务Service通常不需要自己写并发保护。
但“单例”这个前提里藏着一个大坑:如果Bean里有可变的成员变量,那它就是一份所有线程共享的数据。我之前见过一个统计脚本在Service里放了一个int count成员变量用于统计调用次数,上线后数据怎么都对不上,排查半天才发现是单例对象被并发线程同时改这个字段了。理解单例模式的本质,比会写懒汉式饿汉式重要得多,因为你天天都在和框架的单例机制打交道。
5.2 老系统对接,适配器模式比强行改老接口体面得多
公司里总有改造不完的老系统。比如早期的会员系统返回的会员等级是“0、1、2”,但新系统的会员等级已经改成枚举“NORMAL、SILVER、GOLD”,而且多个业务模块都已经按新枚举编写。这时候有两种方案,一种是把老系统数据库全量改造一遍,风险和时间成本都极高;另一种是写一个适配器,在老系统接口上包一层,对外输出新系统的数据结构。
适配器模式的核心就是解决“接口不兼容”的问题,关键在于识别清它是用来兼容的,不是用来“加一层就完事”的。我看到很多项目里在Controller和Service之间套了三四层“适配风格”的转换类,美其名曰防腐层,实际上就是简单字段拷贝,徒增理解成本。一招判断是否真的需要适配器:接口两边定义是否真的不一样,是否存在不可控的外部依赖。如果只是内部两个模块字段对不上,应该调整的是领域模型,而不是到处加适配器。
5.3 MyBatis的Mapper和Spring的事务,底层全是代理模式
零基础读者一定遇到过这个现象:Mapper接口只写了一个方法声明,没有实现类,为什么调用时却能正常执行SQL?答案是代理模式。MyBatis在启动时会为每个Mapper接口生成一个代理Bean,你调用接口方法时,真正干活的是代理对象——它负责解析方法上的SQL注解或XML映射、设置参数、执行JDBC操作、组装返回结果。代理模式的核心就是“给真实对象加一个中间层”,在方法调用前后悄悄的插入额外逻辑。
这个机制不只是让人感叹“原来如此”,它直接关系到你排查线上问题的能力。@Transactional就是一个典型的基于动态代理的能力:你调用一个带事务注解的方法时,Spring容器在方法执行前开启事务,执行成功提交、异常回滚。但如果你在同一类内部用this.selfMethod()的方式调用,不会走代理对象,事务注解会失效——这个问题没有理解代理模式,排查起来完全是没有头绪的。所以不要觉得代理模式只是框架底层的事,它能帮你在实际开发里少踩很多坑。零基础读者平时读代码时,可以刻意做“识别模式”的练习,看到一个接口只有声明没有实现却能运行,就反应过来“哦,这里有代理”。
5.4 零基础怎么把这些模式真正用起来
很多入门者最大的问题不是学不会,而是学了不会用。我的建议很简单:不要试图“系统地”把模式套进所有代码里,而是找一段你最近写过的最痛苦、最乱的代码,然后问自己三个问题:这段代码里最常变化的是什么?这个变化是被if-else控制着,还是被多个方法反复拼装?如果我把它拆成接口加实现,新增一个分支时老代码会不会全都不用动?
从这个角度出发,通常改造的落点无非是策略、工厂、模板方法三个之一。先完成一次小范围重构,用git diff对比一下改动的影响范围,你会发现设计模式带来的收益是“未来新增需求时,旧代码一行不用改”。这才叫提升代码质量,而不是靠美观的分层和漂亮的UML图。设计模式的核心不是背诵23个定义,而是识别变化点、隔离变化点,让每一次新增需求都像“填格子”一样简单、安全、可预测。
说回到那个多商户订单模块。后来几次重构里,我把订单创建链拆成了工厂加策略加责任链,把支付链路改成了模板方法,把支付成功后的联动改成了事件驱动,新增一个跨境订单时,整个改动就是新写一个Creator类和一个ValidateNode类,再在配置里注册一下,老代码一行没碰。那个月我把diff给新来的同事看,他说“好像也没干啥嘛”,但我知道,这就是设计模式最理想的状态——你不再是改动你两个月前写的那坨if-else,而是像搭积木一样把新积木放进去,旧积木稳稳地立在原地。收藏这篇文章之前,我更建议你先打开IDE,找到手头代码里最让你头疼的那段分支逻辑,尝试用策略模式重构一次。改完再看git diff,你就能直观地体会到什么叫“设计模式的实战价值”。