提到行为型模式,很多朋友能背出几个名字:策略、模板方法、状态、命令、观察者。但真正到项目里,往往又是另一回事——代码里塞满 if/else、switch、回调、事件通知,改一处分寸把握不好就牵连一片。这篇是设计模式系列的第四篇,也是行为型模式的上篇,我准备把五个最常用、也最容易在真实业务里遇到的行为型模式讲透:策略模式、模板方法模式、状态模式、命令模式、观察者模式。
如果你对设计模式还停留在“看概念、抄类图”的阶段,这篇可能比教科书更接近实战。每个模式我都会先给一个能直接落地的场景,再放完整代码和踩坑提醒。看完之后,你至少能判断“这个模块该用哪种模式”以及“哪里千万别硬套模式”。
1. 行为型模式要解决的核心问题
1.1 从三个问题理解行为型模式
创建型模式解决“对象怎么来”,结构型模式解决“类和对象怎么拼”,行为型模式则回答另一个层面的事:对象之间的职责怎么分、算法怎么封装、消息怎么传递。落到代码里,行为型模式通常都在处理三类很具体的问题:
- 一段算法经常变化,或者有多个平级版本,怎么避免每次改动都动主流程?
- 一个对象在不同状态下表现出不同行为,怎么不把状态判断堆成一座大山?
- 一个对象变化时,多个其他对象需要响应,怎么通知它们又不能让它们互相强耦合?
这三类问题几乎每天都会在业务代码里出现。比如订单状态流转、多套优惠计算、支付回调通知、编辑器撤销重做,全都是行为型模式的典型舞台。
1.2 类行为型与对象行为型
GoF 一共定义了 11 种行为型模式。按实现方式可以分两组:
- 类行为型:主要靠继承来复用,代表是模板方法模式、解释器模式。
- 对象行为型:主要靠组合和对象协作,剩余大多数模式都属于这一类。
区别在于组合更灵活,运行期可以替换;继承则是编译期定死,结构更简单。现在 Java 项目里基本以组合为主,但模板方法依旧在框架层大量存在,比如很多框架的初始化流程、资源加载流程,都会留一个可继承的骨架给使用方扩展。
这一篇先挑五个高频模式讲,把最常见、最容易被滥用的部分说清楚。剩下的责任链、迭代器、中介者、备忘录、访问者、解释器,放到下篇继续。
2. 策略模式:让算法可以替换,而不是散落在 if/else 里
2.1 先用一个促销计费需求热身
假设你在做商城订单模块,最开始只有普通用户,计算价格是一门心思的原价。不久后产品加了一个会员折扣,于是代码变成这样:
public double compute(double amount, int userLevel) { if (userLevel == 1) { return amount * 0.85; } return amount; }再过一个月,节假日活动上线,代码继续膨胀:
public double compute(double amount, int userLevel, boolean holiday) { if (holiday) { return amount * 0.8; } if (userLevel == 1) { return amount * 0.85; } return amount; }看着还行,但后面的需求往往是“会员叠加节假日”“新人首单”“满减优惠”,这时 if/else 会膨胀到无法阅读,而且每次新增规则都要去改这段核心方法,别人看着胆战心惊,自己也容易改漏。
2.2 组合一个可替换的策略
策略模式的做法很简单:把“怎么算价格”从订单计算流程里抽出来,定义成一组策略接口和实现类,订单对象持有其中一个策略,需要换规则时,传入不同的实现即可。
先定义策略接口:
public interface DiscountStrategy { double calculate(double amount); }再写三个平级策略:
public class NormalDiscount implements DiscountStrategy { public double calculate(double amount) { return amount; } } public class VipDiscount implements DiscountStrategy { public double calculate(double amount) { return amount * 0.85; } } public class HolidayDiscount implements DiscountStrategy { public double calculate(double amount) { return amount * 0.8; } }订单类改成持有策略:
public class Order { private DiscountStrategy strategy; public Order(DiscountStrategy strategy) { this.strategy = strategy; } public double finalAmount(double originalAmount) { return strategy.calculate(originalAmount); } }客户端使用:
Order order = new Order(new HolidayDiscount()); double result = order.finalAmount(200);以后新增“新人首单折扣”,只需要加一个NewUserDiscount implements DiscountStrategy。订单计算逻辑完全不用动,原来的测试用例也基本不受影响。这就是开闭原则最直接的体验:对扩展开放,对修改关闭。
2.3 代入项目的三个提醒
策略模式不是万能药,我在实际项目里总结过三个值得注意的点。
第一,没必要看到 if/else 就套策略。如果只有两个分支,且短期内没有扩展迹象,if/else 反而是最清晰的实现。强行抽象会让代码多出好几个类,给后来维护的人造成“这里一定很复杂”的误解。
第二,策略类太多时,可以用注册表降低选择成本。比如用Map<String, DiscountStrategy>把策略类型和实现对应起来,或者直接用 Java 8 的 lambda 配合枚举,把每个策略收敛成一个短小的函数。类数量从“一个策略一个类”变成“一个枚举字段一个表达式”,管理成本会低很多。
第三,策略对象最好不要持有可变状态。策略一旦在并发场景里被多个订单共用,内部字段一变,其他请求的结果就跟着错。我的习惯是策略实现做成无状态对象,要么直接使用静态实例,要么每次都 new 一个轻量对象。
3. 模板方法模式:固定流程留在基类,差异步骤交给子类
3.1 什么时候会想起模板方法
模板方法模式解决的是另一种变化:算法整体骨架固定,但其中某些步骤每个场景都不一样。
举个常见例子,数据迁移任务。无论是从 Excel 导入、从接口拉取,还是从旧系统读取,流程都逃不开这几步:打开数据源、读取数据、清洗转换、写入目标、关闭资源。如果把每一步都写在一个方法里,重复代码会很多;如果让每个迁移任务自己实现全部流程,又会把通用的骨架逻辑重复写很多遍。
正确思路是把“整个流程怎么走”放进父类,把“某个步骤具体怎么做”留给子类覆盖。
3.2 一个数据迁移任务的完整实现
定义抽象任务类:
public abstract class DataMigrationTask { public final void migrate() { openSource(); extractData(); transform(); load(); closeSource(); } protected abstract void openSource(); protected abstract void extractData(); protected void transform() { // 默认什么都不做,子类可以按需覆盖 } protected abstract void load(); protected void closeSource() { // 默认空实现,资源释放逻辑可覆盖 } }注意这里的几个设计点:
migrate()用final修饰,防止子类修改整个流程。- 抽象方法代表这个步骤必须由子类提供,比如打开数据源。
- 非抽象方法叫钩子方法,子类可以选择覆盖,也可以选择忽略,比如
transform()。
一个 Excel 迁移任务实现类:
public class ExcelMigrationTask extends DataMigrationTask { protected void openSource() { System.out.println("打开 Excel 数据源"); } protected void extractData() { System.out.println("读取工作表并逐行扫描"); } protected void transform() { System.out.println("清理空值,统一日期格式"); } protected void load() { System.out.println("写入业务订单表"); } }以后新增一个从接口拉取数据的任务,只需再写一个子类覆盖对应步骤,任务执行入口永远是同一个migrate()。这种模式在框架代码里极其常见,比如很多初始化器、资源加载器,都是模板方法的变体。
3.3 钩子与重写的边界
模板方法容易被用歪,通常有两种踩坑方向。
第一个坑是抽象方法定义得太多,导致子类负担很重。如果一个模板里塞了七八个抽象方法,每个子类都得覆写一大堆无关步骤,这其实违背了模板方法的本意。合理的抽象是把“必然不同”的步骤抽出来,把“大多数场景一致,少数场景需要微调”的步骤设计成钩子,甚至提供默认实现。
第二个坑是流程骨架后期扩展。在migrate()中间插入一个validate(),会让所有已有子类都要被动适配。如果 Java 默认接口方法能提供安全默认值,影响会小很多;但如果是抽象类,尽量在骨架设计初期就想清楚哪些步骤是会发生变化的。我经常看到有人把模板方法写得太“完美”,结果新需求一来,子类要推翻一半逻辑,那就是过度设计了。
模板方法模式还常和策略模式配合出现。比如模板负责控制“什么时候读取、什么时候清洗”,策略负责“清洗的算法具体选哪一套”。两者不冲突,解决的问题视角不同。
4. 状态模式:把状态转移和分支判断打包成状态对象
4.1 从订单状态的痛开始
订单模块是状态模式最好的教学现场。一个订单通常有:待支付、已支付、已发货、已完成。同一个“取消订单”操作,在不同状态下结果完全不同:
- 待支付:直接取消。
- 已支付:需要申请退款、通知仓库停止发货。
- 已发货:可能无法直接取消,要走售后流程。
- 已完成:取消操作没有意义。
用最原始的写法,通常是一个枚举加一个switch:
switch (order.getState()) { case UNPAID: cancelUnpaid(order); break; case PAID: refundAndNotify(order); break; case SHIPPED: applyAfterSale(order); break; default: throw new IllegalStateException(); }问题在于,新增状态时,所有switch都要跟着改;而且实际业务里不止“取消”,还有“支付”“发货”“确认收货”这些操作,每个操作都得判断一遍当前状态。状态一多,方法就像毛线球。
4.2 状态模式版订单流转
状态模式把“当前状态”本身抽象成对象,每个状态对象知道自己能做什么、能转到哪个状态。
定义状态接口:
public interface OrderState { void pay(OrderContext context); void ship(OrderContext context); void confirm(OrderContext context); }实现“待支付”状态:
public class UnpaidState implements OrderState { public void pay(OrderContext context) { System.out.println("订单已支付,状态切换为已支付"); context.setState(new PaidState()); } public void ship(OrderContext context) { System.out.println("未支付订单不能发货"); } public void confirm(OrderContext context) { System.out.println("未支付订单不能确认收货"); } }订单上下文持有当前状态,并把操作委托给当前状态对象:
public class OrderContext { private OrderState state; public OrderContext() { this.state = new UnpaidState(); } public void setState(OrderState state) { this.state = state; } public void pay() { state.pay(this); } public void ship() { state.ship(this); } public void confirm() { state.confirm(this); } }客户端使用:
OrderContext ctx = new OrderContext(); ctx.pay(); // 从 UnpaidState 切到 PaidState ctx.ship(); // 当前状态是 PaidState,由该状态处理逻辑状态转移被收进了状态对象内部。支付完成进入已支付态,发货进入已发货态,每个状态类的逻辑彼此独立,新增状态只需新增一个类,再在对应状态里补跳转。
4.3 状态模式跟策略模式的区别
这两个模式类图结构几乎一样,都是“上下文持有一个可替换对象”,但意图完全不同。
- 策略模式:同一状态下,选择不同算法。客户端主动指定用哪个策略。
- 状态模式:不同状态下,同一操作有不同行为。状态对象内部决定下一步切到哪个状态。
简单记法:策略是“你来选”,状态是“它自己走”。状态模式里的状态转移往往是有方向的,比如待支付只能到已支付,不能直接跳已完成;而策略模式可以随时替换回任意策略。
还有一个实际坑:状态模式写完以后,整个流程不容易一眼读出来。状态类之间互相跳转,代码散落在多个类里。我的建议是在状态类里留清晰的注释,标明“本状态在哪个操作后切换到什么状态”,或者用一个状态枚举配合状态机配置表。状态非常多且规则复杂时,也别硬套状态模式,直接用状态机框架或者规则引擎会更容易维护。
5. 命令模式:把请求本身做成可传递、可撤销的对象
5.1 编辑器按钮为什么越来越难看
再看一个高频场景:文本编辑器工具栏。有一个“保存按钮”,一个“粘贴按钮”,一个“撤销按钮”。一开始,按钮直接调用编辑器方法:
button.setOnClick(() -> editor.save());逻辑简单时没问题。但需求逐渐变多:要记录操作日志、要支持撤销、要把一组操作做成宏命令、要把操作发送到远程队列执行。如果按钮和编辑器方法直接耦合,这些功能全都得在按钮层硬编码。
命令模式的核心思路是:把“调用哪个对象、执行什么方法”封装成一个独立命令对象。按钮、宏、撤销栈都不需要知道具体的业务接收者是谁,它们只面对统一的execute()和undo()接口。
5.2 命令、接收者、撤销栈的完整写法
定义命令接口:
public interface Command { void execute(); void undo(); }接收者是被操作的真实业务对象,这里以文本编辑器为例:
public class TextEditor { private final StringBuilder content = new StringBuilder(); private int lastAppendedLength = 0; public void append(String text) { lastAppendedLength = text.length(); content.append(text); } public void rollbackAppend() { if (lastAppendedLength > 0 && content.length() >= lastAppendedLength) { content.setLength(content.length() - lastAppendedLength); lastAppendedLength = 0; } } public String getContent() { return content.toString(); } }具体命令:
public class AppendCommand implements Command { private final TextEditor editor; private final String text; public AppendCommand(TextEditor editor, String text) { this.editor = editor; this.text = text; } public void execute() { editor.append(text); } public void undo() { editor.rollbackAppend(); } }用撤销栈管理:
import java.util.ArrayDeque; import java.util.Deque; TextEditor editor = new TextEditor(); Deque<Command> undoStack = new ArrayDeque<>(); Command cmd1 = new AppendCommand(editor, "hello"); cmd1.execute(); undoStack.push(cmd1); Command cmd2 = new AppendCommand(editor, " world"); cmd2.execute(); undoStack.push(cmd2); undoStack.pop().undo(); System.out.println(editor.getContent()); // 输出 hello这个例子里,按钮层、撤销栈、宏脚本层看到的都是同一个Command接口。以后要加“替换”“删除”操作,只需要新增对应命令类,撤销栈逻辑完全不用动。
5.3 什么场合不要用命令模式
命令模式最常见的误用,是把所有执行逻辑直接写进execute(),让命令对象变成一个巨大的处理器。比如AppendCommand.execute()里既操作编辑器,又保存日志,又发送通知。这样撤销逻辑会非常难写,因为命令对象和接收者的边界被抹掉了。
正确做法是命令对象尽量薄。一个命令只承载“请求参数”和“调用哪个接收者哪个方法”,具体业务逻辑都在接收者内部。这样撤销时,接收者才知道怎么回滚自身状态。
这个模式也不是非要显式定义Command接口。Java 里常见的Runnable、Callable,某种意义上就是轻量命令模式。如果你的场景不需要撤销、不需要宏组合,只想着“解耦按钮和业务”,那用 lambda 或方法引用就够了。强行设计一套命令类体系,反而增加理解成本。
还有一个注意点:真正实现撤销时,有些操作无法只靠undo()一步还原。比如“发送邮件”就没法撤销。遇到这类不可逆操作,要么不提供撤销,要么用补偿命令去抵销,比如发一封撤回通知。在设计命令接口时就要想清楚哪些命令能进撤销栈,哪些只能进日志。
6. 观察者模式:一对多变化的通知模型
6.1 回到订单状态变更场景
电商系统里,订单状态从“已支付”变到“待发货”后,要做的事情很多:发短信通知用户、推送 App 消息、记录操作日志、更新库存。如果直接在订单状态变更方法里写:
Order order = orderRepository.find(id); order.setState(paid); smsService.send(order); pushService.send(order); logService.log(order);看起来没问题,但每加一个通知渠道,都要来改这段代码。而且顺序一旦固定,后续想调整优先级,也得改这里。观察者模式把这种“一对多通知”变成订阅关系:订单状态变化时,不需要知道自己应该通知谁,只需要广播“我变了”,所有订阅者自行响应。
6.2 观察者模式的实现细节
定义观察者接口和主题对象:
public interface OrderObserver { void update(Order order); } public class OrderSubject { private final List<OrderObserver> observers = new ArrayList<>(); private Order order; public void attach(OrderObserver observer) { observers.add(observer); } public void detach(OrderObserver observer) { observers.remove(observer); } public void changeOrder(Order newOrder) { this.order = newOrder; notifyObservers(); } private void notifyObservers() { for (OrderObserver observer : observers) { observer.update(order); } } }具体观察者:
public class SmsObserver implements OrderObserver { public void update(Order order) { System.out.println("发送短信通知:" + order.getUserId()); } } public class LogObserver implements OrderObserver { public void update(Order order) { System.out.println("记录订单状态变更日志"); } }客户端组装:
OrderSubject subject = new OrderSubject(); subject.attach(new SmsObserver()); subject.attach(new LogObserver()); Order order = new Order(); order.setUserId(1001); order.setState("paid"); subject.changeOrder(order);以后新增一个“优惠券发放观察者”,只需要再attach一次,订单状态变更的主体代码不需要动。
6.3 观察者与发布-订阅的边界
实际项目里,观察者模式有两个容易踩的点。
第一个是通知顺序和异常。notifyObservers()里某个观察者抛异常,后面的观察者就全断了。真实场景里,要么逐个 try/catch 隔离异常,要么把观察者列表换成交发型队列,让一个观察者失败不影响其他人。JDK 里CopyOnWriteArrayList也常用来解决遍历过程中观察者增删导致的并发修改问题。
第二个是观察者与发布订阅的区别。观察者模式里,主题对象直接持有观察者引用,是同步、点对点调用;而发布-订阅通常会引入一个消息通道或事件总线,发送方和接收方完全不知道彼此存在。项目里如果已经用了事件总线、消息队列,那观察者模式的思想其实已经被更高层的基础设施覆盖了,手工写一套观察者列表反而多余。
还有一个我自己栽过的坑:观察者没有及时detach。在 Web 容器或桌面程序里,观察者被注册后忘了移除,主题对象就会持有无用的引用,长期运行会造成内存泄漏。特别是主题的生命周期比观察者长时,一定要在观察者销毁时主动取消订阅。
7. 五种模式该怎么选:一张速查表,三条实战经验
7.1 模式对照速查表
| 模式 | 核心意图 | 关键角色 | 最常解决的信号 | 常见误区 |
|---|---|---|---|---|
| 策略模式 | 一组可替换的算法 | Context、策略接口、具体策略 | 多处 if/else 选择不同算法 | 两个分支也要硬抽策略 |
| 模板方法模式 | 骨架固定、步骤可变 | 抽象父类、子类 | 多个任务流程完全一致 | 抽象方法过多,子类负担重 |
| 状态模式 | 行为随状态变化,状态自行流转 | 上下文、状态接口、具体状态 | 同一操作在不同状态表现不同 | 和策略模式混淆;状态跳转不清晰 |
| 命令模式 | 把请求封装为对象 | 调用者、命令、接收者 | 需要撤销、重放、队列、宏操作 | 业务逻辑全塞进命令类 |
| 观察者模式 | 一对多变化通知 | 主题、观察者 | 一个对象变化,多个对象需要响应 | 观察者不及时注销,异常互相影响 |
这张表不能替代真实的判断,但它能帮你快速定位。当你说不清“这是哪种变化”时,先把它归到某一列,思路就顺了。
7.2 三条经验
第一条:模式不要先行。很多初级项目一上来就规划十几个抽象接口,结果业务还没跑起来,光类的跳转就让人绕晕。我更习惯先用普通代码把业务写对,等同一个 if/else 出现第二次、第三种变化时,再用手头这些模式去重构。重构完的代码如果比原来更复杂,那就说明这个模式根本不适合这个场景。
第二条:模式可以组合,但不要堆砌。订单状态流转可以用状态模式,通知渠道可以用观察者模式,每个按钮的操作可以用命令模式。三者同时出现在一个模块里并不冲突,因为它们负责的职责不同。真正要警惕的是,一个方法里既套策略又套模板又套命令,最后变成类满天飞、每个类三行代码。
第三条:把模式当成沟通词汇,而不是荣誉勋章。设计模式在团队协作里最大的价值,是你说“这里用观察者”时,同事能立刻想到大致的结构和协作方式。代码评审时与其说“请你抽象一下”,不如直接说“这部分用策略模式抽一个接口,新增计费规则就不用动主流程了”。模式名称本身就是一种高效的设计语言。
最后再分享一个小习惯:我在真实项目里不会刻意追求“每个模块都套一个模式”。很多代码用 if/else 写清楚,反而比硬套状态模式更容易维护。模式的价值在于让变化可控,而不是证明代码库“很设计”。等你手头出现一个反复修改、越改越乱的类时,再翻出这五种模式对照一遍,会比一开始就铺天盖地地抽象稳妥得多。