工作里有个特别有意思的现象:刚学设计模式的时候,觉得这23种模式是灵丹妙药,恨不得每个类都用一遍;真做项目做到三五年,反而越用越谨慎。不是说设计模式不重要,而是它在真实项目里的角色,跟教科书上那套讲法完全不一样。
这篇文章我不打算把23种设计模式再抄一遍,而是想聊一个更实在的问题:当我们接手一个不断迭代的业务系统,面对大量if-else、重复代码、频繁变更的需求时,设计模式到底是怎么被“逼”出来的,又该怎么落地。里面会有我实际重构过的代码片段,也有选型时的纠结和踩过的坑,适合正在做Java或C++后端开发、写过一段时间业务代码,想从“会用模式”走向“用对模式”的朋友。
1. 先聊清楚:真实项目里的设计模式到底解决什么问题
1.1 别把模式当考点,它是项目演进的“止损工具”
很多教材讲设计模式,开头就是UML图、类图、参与者。说实话,这些是考试需要的,不是项目需要的。真实项目里没有哪个产品经理会告诉你“这里请用策略模式”,他只会告诉你:现在有3个渠道要做,以后可能还有第4个、第5个。你听到这句话,脑子里能立刻做出判断——这里得留一个扩展点,这个时候设计模式就出场了。
所以我的第一个观点是:设计模式本质上是对“变化”的预判和隔离。业务代码最大的敌人不是代码量,而是变更的不可控性。你今天写一个类,三行逻辑,很爽;半年后这个类长到两千行、里面塞了十几种业务类型,每次需求过来都战战兢兢,因为你不知道改这里会不会炸掉别的地方。这就是变化的代价,而设计模式的价值,就是把这种代价控制在一个可接受的范围内。
之前我带过一个新人,他看了一本设计模式的书,特别兴奋,把项目里所有类都套上抽象接口,三层继承起步,最后代码评审的时候大家看着那一堆空接口发呆。我就问他:你这几个抽象类,现在分别有几种实现?他说只有一种。那我说这个抽象现在没有任何收益,它只是让代码变难读。模式必须跟着变化走,变化还没来,模式就是负债。
1.2 23种模式在真实项目中的真实分布
关于23种设计模式,热词里搜得到各种图解,这里我想给一份基于真实代码库的分布参考。根据我这些年看过和写过的后端项目统计,java和C++业务代码里,模式的出现频率其实是极度不均匀的。
高频出现的,基本就是那么几个:策略模式、工厂模式(尤其是简单工厂和抽象工厂的变体)、模板方法模式、观察者模式、单例模式、建造者模式。这六个我可以说,在任何一个上了规模的后端项目里,只要代码不是烂到极致,都能找到影子。中频的包括状态模式、装饰器模式、适配器模式、代理模式,它们往往集中在特定领域,比如订单状态机、流式处理、远程调用封装。
低频的就有意思了,像享元模式、桥接模式、组合模式、备忘录模式,很多写了五六年代码的人都没在实际项目里用过一次。这不丢人,因为这些模式本身就是为了特定场景设计的,场景没出现,硬套就是灾难。如果你还在校或者刚工作,看到某些模式觉得“这有什么用”,恭喜你,你的直觉是对的。设计模式不是需要全部掌握才能干活,而是需要理解它们各自解决什么类型的问题,等出了问题自然会被触发。
我用一个表格来说明这个分布特征:
| 使用频率 | 模式举例 | 典型场景 |
|---|---|---|
| 高频 | 策略、工厂、模板方法、观察者、单例、建造者 | 支付渠道、折扣规则、流程编排、事件通知、配置读取、复杂参数对象 |
| 中频 | 状态、装饰器、适配器、代理 | 订单状态流转、日志增强、第三方接口适配、AOP与远程调用 |
| 低频至罕见 | 享元、桥接、组合、备忘录 | 图形编辑器、大规模对象复用、设计工具类项目 |
这个分布不是我编的,是大多数业务系统用脚投票的结果。原因很简单,业务系统的核心矛盾是需求变化快,而上面那六个高频模式正好对应了需求变化的几种典型形态:分支膨胀、行为切换、流程固定、事件扩散、全局唯一、参数太多。搞懂这六个,你在真实项目里已经能解决80%的问题。
1.3 判断“该不该用模式”的三种信号
很多人纠结一个问题:我写代码的时候,到底什么时候该上设计模式,什么时候不该?这里我给出三个特别实际的信号,只要命中任意一个,你就该认真考虑模式重构了。
信号一:if-else或者switch在持续膨胀。你数一下某个方法里的分支数,超过八个,而且每个分支后面跟着几十行代码,这个逻辑已经不可能靠肉眼维护了。这个时候不是要不要用模式的问题,是必须在下次需求来之前把它拆掉。
信号二:同一个业务行为有多个实现,并且经常互相切换。比如计费规则一会儿按固定价格,一会儿按重量,一会儿按会员折扣;或者消息通知一会儿发短信,一会儿发邮件,一会儿推企微。切换不是靠配置文件改几个参数就能完成的,而是每次都要改代码逻辑,这就是策略模式最典型的味道。
信号三:新增一种业务类型时,你需要改动旧代码的地方超过两处。比如新增一个订单类型,你需要去订单创建逻辑里加一个分支,去订单校验逻辑里加一个分支,去订单查询逻辑里再加一个分支,每处都埋着一颗雷。这是开闭原则没有守住的表现。开闭原则不是让你把所有代码都抽象化,它的真实含义是:新增功能时,你能尽量少改动已经验证过的代码,那你的系统就是健康的。
我遇到很多项目,不是没有用设计模式,而是模式用得极其拧巴:该用策略的地方用if-else硬堆,该用工厂的地方让调用方自己new,结果代码既没有面向对象也没有结构化,每次迭代都像是在高速公路上换轮胎。所以真正的问题不是“要不要学设计模式”,而是“什么时候你的代码已经发出了需要模式的信号”。
2. 实战选型:四个最常见场景的模式落地
2.1 订单状态流转:状态模式还是状态机
订单系统是状态模式最经典的课堂案例,真实项目里也确实绕不开。一个订单从创建、待支付、已支付、已发货、已完成,中间还可能插入取消、退款,这个流转过程如果用if-else写,很快会变成一团乱麻。困难不在于状态本身,而在于每个状态下能执行的操作不一样,而且操作还会触发状态的迁移。
举个例子,支付成功这个操作,在待支付状态下是合法的,在已完成的订单上再支付那就得报错。用状态模式的做法,是把每个状态封装成一个类,状态类里定义它能处理的动作。
public interface OrderState { void pay(OrderContext context); void ship(OrderContext context); void complete(OrderContext context); } public class UnpaidState implements OrderState { @Override public void pay(OrderContext context) { System.out.println("订单已支付"); context.setState(new PaidState()); } @Override public void ship(OrderContext context) { throw new IllegalStateException("未支付订单不能发货"); } @Override public void complete(OrderContext context) { throw new IllegalStateException("未支付订单不能完成"); } }这样每个状态都清楚自己能做什么、不能做什么,迁移逻辑跟着状态走,而不是堆在调用方的一大段switch里。这是状态模式的核心收益。
但我在实际项目里也会遇到另一个选择:直接用一个状态机框架,比如Spring StateMachine或者一些开源的轻量状态机,通过配置DSL管理状态迁移。两种方案怎么选?我的经验是这样的:如果状态数量少(五六个以内)、迁移规则简单、而且团队对状态模式熟,那就自己写状态模式,代码完全可控,不用引入额外依赖。如果状态数量多、迁移条件复杂,比如订单还会被人为干预、逆向流程多、不同来源的订单规则还不一样,那就直接上状态机框架,把迁移表配清楚,反而更省心。模式不是教条,能解决问题的都是好方案。
2.2 多渠道支付与折扣规则:策略模式如何干翻if-else
如果说状态模式解决的是“随着状态迁移行为不同”,那策略模式解决的就是“同一个行为有多个算法族,可以自由切换”。支付渠道是策略模式最典型的落地场景:微信支付、支付宝、银联、PayPal,它们的请求参数、签名方式、回调处理完全不同,但业务上的语义都是“发起支付”。
策略模式的三个核心元素分别是策略接口、策略实现和策略选择器。定义接口的时候记住一点,接口的抽象程度要是“业务动作”,而不是“底层实现”。比如:
public interface PayStrategy { String payType(); PayResult pay(PayRequest request); }每个渠道实现一个类,微信支付有WechatPayStrategy,支付宝有AlipayStrategy,然后由一个工厂或者注册表根据payType返回对应的策略。这样一来,新增一个支付渠道时,你只需要写一个实现类,把它注册进去,调用方完全不用改。这满足的可不只是“代码整洁”,更重要的是降低了风险——老渠道的代码你一行都没碰,怎么可能被新渠道搞挂?
还遇到过一种变体,设计模式书里叫策略枚举,我称之为“策略表驱动”。就是把策略对象放进一个Map,key就是渠道代码,value就是策略实例,运行时直接map.get(type)取出来调用。在Spring环境下更简单,直接把所有策略实现注入到一个Map,免去手写工厂:
@Component public class PayStrategyFactory { private final Map<String, PayStrategy> strategyMap; public PayStrategyFactory(List<PayStrategy> strategies) { strategyMap = strategies.stream() .collect(Collectors.toMap(PayStrategy::payType, Function.identity())); } public PayStrategy getStrategy(String payType) { PayStrategy strategy = strategyMap.get(payType); if (strategy == null) { throw new IllegalArgumentException("不支持的支付类型: " + payType); } return strategy; } }这套代码写完之后,原先那个三百多行的分支方法直接被删掉了,调用方变成两行。我每次看到这种删代码的重构都特别舒坦,因为删掉的不只是代码,还有未来的认知负担。
2.3 订单事件通知:观察者模式解耦业务链路
业务系统里还有一个高频痛点:一个核心动作发生后,后面跟着一长串“额外处理”。订单创建后要发短信、发邮件、推送消息、更新积分、记录审计日志、同步到搜索服务……如果这些逻辑全部写在订单创建的service方法里,那每次加一个“额外处理”你都得改动这个核心方法,而且订单创建逻辑会越来越长,到后期一个方法几百行,没人敢动。
观察者模式就是来解决这个问题的。事件发布者只负责“我创建了一个订单”,剩下的事它一概不管;监听者各自维护自己的逻辑,你想加一个处理就新增一个监听器,不用碰发布者。
public class OrderService { private final ApplicationEventPublisher publisher; public void createOrder(OrderDTO dto) { Order order = saveOrder(dto); // 核心业务完成,发布事件,后续处理全部异步解耦 publisher.publishEvent(new OrderCreatedEvent(order)); } } @Component public class SmsListener { @EventListener public void onOrderCreated(OrderCreatedEvent event) { // 发送短信通知 } } @Component public class PointsListener { @EventListener public void onOrderCreated(OrderCreatedEvent event) { // 累计积分 } }在Spring里,ApplicationEventPublisher加上@EventListener就是观察者模式的标准实现,不需要任何额外依赖。C++项目里也可以用信号槽机制,比如Qt的信号槽,或者自己维护一个观察者列表,原理都是一样的。
要特别注意一点,观察者模式虽然好,但别盲目把同步执行的事情都改成事件。核心链路里如果某个监听器执行出错必须导致主流程失败,那就不要走事件;只有那些“不影响主流程成败”的旁路逻辑,才适合用观察者模式解耦。真实项目里我倾向于把事件处理和主流程用事务边界隔离,监听器里还能选择同步或异步执行,这需要根据业务对一致性的要求做权衡。
2.4 复杂对象构建:建造者模式与工厂模式的配合
第三类的实践是建造者模式。我最早觉得建造者模式的产品就是Lombok的@Builder,直到遇到那种参数多达十几个、而且不是所有参数都必须填的对象,才知道Builder有多香。比如一个报表查询条件对象,可能有分页参数、筛选条件、排序规则、权限控制、时间范围,全用setter方法去赋值,调用方代码会非常散;用构造器直接传,又容易把参数顺序搞错。
建造者模式解决的核心问题是“复杂对象的组装过程需要清晰可控”。它让调用变得语义化:
ReportQuery query = ReportQuery.builder() .pageNo(1) .pageSize(20) .startTime(start) .endTime(end) .userType("vip") .build();这种写法相当于把构造过程拆成链式调用,每一步都清清楚楚,将来新增可选参数只需要在Builder上加一个字段和一个方法,不影响现有调用。这是Java后端我认为最值得养成的习惯之一,C++里用Builder模式也常见,特别是当对象涉及多个配置项时。
建造者和工厂经常被拿来一起比较。我的理解是:工厂关注“创建一个什么东西”,建造者关注“那个东西内部的细节怎么组装”。比如一个支付策略,用工厂创建,因为策略是“选一个实现”;一个复杂的支付请求参数,用建造者构建,因为它的重点在于“参数怎么拼”。二者不是互斥的,在实际代码里经常会配合使用——工厂负责创建某个对象,这个对象内部的复杂结构由Builder来完成,各管一段。
3. 一次真实重构实录:从680行if-else到策略加工厂的组合改造
3.1 重构前的痛点与代码现状
讲一个真实的项目案例。当时我接手一个对外的消息推送服务,核心方法里有一个大方法,将近680行,里面全是if-else按渠道类型分发的逻辑:短信渠道、邮件渠道、App推送、企微机器人、Webhook。每个渠道有自己的报文格式、签名算法、发送前校验、发送后处理。但是公共流程是一样的:先校验参数、再组装报文、再发送、再记录日志、再返回结果。
到了后期,这个方法的恐怖之处在于:每当要增加一个新渠道,你需要明白这个方法的整体脉络,然后在正确的位置插入你的一小段代码;稍不留神,嵌在中间的return或者continue就会把你绕进去。而且代码里还有几个坑,比如某个渠道的报文组装逻辑在method A里写了一半,另半截在method B里,找人排查的时候得两头看。
更要命的是需求还在频繁变化。一会儿要求所有渠道发送前统一加一个签名头,一会儿要求某两个渠道的异常处理方式不同,一会儿要求记录发送耗时。这些需求每一个都意味着你要动那个680行的大方法,而每次动它都像是在雷区里迈步。
代码评审的时候我们也讨论过要不要推倒重来,但推倒重来意味着业务验证成本很高,老板不可能给你一个专窗来做这件事。所以最后决定采用渐进式重构:先把接口定义好,然后一个一个渠道往新结构里搬,搬完一个验证一个,不追求一天完成。
3.2 改造步骤:接口定义、策略注册、工厂组装
第一步是定义抽象接口。这个接口一定要基于业务动作来定义,而不是基于某个渠道的个性行为:
public interface MessageSender { String channelType(); SendResult send(MessageContext context); }MessageContext里面放的是公共字段:收件人、标题、内容、附加参数等。这样做的用意是:让调用方面对一个统一的抽象,不需要关心具体渠道的差异。
第二步是把公共流程提取成一个模板方法。我们当时没有把模板方法做成抽象类的继承结构,而是用了一个组合方式的公共流程类,内部持有各个渠道的Sender,然后统一执行“参数校验、发送前处理、调用渠道发送、发送后处理、异常记录”这几个步骤。这个公共流程的方向是固定的,渠道策略只负责中间的发送环节,这样的好处是公共流程的变更只需要改一处。
第三步是渠道策略类的迁移。每搬一个渠道,就新建一个Sender实现类,把原来if-else分支里的代码原封不动地挪过来,挪完对比输出。比如短信渠道变成SmsSender,邮件变成EmailSender。
第四步是组装工厂,在Spring环境下就是前面说的依赖注入:
@Component public class MessageSenderFactory { private final Map<String, MessageSender> senderMap; public MessageSenderFactory(List<MessageSender> senders) { senderMap = senders.stream() .collect(Collectors.toMap(MessageSender::channelType, Function.identity())); } public MessageSender getSender(String channelType) { MessageSender sender = senderMap.get(channelType); if (sender == null) { throw new UnsupportedOperationException("不支持的渠道: " + channelType); } return sender; } }改造完成后的调用方只剩一行核心逻辑:拿到工厂,按渠道类型取Sender,调用send方法。新增渠道不再是“改老方法”,而是“新建一个类”,老代码碰都不用碰。我还额外做了一个开关,让每个渠道可以通过配置决定是否启用,暂时没谈拢的渠道直接停用,不会影响其他渠道。
3.3 重构后的收益与代价
这个重构的收益非常明显。首先是代码量从680行降到了每个渠道大约80到150行,并且每个类职责单一,可读性提升了不止一个档次。其次是测试好写了,以前你要造各种渠道的数据然后走那个庞大的方法,现在单测一个渠道只需构造对应Sender,传入上下文断言结果,复杂度大大降低。
代价也真实存在。类数量变多了,这个是策略模式绕不开的——一个渠道一个类,十几个渠道十几个类。有些人会觉得类太多了,但我认为类多不是问题,问题在于类之间的组织是否清晰,工厂把这些类归拢在一起,结构上是清楚的,理解起来并不难。
还有一点需要诚实说:刚开始重构那两周,团队效率其实是下降的,因为代码从一个结构变成另一个结构,大家需要在新的文件里找老逻辑。这个阵痛期很关键,一定得有代码评审和回归测试兜底。等第一批渠道迁移完成后,效率就会回升,后续新增渠道甚至不需要问别人,自己照着模式写就行。
4. 真实项目中的常见坑与排查技巧
4.1 过度设计:为了模式而模式的辨别方法
凡是写过几年代码的人,多多少少都见过“过度设计”。特征很明显:类的继承层次超过三层,有些抽象接口只有一种实现,方法之间绕来绕去,读代码的人要想半天“这个抽象的目的是什么”。这种代码往往是一个很聪明的人,在需求还模糊的时候就基于想象做了一层完美架构,结果想象的功能没来,架子反而成了累赘。
我有个判断标准,叫“两拍原则”:如果你预测这个抽象在未来两个迭代之内会被用到,那现在就做;如果你自己心里也没底,需要靠“可能以后需要吧”来说服自己,那就不做。变化是逼出来的,不是预判出来的。真实项目的节奏是需求驱动代码演进,不是代码架构驱动需求设计。
那有人会问:如果以后真的需要,不是又要改一遍吗?改一遍的成本,远低于长期背着一个错误抽象的成本。而且说实话,当一个“以后要用的抽象”真正来临的时候,你对业务的理解已经比当初深得多,重新抽象出来的结构往往更合理。
4.2 抽象泄漏与接口膨胀:怎么控制边界
另一个常见坑是接口定义失控。策略接口的参数一开始设计得很具体,比如支付策略接口直接传orderId和amount两个参数,结果新接入一个渠道,需要tenantId、couponId、sceneCode……接口被迫改签名,所有实现类都得跟着变。这就是典型的“抽象泄漏”——接口没有封装变化的点,把每个实现的具体需求都映射成了参数,接口变得越来越臃肿。
解决方案是参数对象封装。定义一个请求上下文对象,把所有可能变化的字段都放在里面,接口只接收这个上下文:
public interface PayStrategy { PayResult pay(PayContext context); }以后新增字段,直接往PayContext里加,接口签名不用动,实现类按需读取。这个思路同时也要克制:不要为了“灵活”把PayContext变成一个无限大的Map,每个字段都应该有明确的业务语义,否则又会掉进另一个陷阱。
还有一个信号值得警惕:如果某几个策略实现类之间开始出现大量重复代码,说明抽象边界可能划错了。比如三个渠道的Sender里都有一段相同的报文签名逻辑,这时候就应该把它抽到一个公共工具类或者基类里。策略模式只管“行为切换”,可公共逻辑该抽还是得抽,不然你会得到一堆长得差不多的类,改一个漏三个,跟改if-else也没啥区别。
4.3 团队协作时设计模式的落地节奏
设计模式这事儿,单兵作战和团队作战完全是两码事。你自己写得很开心,别人看不懂,代码一交接就废了。所以团队里推动设计模式落地,节奏特别重要。
我的建议是:先找一个痛点最明显的模块做试点,比如那个让你每次改需求都心跳加速的大方法,不要一上来就全面铺开。把重构方案在代码评审会上讲清楚,让团队成员理解目标和结构,然后分批迁移,每迁移完一部分跑一遍完整回归。
另一个细节是命名规范。策略实现类、工厂类的命名尽量统一,比如XxxStrategy、XxxStrategyFactory、XxxHandler、XxxEvent、XxxListener,让新人顺着命名就能猜出结构。不要这个模块叫Service、那个模块叫Action,风格混杂会极大增加理解成本。
还有一点:设计模式重构最好跟着业务迭代走,不要专门开一个“重构月”把系统全部推翻。我做过的几次成功重构,都是借着“加一个新渠道”或者“改一个现有规则”的契机,顺手把旁边那一坨if-else拆掉。这样做的风险最小、反馈最快,同事也不会觉得你在搞一个看不见摸不着的技术秀。
我自己这几年最大的体会是,设计模式的重点从来不是UML图,而是对变化的感知。真正的高手不是把所有模式都用上,而是能在最需要的地方,用最小的成本把未来的变化挡住。先学会识别代码里的坏味道,再考虑用哪个模式去化解,顺序不能反。最后再分享一个小技巧:每次写完一个模式改造,问问自己,如果明天新增一个业务类型,我的改动是一行还是三个文件?如果答案是“一行”,那这个模式就用到位了;如果答案是“先翻半天代码”,那就还得继续收拾。