1. 先搞清楚行为型模式到底在管什么事
行为型模式(Behavioral Patterns)这组东西,在 GoF 的 23 种设计模式里占了 11 个席位,是所有分类里数量最多的。它和创建型、结构型最大的区别在于:创建型模式管的是对象怎么 new、怎么产生;结构型模式管的是类和对象之间怎么组合成更大的结构;而行为型模式管的是对象之间在运行时怎么协作、怎么分配责任、怎么通信。说白了,前面两类解决的是"代码长什么样"的问题,行为型解决的是"代码跑起来之后怎么互动"的问题。
我早年学这块的时候,总觉得"行为型"三个字太虚,后来换了个角度才真正打通:这一组模式解决的核心痛点,就是把会变化的行为从稳定的业务骨架里剥离出来。举个例子,同一个业务流程,不同场景下细节不一样;同一个对象,不同状态下表现完全两样;同一个事件发生了,好几个模块都要响应。这些"变化点"如果直接写死在代码里,系统很快就会变成一团 if-else 和重复代码,改一个需求牵一发动全身。
这一篇(上)先覆盖五个日常业务代码里出现频率最高的:模板方法、策略、观察者、命令、迭代器。状态模式因为和策略模式经常被拿来做对比,我也一并讲了,凑成六个一起聊。这几个模式里,策略和观察者几乎可以说是中大型项目的标配,你随便翻一个后端项目源码,都能看到它们的身影。
适合什么读者来看?如果你已经知道继承、多态、接口这些基础概念,又想搞明白这些模式在实际项目里到底怎么落地、而不是停留在背概念和 UML 图的阶段,那这篇就是给你准备的。每个模式我都会给一个能直接跑起来的示例代码,然后重点讲实际项目中容易踩的坑——这些才是常规教程里不会写的东西。
1.1 三种模式家族的职责边界
先画一条清晰的线。创建型模式,比如单例、工厂、建造者,回答的问题是"对象怎么来";结构型模式,比如适配器、装饰器、代理,回答的问题是"对象怎么组合、怎么适配";行为型模式回答的问题是"对象之间怎么互相配合、怎么把算法和责任分出去"。这个边界很重要,因为很多人写代码写着写着就把策略模式写成了工厂模式,或者把命令模式写成了策略模式,根源就是没分清模式要解决什么问题。
判断方法很简单:如果你的代码里有一个接口、多个实现类,但你在写代码的时候根本不关心调用方是谁,那大概率是策略模式;如果你的代码里有一个"请求发起方"和一个"请求执行方",你想把请求本身存起来、发出去、撤销掉,那是命令模式;如果你的核心问题是"某个状态发生变化要通知一堆人",那是观察者模式。不同的关注点,对应的模式完全不同。
1.2 本篇覆盖范围与学习路径
我在选例子上花了点心思,没有用教科书里烂大街的"咖啡与茶""遥控器与电灯",而是尽量贴近真实业务场景:数据导入流程、订单运费计算、支付成功通知、编辑器撤销重做、自定义集合遍历、订单状态流转。这几个例子分别对应模板方法、策略、观察者、命令、迭代器、状态,全部用 Java 写,个别地方我会顺手提一下在其它语言里的对应形态,方便你横向理解。
建议你不要只"看"代码,而是把每个例子自己敲一遍。设计模式这东西有个特点:看懂只需要十分钟,用对需要练上很久。尤其是策略和状态、命令和策略这两组"易混兄弟",不亲手写几个场景,面试被问到"区别是什么"的时候,很容易答得模棱两可。
2. 模板方法模式:把流程定死,把细节放开
2.1 核心思想:不变的骨架,可变的步骤
模板方法模式(Template Method)的核心一句话就能说清:在父类里定义一个算法的骨架,把其中某些步骤延迟到子类去实现。这样整个流程的顺序是固定的,但每一步的具体做法可以由子类决定。
为什么需要这个东西?因为在真实业务里,流程的骨架往往是业务专家定的,轻易不能改动,但每个环节的实现细节却会因场景而异。如果不用模板方法,最常见的做法就是把这段流程写成一个大方法,里面塞满 if-else,根据某个入参走不同的分支。这么做第一次写很爽,第二次改就要了命——每加一种场景,都要改这个公共方法,改着改着就把别人的逻辑改坏了。
模板方法通过"继承+多态"把流程固定下来。父类里的骨架方法通常是 final 的,子类只能实现声明好的抽象方法,或者覆盖某些"可选步骤",但改不了整体顺序。这个约束是模板方法最重要的纪律。
2.2 实例:统一的数据导入流程
举个我实际写过的场景。项目里要从 CSV、JSON、Excel 三种文件导入用户数据,导入流程是一样的:加载文件、解析内容、校验数据、落库、记录日志。但每一种文件的解析方式完全不同,校验规则也有差异。
我先定义一个抽象父类:
public abstract class AbstractDataImporter { // 骨架方法,用 final 防止子类篡改流程 public final ImportResult importData(String filePath) { String content = loadFile(filePath); List<RawRecord> records = parseContent(content); List<String> errors = validate(records); if (errors.isEmpty()) { saveRecords(records); return ImportResult.success(records.size()); } return ImportResult.failure(errors); } protected abstract String loadFile(String path); protected abstract List<RawRecord> parseContent(String content); protected List<String> validate(List<RawRecord> records) { // 默认校验规则,子类可覆盖 return Collections.emptyList(); } protected void saveRecords(List<RawRecord> records) { // 默认落库逻辑,子类可覆盖 } }然后每个文件格式写一个子类。CSV 导入器实现 loadFile 和 parseContent,如果 CSV 需要多一个"跳过表头"的参数,就在子类里加构造参数,完全不碰父类。JSON 导入器同理,只是 parseContent 里用的是 JSON 解析库。将来要新增 XML 导入,只需要再写一个子类,父类和其它导入器一行都不用改。
这中间有个容易被忽略的点:validate和saveRecords我留的是普通方法而不是抽象方法,这叫钩子方法。钩子方法的价值在于:某些步骤大多数场景下都有默认实现,只有少数场景需要改。全部声明成抽象方法会让子类被迫实现一堆用不到的方法,钩子方法就灵活得多。
2.3 实操心得:钩子方法与"骨架被篡改"的坑
用模板方法几年,我踩过几个坑值得说一下。
第一,骨架方法一定要加 final。不加的话,总会有个"聪明"的同事直接覆盖整个 importData 方法,把流程改得面目全非,然后所有子类的行为都变得不可预测。final 在这里不是语法糖,是团队纪律的强制约束。
第二,钩子方法不要放太多。一个模板方法里有三五个钩子还能接受,超过七个基本上就是过度设计了,子类看都看不懂哪些必须实现、哪些可以覆盖。我见过一个数据同步的模板类,抽象方法加钩子方法一共十一个,每个子类都几百行,最后没人分得清哪些逻辑该放哪一层。模板方法解决的是"骨架复用",不是"把类堆成上帝类"。
第三,父子类的模板方法如果层次太深,调试会很痛苦。我建议最多两层:一个抽象基类定义骨架,一层具体的子类实现差异。三层以上的继承链,出问题的时候你根本不知道哪个方法在哪个层级被覆盖了,排查成本直线上升。
3. 策略模式:把算法装进盒子,随时换
3.1 核心思想:面向接口编程,而不是面向一堆 if-else
策略模式(Strategy)解决的是"算法族"的替换问题。它的定义是:定义一组算法,把每个算法都封装起来,并且使它们之间可以互换。这里的关键词是封装和互换——算法不再是散落在业务代码里的零散判断,而是一组实现了同一个接口的独立类。
我在实际项目里见到最多的策略模式用法,就是替代那种长到离谱的 if-else 链。比如一个订单模块,运费计算规则有十几种:普通快递、冷链、同城急送、大件物流……每个规则都有自己的计费逻辑。如果你在业务方法里这样写:
if (type.equals("NORMAL")) { // 普通快递计费 } else if (type.equals("COLD")) { // 冷链计费 } else if (type.equals("SAME_CITY")) { // 同城计费 }每加一种配送方式,就要改动这个聚合了很多业务逻辑的方法,而且这个方法会越来越长。更重要的是,这些计费规则如果分属不同团队维护,大家都要在同一个方法里改代码,冲突简直不可避免。
策略模式的解法很干净:定义一个策略接口,每种计费规则一个实现类,业务方只面向接口编程,运行时根据实际类型选择具体的策略对象。
3.2 实例:订单运费计算
先看策略接口:
public interface ShippingCostCalculator { double calculate(Order order); }然后每种配送方式写一个实现:
public class NormalShippingCalculator implements ShippingCostCalculator { @Override public double calculate(Order order) { return order.getWeight() * 5 + 3; } } public class ColdChainCalculator implements ShippingCostCalculator { @Override public double calculate(Order order) { return order.getVolume() * 10 + order.getWeight() * 2; } }业务侧的使用方式:
public class ShippingService { private final Map<String, ShippingCostCalculator> calculatorMap; public ShippingService() { this.calculatorMap = new HashMap<>(); this.calculatorMap.put("NORMAL", new NormalShippingCalculator()); this.calculatorMap.put("COLD", new ColdChainCalculator()); } public double getShippingCost(String shippingType, Order order) { ShippingCostCalculator calculator = calculatorMap.get(shippingType); if (calculator == null) { throw new IllegalArgumentException("unsupported shipping type: " + shippingType); } return calculator.calculate(order); } }在这个例子里,calculatorMap相当于一个策略注册表,把类型字符串映射到具体的策略实现。新增一种配送方式时,只需要新写一个实现类,然后在注册表里加一行映射。计价逻辑发生变化的场景,也只需要改对应的策略类,不会波及整个 ShippingService。
3.3 Spring 项目里的策略模式常态
如果在 Spring 项目里写,策略模式会更丝滑。把策略实现类全部注册成 Bean,然后用一个 Map 一次性注入:
@Service public class ShippingService { private final Map<String, ShippingCostCalculator> calculatorMap; public ShippingService(Map<String, ShippingCostCalculator> calculatorMap) { this.calculatorMap = calculatorMap; } public double getShippingCost(String shippingType, Order order) { return calculatorMap.get(shippingType).calculate(order); } }Spring 会把所有 ShippingCostCalculator 类型的 Bean 收集到这个 Map 里,key 默认是 Bean 的名字。你只要在每个实现类上加上@Component("COLD")这样的注解,注册过程就自动完成了。这个用法我在好几个项目里都见到过,算得上是 Spring 和策略模式结合的"标准答案"。
不过有个细节要注意:策略对象最好是无状态的,也就是策略类里不要放实例变量来保存调用过程中的中间数据。因为我们通常会把策略类注册成单例,如果策略内部有可变状态,并发场景下就会互相污染,出现诡异的 bug。如果确实需要传参数,应该通过方法的参数传进去,而不是存在策略对象里。
4. 观察者模式:有事广播,没事互不打扰
4.1 核心思想:发布-订阅的面向对象版本
观察者模式(Observer)解决的是"一对多依赖"的问题:当一个对象(主题)的状态发生变化时,所有依赖于它的对象(观察者)都能得到通知,并自动更新。
我用最接地气的方式解释:你就是那个主题,你的朋友圈就是观察者。你发了一条动态,所有关注你的人都会收到通知。至于谁点了赞、谁看了没反应,你不需要知道,你只需要把动态发出去就完事了,系统会自动通知到每个关注者。
在面向对象的世界里,这套机制的表现形式是:观察者把自己注册到主题上,主题在状态变化时遍历观察者列表、逐个调用它们的更新方法。主题完全不需要知道观察者内部是怎么处理通知的,观察者也不关心其它观察者是否存在,两边通过一个接口保持"藕断丝连"的关系。
这个模式的精髓就在于解耦。被观察者和观察者之间没有直接的依赖关系,只有一层薄薄的接口约定。新增一个观察者,主题一行代码都不用改。
4.2 实例:订单支付成功后的通知链
很典型的一个业务场景:订单支付成功后,系统要做的事包括——发短信给用户、更新库存、通知财务记账、给运营发企微消息、可能的积分入账。如果把这些逻辑全部写在支付成功的业务方法里,这个方法会变得极长,而且每次加一个新通知需求都要改动核心支付代码。
用观察者模式重构,思路是这样:订单模块作为主题,只负责在支付成功后触发一个事件通知;各个需要响应的模块作为观察者,各自实现自己的处理逻辑。
public interface OrderEventObserver { void onOrderPaid(Order order); }public class SmsNotifier implements OrderEventObserver { @Override public void onOrderPaid(Order order) { // 发送短信给用户 } } public class InventoryUpdater implements OrderEventObserver { @Override public void onOrderPaid(Order order) { // 扣减库存 } } public class FinanceRecorder implements OrderEventObserver { @Override public void onOrderPaid(Order order) { // 记录财务流水 } }订单支付方法里只需要一行调用:
orderEventObservers.forEach(observer -> observer.onOrderPaid(order));观察者列表可以在启动的时候统一注册。以后要新增一个"支付后给用户发放优惠券"的功能,只需要写一个新的 Observer 实现类,再注册进去。订单支付的核心流程完全不用动,这就是观察者模式在业务代码里最实在的价值。
4.3 观察者 vs 消息队列:什么时候不用观察者
实际项目中,很多人会问:这不就是消息队列干的事吗?直接用 MQ 不香吗?我的经验是,这两者不是一个层面的东西。观察者模式是进程内的事件同步分发,消息队列是跨进程的异步解耦。要是你的通知事件需要可靠投递、需要支持多个服务订阅、需要削峰填谷,那确实应该上消息队列。
但很多场景其实用不上 MQ。比如上面说的支付成功更新库存和记财务流水,如果服务部署在一起、处理逻辑也不复杂,直接同步调用观察者就够了,引入 MQ 反而增加了运维复杂度和故障排查难度。项目里我见过不少"为了架构而架构"的案例,一个单体应用硬塞消息队列,最后消息积压、重复消费、丢失,问题一大堆。技术选型的第一原则永远是:解决问题为主,架构演进为辅。
另外说一个观察者模式在 Java 里的实际应用:Swing 和 JavaFX 的按钮点击事件、Spring 的 ApplicationEvent 机制,本质上都是观察者模式。你在 Spring 里发布一个自定义事件,用@EventListener标注处理方法,底层就是观察者思想的具体实现。理解了模式本身,再看这些框架的用法就很容易触类旁通。
5. 命令模式:把"请求"本身变成对象
5.1 核心思想:操作可以被存储、排队、回放
命令模式(Command)在行为型模式里算是抽象程度比较高的一个。它的核心是:把请求封装成对象,这样请求就可以作为参数传递、保存在列表里、随时执行或者撤销。
这句话听起来有点绕,我拆开讲。在传统的代码里,方法调用是即时发生的:A 调用了 B 的方法,B 立刻就执行了。但有些场景下,我们不想让"发起请求的人"和"执行请求的人"直接打交道。我们希望把请求先"存起来",在合适的时候再执行;或者执行完之后还能"回退"。
举一个生活中最通俗的例子:餐厅点餐。你(请求发起者)告诉服务员你要吃什么,服务员(命令对象)把订单记下来,然后交给后厨去做(执行者)。这里的关键是,你根本不需要认识后厨,你也不需要知道菜是怎么做出来的,你只需要把"点餐"这个命令传递给中间人。如果临时想换菜,还可以拿着订单去撤销、去修改,因为订单是一张实实在在的纸,而不是一句已经说出口的话。
命令模式用四个角色来描述这个过程:
- 命令(Command):封装了请求的接口,里面有一个 execute 方法。
- 具体命令(ConcreteCommand):命令的具体实现,绑定了一个接收者。
- 接收者(Receiver):真正干活的对象。
- 调用者(Invoker):持有命令,负责触发执行。
在文本编辑器里,"撤销重做"功能就是命令模式的经典应用场景。每个操作(输入文字、删除、格式化)都封装成一个命令对象,execute 执行操作,undo 回退操作。编辑器把所有命令压进一个历史栈,撤销时弹出栈顶调用 undo,重做时再从另一个栈里恢复。
5.2 实例:文本编辑器的撤销重做
我用一个简化版来看代码长什么样。先定义命令接口:
public interface Command { void execute(); void undo(); }然后实现"插入文字"这个具体命令:
public class InsertTextCommand implements Command { private final TextEditor editor; private final String text; public InsertTextCommand(TextEditor editor, String text) { this.editor = editor; this.text = text; } @Override public void execute() { editor.append(text); } @Override public void undo() { editor.removeLast(text.length()); } }编辑器作为调用者,维护一个命令历史栈:
public class TextEditor { private StringBuilder content = new StringBuilder(); private Deque<Command> history = new ArrayDeque<>(); public void executeCommand(Command command) { command.execute(); history.push(command); } public void undo() { if (!history.isEmpty()) { Command command = history.pop(); command.undo(); } } public void append(String text) { content.append(text); } public void removeLast(int length) { content.delete(content.length() - length, content.length()); } }这段代码的关键在于:命令对象保存了执行撤销所需的全部信息。在我们的例子里是"要删除的文本长度",复杂一点的场景可能要保存操作前的位置、被删除的文本内容,甚至光标状态。这也是命令模式在实现层面对开发者要求最高的地方——每个命令都必须"记得"足够多的现场信息,才能保证 undo 是精确的。
5.3 命令模式在现代代码里的简化形态
老实说,在很多现代框架里,命令模式已经以更轻量的方式存在了。拿 Java 8 的Runnable和函数式接口来说,把一段逻辑封装成对象传递,这种能力和命令模式其实是同一个思想。你在线程池里提交一个任务,提交的就是一个命令对象;你使用Consumer、Function这类函数式接口,本质上也是一种命令的传递。
但函数式接口和完整的命令模式有一个重要区别:函数式接口通常只封装了"做什么",没有"怎么撤销"。所以在需要撤销、重做、事务性回滚的场景里,完整的命令模式依旧不可替代。比如在一个业务系统里,你要批量执行一组操作,如果某个操作失败,需要把之前成功执行的操作全部回滚,这种"补偿性事务"的场景如果用命令模式实现,每个命令都实现 undo 方法,回滚逻辑写起来会非常自然。
这里有个经验要注意:命令模式会把类的数量翻倍。一个操作一个命令类,十个操作就是十个类,如果每个命令里还有接收者参数,代码量会膨胀得很快。建议只在确实需要撤销、排队、日志回放这些能力的时候才用命令模式,单纯为了"解耦"而强行使用,很容易把简单业务搞复杂。
6. 迭代器模式:让别人遍历,但不暴露你的底裤
6.1 核心思想:把遍历逻辑从集合中抽离
迭代器模式(Iterator)可能是这六个里大家最熟悉的一个,因为几乎所有主流语言都内置了迭代器。它的定义是:提供一种方法顺序访问一个聚合对象中的各个元素,而不暴露该对象的内部表示。
关键词在后半句——不暴露内部表示。迭代器模式最大的价值,不是"让你能循环遍历",而是"让你能循环遍历,但看不到集合底层到底是什么结构"。你用迭代器遍历的时候,根本不需要关心底层是数组、链表、树还是别的什么结构,只需要调用 next、hasNext 这两个方法,遍历就完成了。
这对代码的可维护性提升是巨大的。举个例子,你的系统早期用 ArrayList 存数据,后来发现读取频繁、插入删除也多,想换成 LinkedList。如果所有遍历代码都直接操作 ArrayList 的 get 方法,这一换整个项目都要跟着改;如果大家统一通过迭代器遍历,换集合类型的时候,遍历代码一行都不用动。
6.2 实例:自定义一个二叉树迭代器
在 Java 里,实现Iterator接口很直接。我拿二叉树来举个有含金量的例子。二叉树的层级遍历,也就是广度优先遍历,需要借助队列;中序遍历要用栈或递归。如果把这些遍历逻辑全部塞进二叉树类本身,这个类会变得又大又乱。用迭代器模式,把遍历逻辑抽到单独的迭代器类里,二叉树类只负责提供迭代器对象,自己保持干净。
public class TreeNode { int value; TreeNode left; TreeNode right; } public class LevelOrderIterator implements Iterator<Integer> { private final Queue<TreeNode> queue = new LinkedList<>(); public LevelOrderIterator(TreeNode root) { if (root != null) { queue.offer(root); } } @Override public boolean hasNext() { return !queue.isEmpty(); } @Override public Integer next() { TreeNode node = queue.poll(); if (node.left != null) { queue.offer(node.left); } if (node.right != null) { queue.offer(node.right); } return node.value; } }这个迭代器的精妙之处在于:它内部维护了一个队列,每次 next 返回当前节点后,把左右子节点入队,从而实现了逐层访问。外部在使用时,只需要while (iterator.hasNext()),完全不用关心二叉树的节点关系。
6.3 实际场景:Java 集合框架与 foreach
Java 集合框架里的for-each循环,底层的实现机制就是迭代器。编译器会把for (String s : list)这样的代码,替换成使用Iterator的 while 循环。
有一个实打实的坑必须提醒:在 for-each 循环里直接调用集合的 remove 方法,会抛出 ConcurrentModificationException。原因是修改集合的 modCount 和迭代器内部的期望 modCount 对不上,迭代器认为是并发修改,直接罢工。正确的做法是用迭代器的 remove 方法,或者用 Java 8 的removeIf。这个坑几乎所有 Java 开发者都踩过,第一次遇到时一脸懵,觉得"我明明写的单线程代码,怎么还并发修改了",其实是迭代器的保护机制在起作用。
迭代器模式在实际开发中的地位比较特殊:我们主要是作为"使用者"去接触它,很少自己去实现一个迭代器,因为标准库的集合类都已经提供了完整的迭代器支持。但理解它的机制,能让你在遇到自定义数据结构时,知道怎么给外部提供一个统一的遍历入口,也能让你明白框架里那些"for-each 遍历过程中能不能修改集合"的限制到底是怎么来的。
7. 状态模式:用对象替代无尽的 if-else
7.1 核心思想:状态即对象,行为随状态切换
状态模式(State)解决的是:一个对象在不同状态下,对同一个行为有不同的表现。它把状态相关的行为封装成独立的类,让对象的状态改变时,行为也跟着改变,看起来就像这个对象"换了一个类"。
这段时间业务系统里最常见的例子就是订单。订单有多个状态:待支付、已支付、已发货、已完成、已取消。在不同状态下,同一个操作的行为完全不同。比如"申请退款"这个行为,待支付状态直接取消订单就行;已支付但未发货可以申请退款;已发货需要走退货流程;已完成则不能退款。
如果用 if-else 硬写,代码会长这样:
if (orderState == PENDING_PAYMENT) { // 取消订单 } else if (orderState == PAID) { // 申请退款 } else if (orderState == SHIPPED) { // 走退货流程 }每个和状态相关的操作都要加一遍这样的判断,状态越多维度越高,代码复杂度呈指数上升。状态模式的做法是:把每个状态抽象成一个类,每个类里定义这个状态下各种操作的具体行为。
7.2 实例:订单状态流转
先定义一个订单状态的抽象类或接口:
public abstract class OrderState { protected Order order; public OrderState(Order order) { this.order = order; } public abstract void pay(); public abstract void ship(); public abstract void refund(); }然后分别定义各个状态:
public class PendingPaymentState extends OrderState { public PendingPaymentState(Order order) { super(order); } @Override public void pay() { System.out.println("支付成功,订单进入已支付状态"); order.setState(new PaidState(order)); } @Override public void ship() { System.out.println("订单未支付,不能发货"); } @Override public void refund() { System.out.println("订单未支付,直接取消"); order.setState(new CancelledState(order)); } } public class PaidState extends OrderState { public PaidState(Order order) { super(order); } @Override public void pay() { System.out.println("订单已支付,请勿重复支付"); } @Override public void ship() { System.out.println("发货成功,订单进入已发货状态"); order.setState(new ShippedState(order)); } @Override public void refund() { System.out.println("已支付未发货,可以退款"); order.setState(new RefundedState(order)); } }Order 类持有当前状态,所有操作都委托给当前状态对象:
public class Order { private OrderState state; public Order() { this.state = new PendingPaymentState(this); } public void setState(OrderState state) { this.state = state; } public void pay() { state.pay(); } public void ship() { state.ship(); } public void refund() { state.refund(); } }调用方只需要order.pay()、order.ship(),完全不用关心当前到底是什么状态、能做什么不能做什么。是当前状态对象在内部决定每个操作的走向,以及下一个状态是谁。新增一个状态,只需要新增一个状态类,原状态类改动极小。
7.3 和策略模式的区别:状态是会自己"跳"的
状态模式经常和策略模式被放在一起对比,因为它们在类结构上长得几乎一模一样,都是一个接口或抽象类加多个实现类。但它们的意图完全不同。
策略模式的各个策略之间是平级关系,一个策略用完就结束了,调用方通过参数指定用哪个策略,策略对象之间不会互相切换。状态模式则不同,各个状态之间是有流转关系的:支付成功会从待支付变成已支付,发货会从已支付变成已发货。状态对象内部会持有对"下一个状态"的引用,从而推动状态自动流转。
记住一句好记的话:策略是被动的,状态是主动的。策略模式是外部定策略、策略听话执行;状态模式是状态自己决定下一步往哪走。如果要把两者结合起来理解,用有限状态机(FSM)的眼光看状态模式会很有帮助——每个状态类就是状态机里的一个节点,方法里转移状态就是状态机的边。
7.4 状态模式的一个注意点
状态模式有一个隐性问题:订单类需要和状态类互相持有引用,状态类要操作订单的数据,订单要调用状态类的方法。这种双向关联一旦工厂化、池化,很容易出现循环依赖和状态逸出。
我的建议是:状态类保持轻量,只放和状态流转相关的逻辑,不要在里面做重量级的业务处理。比如发短信、调库存这类逻辑,应该在状态类的方法里委托给专门的 service,而不是直接在状态类里写一坨。否则状态类会越来越重,最后变成一个新的"上帝类"。另外,状态对象如果没有内部可变字段,可以考虑复用同一个实例,而不是每次流转都 new 一个,能省一点对象创建开销。
8. 六个模式对比与选型经验
8.1 速查对照表
六个模式讲完了,我把它们的核心关注点、典型场景和判断信号整理成一张表格,方便你日后翻查。
| 模式 | 一句话定义 | 典型场景 | 最大的识别信号 |
|---|---|---|---|
| 模板方法 | 父类定流程,子类定步骤 | 数据导入、构建流程、批处理 | 流程骨架固定,子类只实现差异化步骤 |
| 策略 | 算法族可互换,消除 if-else | 计费规则、折扣计算、压缩算法 | 按类型选算法,算法之间平级无流转 |
| 观察者 | 状态变化广播通知,一对多解耦 | 事件通知、消息推送、监听机制 | 主题不知道观察者,观察者可增删 |
| 命令 | 请求封装成对象,支持撤销回放 | 编辑器撤销重做、事务补偿、操作日志 | 需要把操作用对象保存、传递、回放 |
| 迭代器 | 统一遍历接口,隐藏内部结构 | 集合遍历、自定义数据结构 | 对外只暴露 hasNext/next,不暴露底层 |
| 状态 | 状态对象驱动行为切换 | 订单流转、审批流程、设备状态 | 状态之间有明确流转关系,状态自己跳 |
8.2 我的选型建议与避坑记录
最后聊点实际经验。这六个模式在项目里不是孤立使用的,它们经常组合在一起出现。比如一个订单支付流程,可以用模板方法定义支付骨架(校验、扣款、通知),用策略模式选具体的支付渠道(微信、支付宝、银行卡),用观察者模式通知上游系统和下游服务,用状态模式管理订单从待付款到已完成的生命周期。设计模式从来不是"非此即彼"的选择题,而是可以叠加的组合拳。
另外一个经常被问到的问题是:这么多模式,我记不住也分不清怎么办。我的建议是别贪多,先从策略和观察者这两个练起,它们最容易在工作中找到真实场景,也最容易看到重构前后的对比效果。把这两个模式练熟了,再回头学状态和命令,你会发现理解成本低很多。
踩坑方面,我最大的体会是:不要为了用模式而用模式。早期我刚学设计模式的时候,特别喜欢往代码里套模式,本来一个方法能写完的逻辑,非要用三个类拆开,结果同事接手时骂了我好久。设计模式的价值在于解决复杂问题,而不是给简单问题增加复杂度。判断标准很简单:重构完之后,代码是不是真的更小了、更清晰了、更容易改了?如果答案是"类变多了但逻辑更绕了",那这个模式用得就是错的。
我个人的经验法则是:如果你还不确定要用哪种模式,先按最朴素的方式写,等代码里出现了明显的重复、难以维护的信号,再考虑用模式去重构。设计模式不是前置设计工具,而是重构工具。这也是我在前面每个例子里都强调"场景"的原因——先有问题,后有模式,顺序不能反。反过来的结果,就是一堆过度设计的抽象类和一张谁也看不懂的类图。
这套行为型模式系列我会接着写下一篇,把剩下几个使用频率相对低但同样重要的模式拆开讲。在那之前,建议你把今天的六个例子亲手敲一遍,体会一下每个模式在结构上的微妙差异。模式这东西,光看永远只是眼熟,写出来、用起来,才算是真正长在自己身上了。