设计模式系列写到第四篇,终于轮到行为型模式了。前几篇我们把创建型(怎么把对象造出来)和结构型(怎么把对象组合起来)讲完了,这一篇开始讲行为型模式的“上篇”。
如果你是跟着这个系列一路看过来的,应该已经感受到一个规律:创建型模式关注的是“对象的诞生”,结构型模式关注的是“对象的静态关系”。而行为型模式完全不同,它关注的是对象之间在运行时怎么协作、怎么分配责任、怎么通信。换句话说,前两类模式回答的是“对象长什么样”,行为型模式回答的是“对象在一起怎么干活”。
行为型模式一共有11个,数量是所有类别里最多的。一口气全讲完既不现实,也容易消化不良。这篇我先把其中5个最常见、面试和日常开发里出现频率最高的讲清楚:策略模式、模板方法模式、观察者模式、迭代器模式、责任链模式。剩下的状态模式、命令模式、备忘录模式、访问者模式、中介者模式、解释器模式,放到下篇继续。
每个模式我会按照“解决了什么问题 → 代码怎么落地 → 实战中要注意什么”的顺序来讲,尽量把我在实际项目里踩过的坑、用过的变体写法都交代清楚。不管是刚开始学设计模式的新人,还是想快速回顾的老手,这篇都适合你。
1. 先搞清楚:行为型模式到底在解决什么问题
1.1 从“对象怎么搭”到“对象怎么协作”
前面说的创建型和结构型模式,本质上都是在解决静态结构问题——类和类之间怎么继承、怎么组合、怎么代理。这些关系在你把代码写完的那一刻就已经确定了。
行为型模式不一样。它关注的是动态行为——程序运行过程中,对象之间怎么传递消息、怎么分配职责、怎么互相调用。同一个对象,在不同策略下表现不同;同一个请求,在不同处理器链上走向不同。这些都是在运行时才决定的。
我打个比方。创建型模式像生产线设计——决定零件怎么生产;结构型模式像装配图——决定零件怎么拼装;行为型模式则是流水线上的调度规则——决定了每个工位什么时候动作、做完之后传给谁、遇到异常怎么办。
1.2 行为型模式的“两条主线”
11个行为型模式其实可以分成两类,理解了这个分类,学习成本能降低一半:
第一类:算法与行为的封装
这类模式的核心思想是把“变化的算法/行为”从“稳定的主体”中抽离出来。代表有:
- 策略模式:把一个算法的不同实现封装成独立的策略对象
- 模板方法模式:把算法的骨架定死,把可变的步骤留给子类
- 状态模式:把对象在不同状态下的行为封装成独立的状态对象
- 命令模式:把一次请求/操作封装成一个独立对象
第二类:对象间的通信与职责分配
这类模式的核心思想是解决“对象之间怎么说话、谁该干活”的问题。代表有:
- 观察者模式:一对多的依赖通知
- 责任链模式:请求沿着链传递,找到能处理的人
- 迭代器模式:提供统一遍历方式,屏蔽内部结构
- 中介者模式:通过中介对象转发多方通信
- 访问者模式:在不修改对象结构的前提下增加新操作
这两条主线下面写代码时,思路是完全不同的。封装类的主线,你得多考虑“哪些是稳定的、哪些是变化的”;通信类的主线,你得多考虑“谁通知谁、谁处理谁、顺序是什么”。
理解了这两条线,接下来每个模式看起来都不会觉得是孤立的,能串成一个体系。
2. 策略模式:把“怎么做”从“做什么”手里拿过来
2.1 一个支付场景的演化过程
策略模式是我在实际项目中用得最多的行为型模式。我们还是拿一个真实的业务场景来说——订单支付。
假设某电商项目一开始只有一个余额支付渠道,代码很简单:
public class PaymentService { public boolean pay(OrderInfo order) { // 从账户余额扣款 return balanceService.deduct(order.getUserId(), order.getAmount()); } }第二天产品经理来了:要接入银行网关支付。于是代码变成:
public class PaymentService { public boolean pay(OrderInfo order, PayChannel channel) { if (channel == PayChannel.BALANCE) { return balanceService.deduct(...); } else if (channel == PayChannel.BANK) { return bankGateway.pay(...); } return false; } }第三天,又来了扫码支付、第三方支付……这个else if链越来越长。你心里已经开始发毛了:新增一个渠道就要改一次PaymentService,测试要回归所有渠道,而且万一某个渠道的服务注入方式还不一样,这个类会膨胀到没法维护。
这不是“代码洁癖”,这是典型的扩展点被埋没问题。支付逻辑的主体流程是稳定的——验证订单、调用渠道、记录结果。变化的是“具体调哪个渠道、渠道内部怎么走”。
把变化封装起来,这就是策略模式的核心思路。
2.2 策略模式的标准结构
策略模式有三个角色:
- 策略接口:定义算法族共有的行为
- 具体策略:实现接口,每种算法一个类
- 上下文:持有策略引用,调用方只需面向接口操作
对应到支付场景,代码长这样:
// 策略接口 public interface PayStrategy { boolean pay(OrderInfo order); } // 具体策略:余额支付 public class BalancePayStrategy implements PayStrategy { @Override public boolean pay(OrderInfo order) { // 扣减余额、记流水 return true; } } // 具体策略:扫码支付 public class QrPayStrategy implements PayStrategy { @Override public boolean pay(OrderInfo order) { // 调用扫码支付网关 return true; } } // 具体策略:银行卡支付 public class BankPayStrategy implements PayStrategy { @Override public boolean pay(OrderInfo order) { // 调用银行接口 return true; } }上下文类不再关心具体策略,只面向PayStrategy编程:
public class PaymentService { private PayStrategy strategy; public void setStrategy(PayStrategy strategy) { this.strategy = strategy; } public boolean execute(OrderInfo order) { return strategy.pay(order); } }客户端选择策略的职责,通常交给工厂或者配置中心,而不是让PaymentService自己判断。
PayStrategy strategy = PayStrategyFactory.getStrategy(order.getChannel()); paymentService.setStrategy(strategy); paymentService.execute(order);这套结构写完之后,新增支付渠道只需要做两件事:写一个实现PayStrategy的新类,在工厂里注册一下。开闭原则立刻就体现了——对扩展开放,对修改关闭。
2.3 我把过的两个坑
第一个坑:把所有判断都塞进工厂
策略模式最容易被反向滥用。有人把所有渠道的构建逻辑都堆到工厂里,导致工厂本身变成了一个巨大的if/else。解决思路是让工厂从配置中心读取“渠道类型 -> 策略Bean名称”的映射,用反射或者注册表来创建策略对象。
第二个坑:策略对象内部还是有大量公共代码
不同支付策略其实有很多共同的流程,比如订单状态校验、失败重试、流水记录。如果每个策略类都各自写一遍,就会出现大量重复代码。
我的做法是:写一个抽象基类,把公共流程放在基类,把差异逻辑留在子类。但注意,这已经是在往模板方法模式靠了。所以策略模式和模板方法模式经常一起出现——策略接口定边界,抽象基类定公共流程,具体子类实现差异。
3. 模板方法模式:算法骨架与可变步骤的分离
3.1 用“冲泡饮料”理解模板方法
如果说策略模式是把“整个算法”封装成对象,那么模板方法模式则是把算法的骨架定死在父类里,把可变步骤留给子类重写。
我经常用“冲泡饮料”来给学生讲这个模式:不管泡咖啡还是泡茶,流程都是一样的——烧水、冲泡、加料倒杯。但是具体冲泡什么、加什么料,每一步都不一样。把不变的骨架写在父类,把变化的步骤留给子类,这就是模板方法模式。
放到订单处理的场景里:
- 稳定的骨架:校验订单 → 扣减库存 → 记录快照 → 通知客户
- 可变的步骤:不同订单类型,校验规则不同、扣库存策略不同
3.2 模板方法 + 钩子方法的实际写法
public abstract class AbstractOrderProcessor { // 模板方法:定义算法骨架,建议用 final 防止子类篡改 public final void processOrder(OrderInfo order) { validate(order); deductStock(order); if (needNotify(order)) { // 钩子方法,子类可选覆盖 notifyCustomer(order); } createSnapshot(order); } // 抽象步骤:强制子类实现 protected abstract void validate(OrderInfo order); // 抽象步骤 protected abstract void deductStock(OrderInfo order); // 钩子方法:默认实现,子类不一定重写 protected boolean needNotify(OrderInfo order) { return true; } private void notifyCustomer(OrderInfo order) { // 发送短信/站内信 } private void createSnapshot(OrderInfo order) { // 保存订单快照到审计表 } }子类只需要关注自己那部分差异:
public class FlashSaleOrderProcessor extends AbstractOrderProcessor { @Override protected void validate(OrderInfo order) { // 限购校验、活动时间校验 } @Override protected void deductStock(OrderInfo order) { // 锁定活动库存 } @Override protected boolean needNotify(OrderInfo order) { // 秒杀订单不发送消息,避免轰炸 return false; } }这里有个关键点:processOrder用了final修饰。很多人忽略这个细节,导致子类把整个骨架都重写了,模板方法模式就失去了意义。骨架是约定,约定就不能被破坏。
3.3 模板方法 vs 策略模式到底怎么选
这是面试里很爱问的一个对比,也是实际开发里我经常要做的选择。
| 对比项 | 模板方法模式 | 策略模式 |
|---|---|---|
| 复用方式 | 继承:复用父类骨架 | 组合:通过持有策略对象复用 |
| 变化粒度 | 算法内部某几步变化 | 整个算法族可替换 |
| 扩展方式 | 继承子类,覆写步骤 | 新写策略类,替换上下文中的策略 |
| 与业务结合 | 适合“骨架稳定,步骤多变”的场景 | 适合“整个算法可替换”的场景 |
| 风险点 | 继承层级深后不好维护 | 策略类数量多后需要管理 |
个人看法:模板方法模式的耦合度比策略模式高,因为子类依赖父类,所以一旦骨架发生变化,所有子类都要跟着检查。策略模式通过组合,耦合度低一些,但代价是增加了一批策略类和上下文之间的交互代码。
实际项目中,我经常两个搭配着用:用策略模式定义大的算法边界,用模板方法模式处理策略内部的可变步骤。比如支付策略的接口用策略模式定义,但每个支付策略内部都有固定流程,那就再抽一个模板基类。
4. 观察者模式:让状态变化自己“广播”出去
4.1 气象站的经典结构
观察者模式应该是最容易理解的行为型模式了。它的核心思想就一句话:当一个对象的状态发生变化时,所有依赖它的对象都会自动收到通知。一对多的依赖关系,这边状态一变,那边所有观察者都被触发。
经典的教学案例是气象站:WeatherData负责采集气象数据,数据一变,DisplayBoard(显示面板)、AlertSystem(预警系统)等多个观察者都要更新。
用代码写出来是这样一个结构:
// 观察者接口 public interface WeatherObserver { void update(float temperature, float humidity, float pressure); } // 被观察者(主题) public class WeatherData { private List<WeatherObserver> observers = new ArrayList<>(); private float temperature; private float humidity; private float pressure; public void register(WeatherObserver o) { observers.add(o); } public void unregister(WeatherObserver o) { observers.remove(o); } public void setMeasurements(float temperature, float humidity, float pressure) { this.temperature = temperature; this.humidity = humidity; this.pressure = pressure; notifyObservers(); } private void notifyObservers() { for (WeatherObserver observer : observers) { observer.update(temperature, humidity, pressure); } } }观察者那边实现接口即可:
public class AlertSystem implements WeatherObserver { @Override public void update(float temperature, float humidity, float pressure) { if (temperature > 35f) { System.out.println("高温预警"); } } }4.2 我踩过好几次的监听器生命周期问题
观察者模式在真实项目中有一个非常隐蔽的坑:观察者注册了之后没有及时注销,造成内存泄漏。
举个例子,某后台管理系统里,用户登录后需要实时接收系统通知。很多人会在用户对象创建时把UserSessionListener注册到全局NotificationCenter上,但用户退出登录时忘了调用unregister。结果就是这个监听器一直被全局对象持有,用户明明已经退出了,GC还是无法回收它关联的一堆对象。在长期运行的服务器上,这就是个定时炸弹,内存悄悄涨,直到某天OOM。
我的经验是两条铁律:
- 谁注册,谁负责注销。注册和注销必须成对出现,最好写在同一个方法里,比如
start()和destroy()。 - 如果无法保证成对出现,使用弱引用。Java里可以用
WeakReference来包装观察者,或者直接用现成的WeakHashMap做监听器存储。
4.3 观察者模式在实战中的变体
实际开发中,很少真的手写一整套观察者接口。大多数时候我们会用事件总线、异步消息、发布订阅框架。Java自身的PropertyChangeSupport、各种消息中间件的Consumer/Producer模型,本质上都是观察者模式的扩展。
但不管用什么工具,核心的语义没变:发布者不关心谁在听,订阅者不关心谁在发声,双方只靠事件类型和内容进行松耦合对接。
这带来一个隐蔽的调试难度:事件一多,代码的调用链就变得很“飘”。你看到一个现象发生了,但不知道是哪个发布者触发、经过了几层异步转发。我的建议是:观察者模式适合跨模块的、低频次的、一对多的通知。如果模块内部逻辑强相关,高频调用,硬套观察者模式会让代码变得特别难追踪,这时候不如直接方法调用。
另外有一个实战细节:通知顺序。默认的ArrayList是按注册顺序遍历的,这本身没问题。但如果你在某个观察者的update里又触发了一个新事件,就可能出现迭代器修改冲突(ConcurrentModificationException),或者通知顺序错乱。稳妥做法是遍历之前对观察者列表做一份快照,或者让通知操作脱离当前线程去执行。
5. 迭代器模式:把“怎么遍历”从集合里拆出来
5.1 为什么需要迭代器模式
迭代器模式可能是行为型模式里最“没存在感”的一个,因为现代语言几乎都内置了迭代器语法,比如Java的Iterator接口、foreach语法糖、Python的__iter__和__next__。很多人以为这个模式已经过时了,其实不然。
迭代器模式的核心价值不是“怎么遍历一个数组”,而是把遍历逻辑和集合内部结构解耦。调用方不需要知道集合底层是数组、链表还是树,只需要调用hasNext()和next(),就能依次拿到元素。
当一个集合的数据结构足够简单时,确实用不上迭代器——直接用下标遍历就行。但真实项目里,集合往往不是铁板一块:
- 有的集合存储的是多级树形结构,遍历时要按层级返回
- 有的集合内部有多个分组,调用方需要按不同规则遍历
- 有的集合在遍历过程中还要做过滤和转换
这时候把遍历算法内聚成一个迭代器对象,价值就体现出来了。
5.2 手写一个可迭代的订单历史
举个实际例子。某订单中心需要按时间倒序遍历用户的历史订单,同时只返回已支付订单。我定义了一个OrderHistory集合类,内部用List存储,但对外暴露一个定制迭代器:
public class OrderHistory implements Iterable<OrderInfo> { private List<OrderInfo> orders = new ArrayList<>(); public void add(OrderInfo order) { orders.add(order); } @Override public Iterator<OrderInfo> iterator() { return new PaidOrderIterator(); } // 迭代器,按时间倒序并过滤未支付 private class PaidOrderIterator implements Iterator<OrderInfo> { private int cursor = orders.size() - 1; private OrderInfo nextReady; @Override public boolean hasNext() { while (cursor >= 0) { OrderInfo candidate = orders.get(cursor); if (candidate.isPaid()) { nextReady = candidate; cursor--; return true; } cursor--; } return false; } @Override public OrderInfo next() { if (nextReady == null && !hasNext()) { throw new NoSuchElementException(); } OrderInfo result = nextReady; nextReady = null; return result; } } }调用方不需要知道OrderHistory内部用了ArrayList、需要倒序访问、还要过滤状态。直接foreach循环:
for (OrderInfo order : orderHistory) { System.out.println(order.getOrderId()); }这个把复杂遍历逻辑封装进迭代器的做法,让调用代码非常干净。
5.3 迭代器陷阱
迭代器最经典的坑是快速失败(fail-fast)与并发修改。Java里,如果在迭代过程中有其他线程修改了集合结构,ArrayList会抛出ConcurrentModificationException。这是保护机制,但很多人第一次遇到时都会懵。
我的建议是:单线程环境下,尽量在迭代过程中不要修改集合结构;确实需要改的,用CopyOnWriteArrayList或ConcurrentHashMap这类并发容器。还有一种做法是先把需要删除的元素收集起来,迭代结束后统一删除。
另外一个容易被忽略的点:迭代器是“一次性”的,不能回溯。如果你在一个场景里需要多次遍历同一个集合,每次都调用iterator()获取新迭代器,而不是复用同一个迭代器对象。
6. 责任链模式:请求自己找“能处理它的人”
6.1 从报销审批看责任链
责任链模式的思路非常贴近现实。拿报销审批来说,报销金额不同,需要不同级别的人审批:
- 500元以下,项目经理批
- 5000元以下,部门总监批
- 50000元以下,总经理批
- 超过50000,董事会批
最直觉的写法是if/else嵌套:
public void approve(ReimbursementRequest request) { if (request.getAmount() < 500) { manager.approve(request); } else if (request.getAmount() < 5000) { director.approve(request); } else if (...) { ... } }问题很明显:审批规则一变就要改这个方法,而且随着规则变复杂,这个类会长得没法看。责任链模式的做法是把每个审批节点拆成独立的处理器,串成一条链,请求从链头进入,能处理的节点处理掉,处理不了就往后传。
6.2 责任链的代码结构
public abstract class ApprovalHandler { protected ApprovalHandler next; public void setNext(ApprovalHandler next) { this.next = next; } public void handle(ReimbursementRequest request) { if (canApprove(request)) { doApprove(request); } else if (next != null) { next.handle(request); } else { request.setApproved(false); System.out.println("无人能审批,直接驳回"); } } protected abstract boolean canApprove(ReimbursementRequest request); protected abstract void doApprove(ReimbursementRequest request); }具体节点实现:
public class ManagerHandler extends ApprovalHandler { @Override protected boolean canApprove(ReimbursementRequest request) { return request.getAmount() <= 500; } @Override protected void doApprove(ReimbursementRequest request) { System.out.println("项目经理审批通过,金额: " + request.getAmount()); } } public class DirectorHandler extends ApprovalHandler { @Override protected boolean canApprove(ReimbursementRequest request) { return request.getAmount() > 500 && request.getAmount() <= 5000; } @Override protected void doApprove(ReimbursementRequest request) { System.out.println("总监审批通过,金额: " + request.getAmount()); } }客户端把链组装起来:
ApprovalHandler manager = new ManagerHandler(); ApprovalHandler director = new DirectorHandler(); ApprovalHandler gm = new GeneralManagerHandler(); manager.setNext(director); director.setNext(gm); manager.handle(request);这套写法的好处非常直接:新增审批节点只需新写一个Handler类,然后在组装链的地方拼接一下,完全不用动已有节点的代码。
6.3 责任链的边界:什么时候该停
责任链模式用多了之后,有一个问题值得警惕:请求在链上传递时,如果没有任何节点能处理,就会走到链尾,表现就是“石沉大海”,或者被默认逻辑兜底。
我在一个日志框架项目里就遇到过这种情况。当时设计了一组日志过滤器,按“DEBUG → INFO → WARN → ERROR”的顺序传递。结果有一次配置错误,某个级别的过滤器没有注册到链上,大量日志直接穿过链尾,既没输出也没处理,排查了半天才找到问题。
从那以后,我给自己定了几条规矩:
- 责任链必须有明确的链尾兜底逻辑。要么在
handle的最后写清楚“无人处理时怎么办”,要么在组装链时保证最后一个节点一定能处理。 - 责任链不能太长。超过五六个节点,调试和阅读成本会显著上升。如果业务本身没有天然的“传递”语义,不要硬套责任链。
- 节点顺序很重要。责任链上的顺序有时候就是业务规则的一部分,组装链的地方应该集中配置,并且要写注释说明为什么是这个顺序。
7. 五种模式放在一起怎么选
写到这里,我们把五个模式都过了一遍。最后用一个表格横向对比一下,再聊聊团队协作时怎么选型。
| 模式 | 核心思想 | 适用场景 | 主要风险 |
|---|---|---|---|
| 策略模式 | 算法族封装,可整体替换 | 支付渠道、优惠规则、排序算法 | 策略类爆炸,工厂膨胀 |
| 模板方法模式 | 骨架固定,步骤留给子类 | 订单处理流程、数据迁移流程 | 继承层级深,骨架改动影响大 |
| 观察者模式 | 一对多状态通知 | 事件广播、消息推送、UI刷新 | 监听器泄漏,调用链难追踪 |
| 迭代器模式 | 遍历逻辑与结构解耦 | 自定义集合遍历、过滤排序遍历 | 并发修改异常,迭代器复用出错 |
| 责任链模式 | 请求沿链传递直到有人处理 | 审批流、日志过滤器、拦截器 | 链尾无兜底,顺序错误难发现 |
选型时我的判断顺序一般是这样的:
先看变化发生的位置。如果是“整个算法都能换”,选策略模式;如果是“算法骨架稳定,只有某几步会变”,选模板方法模式;如果变化的是“对象状态变化后,谁要跟着动”,选观察者模式。
再看调用关系。如果多个对象之间的交互是一对多的广播,观察者模式最顺手;如果请求和处理器之间是传递关系,责任链模式更贴切;如果只是想让外部顺序访问内部元素,迭代器模式就够了。
最后看团队熟悉程度。设计模式的引入不是炫技,要考虑队友后续的维护成本。一个团队如果用策略模式和模板方法用得顺手,那就优先用这两种解决大部分问题;观察者模式要慎用,因为它会切断调用方和被调用方之间的直接联系,新人接手代码时经常找不到逻辑在哪。责任链模式适合业务本身有“层级审批”语义的场景,没事别硬造链。
我自己在项目里的习惯是:策略、模板方法、迭代器这三种模式,几乎每天都会用;观察者和责任链则要结合具体业务场景判断,绝不为了用模式而用模式。
这篇先讲到这里。行为型模式还有六个——状态、命令、备忘录、访问者、中介者、解释器,其中状态模式和命令模式在真实项目里出现频率非常高,等下一篇接着聊。最后分享一个小技巧:学设计模式别只看结构,试着在脑子里把每个模式对应到一个你写过的真实代码场景。能对上号,说明你真正理解了。