跳到正文

行为型模式:策略、责任链、观察者、模板方法、命令、状态 ​

一句话:行为型模式解耦「谁调用谁」:策略让调用方不依赖具体算法,责任链让请求方不依赖具体处理者,观察者让发布方不依赖订阅者,模板方法把流程骨架和可变步骤分开,命令把请求变成对象,状态把状态相关的行为收进状态类。

为什么需要 ​

对象协作一旦硬编码,就会出现三类问题:调用方知道太多实现(if-else 选算法)、请求方知道太多处理者(一串 if 校验)、发布方知道太多副作用(注册后发短信、发券、记日志全写在一个方法里)。行为型模式把「选择」「顺序」「通知」「流程」「请求」「状态」分别抽出来,让变化点独立。

核心机制 ​

问题模式判断信号
多种算法选一个策略 Strategy分支的变更原因不同,且会继续增加
请求依次经过多个检查点责任链 Chain of Responsibility处理者可排序、可短路、可增删
一个动作触发多个独立反应观察者 Observer发布方不该知道谁在听
流程固定、步骤可变模板方法 Template Method骨架和顺序稳定,个别步骤因数据源而异
请求要排队、撤销、审计命令 Command调用本身需要被存储或传递
行为随内部状态变化状态 State状态间有明确迁移,且迁移规则会变

策略:把「选哪个」从调用点移走 ​

java
interface Uploader { String upload(byte[] data); }
final class OssUploader implements Uploader { public String upload(byte[] d) { return "oss://..."; } }
final class UploadRouter {
    private final Map<String, Uploader> routes;
    UploadRouter(Map<String, Uploader> routes) { this.routes = Map.copyOf(routes); }
    Uploader get(String type) {
        Uploader u = routes.get(type);
        if (u == null) throw new IllegalArgumentException("unknown type: " + type);
        return u;
    }
}

真实使用点:java.util.Comparator 是可替换的比较策略;Spring MVC 的 HandlerMapping 按请求选择处理器;Spring 会把 Map<String, Uploader> 按 Bean 名注入所有实现。代价是多一个接口和一次查找;变体少且稳定时,if-else 更直接。

责任链:重点是「组装」,不是单个 Handler ​

最小实现见「最小上手」的 ChainDemo。组装决定行为:new Chain(List.of(new AuthHandler(), new RateLimitHandler(), new BizHandler()))。顺序、是否短路、异常由谁处理,都在组装处显式表达。Spring 里用 @Order 注入 List<Handler> 自动排序;Servlet 的 FilterChain 把链自身传给 doFilter,Handler 决定调不调;Netty 的 ChannelPipeline 是双向链表,入站和出站分别传播;MyBatis 的 InterceptorChain#pluginAll 用动态代理嵌套,不需要 Handler 手动调 next。

观察者:发布方只发事实 ​

java
publisher.publishEvent(new OrderPaid(orderId));
@EventListener @Async
void sendSms(OrderPaid e) { sms.send(e.orderId()); }
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
void grantCoupon(OrderPaid e) { coupon.grant(e.orderId()); }

真实使用点:Spring 的 ApplicationEventMulticaster#multicastEvent;事务场景必须用 @TransactionalEventListener,否则事务回滚了通知已经发出去。跨进程的同构需求用 MQ 的发布订阅,不要拿进程内事件冒充。

模板方法:骨架在父类,步骤在子类 ​

java
abstract class SyncJob {
    public final void run() {                 // final:不允许子类改流程
        validate();
        List<Row> rows = load();
        write(transform(rows));
    }
    protected void validate() {}              // 钩子,可选覆盖
    protected abstract List<Row> load();
    protected abstract List<Doc> transform(List<Row> rows);
    protected abstract void write(List<Doc> docs);
}

真实使用点:JdbcTemplate#execute(ConnectionCallback) 固定「取连接 → 执行 → 释放」,SQL 和结果处理由回调传入;AbstractApplicationContext#refresh() 固定容器刷新步骤;AQS 的 acquire() 固定「尝试获取 → 入队 → 阻塞」,tryAcquire() 留给子类;MyBatis BaseExecutor#query 固定「查缓存 → 查库 → 清缓存」。

命令:请求本身成为对象 ​

java
interface Command { void execute(); }
final class Invoker {
    private final Deque<Command> history = new ArrayDeque<>();
    void run(Command c) { c.execute(); history.push(c); }
    void undoLast() { if (!history.isEmpty()) history.pop(); /* 逆操作由命令自己实现 */ }
}

真实使用点:Runnable/Callable 是把任务封装成对象的最小形态;Netty 的写操作被封装成 WriteTask 放进 EventLoop 队列;XXL-JOB 的 IJobHandler 把任务参数和执行逻辑封装起来。命令模式只有在需要排队、撤销、审计、延迟执行时才划算;普通方法调用不需要。

状态:把迁移规则收进状态类 ​

java
interface OrderState { void pay(Order o); void ship(Order o); }
final class Pending implements OrderState {
    public void pay(Order o) { o.setState(new Paid()); }
    public void ship(Order o) { throw new IllegalStateException("unpaid"); }
}
final class Paid implements OrderState {
    public void pay(Order o) { throw new IllegalStateException("already paid"); }
    public void ship(Order o) { o.setState(new Shipping()); }
}

真实使用点:订单、工单、审批流的生命周期;Spring StateMachine 把状态、事件、转移规则外置,支持持久化和审计。状态对象通常无状态,可以是单例;状态本身要持久化到订单记录里。状态少且迁移稳定时,枚举 + switch 更容易读。

实战场景 ​

一、新增一个策略,忘了登记:上线即“未知支付方式” ​

现象:接入“云闪付”支付时新增 UnionPayStrategy,但 Map<String, PayStrategy> 的登记表是手写的,漏了这一项。灰度当天该渠道全部失败,报 No strategy for code: UNIONPAY;另外两处 switch (channel) 分支同样漏改,分布在工厂与对账任务里。

排查:策略模式的价值是把“选哪个”从调用点移走,前提是选择器完备。

定位层取证据判定
登记方式策略表是手写 Map.of(...)/switch,还是容器注入构建手写 → 必然有漏项
分支残留全仓搜 switch (channel)、if ("UNIONPAY".equals命中即“选哪个”没有完全移走
启动校验启动时是否断言“枚举/配置里的每个 code 都有实现”无校验 → 到第一次调用才暴露
失败形态未知 code 是抛异常、返回 null 还是走默认默认分支会把配置错误变成静默错误
新增策略的改动文件数接入一个渠道实际改了哪几处3 处 → 扩展点开环

对症做法:

java
// 1. 策略自报编码,容器注入自动建表:新增实现只加一个类
public interface PayStrategy {
    String code();                       // 每个策略声明自己负责哪个 code
    PayResult pay(PayRequest req);
}

@Component
class UnionPayStrategy implements PayStrategy {
    public String code() { return "UNIONPAY"; }
    public PayResult pay(PayRequest req) { ... }
}

@Service
class StrategyRegistry {
    private final Map<String, PayStrategy> byCode;
    StrategyRegistry(List<PayStrategy> strategies) {
        this.byCode = strategies.stream().collect(toUnmodifiableMap(PayStrategy::code, identity()));
    }
    public PayStrategy of(String code) {
        PayStrategy s = byCode.get(code);
        if (s == null) throw new UnsupportedPayChannel(code, byCode.keySet());   // 明确失败,不走默认
        return s;
    }
}

// 2. 启动期断言完备:配置里声明的渠道必须都有实现,缺一个就别起来
@PostConstruct void assertComplete() {
    Set<String> missing = config.channels().stream().filter(c -> !registry.has(c)).collect(toSet());
    if (!missing.isEmpty()) throw new IllegalStateException("no strategy for " + missing);
}
text
3. 清掉 switch/if 分支:把“按 code 选实现”这件事只留 registry.of(code) 一个入口;
4. 未知 code 必须显式失败并告警,不允许默默走兜底渠道(资金类尤其如此)。

复盘要点:判据是“新增一个策略需要改动的文件数 = 1”和“启动完备性断言存在”。两者都满足后,接入渠道不再依赖开发者记不记得登记表;未知 code 走默认分支是更危险的做法——它把配置错误转成资金路由错误。

二、责任链漏了一个处理器:脏数据直接落库 ​

现象:内容审核链路原本有 6 个 Handler(长度、敏感词、图片合规、实名校验、频控、落库)。为提升吞吐把顺序改成“落库前置”,同时频控 Handler 在 return 分支里没有调用下一个处理器。结果是:未过敏感词校验的内容直接发布,运营发现 300 余条违规内容在线(经验值,需校准),日志里没有任何拒绝记录。

排查:责任链的重点是组装,不是单个 Handler。

定位层取证据判定
链的实际形态运行时打印 chain.names(),与文档比对顺序顺序与设计不同 → 组装失控
终止语义Handler 拒绝时是否显式终止并记录静默 return → 请求被吞,无人知晓
传递完整性每个 Handler 是否都调用 next.handle()漏调用即链路截断
兜底行为走到链尾仍未处理时是“通过”还是“拒绝”默认通过等于把漏洞留给遗漏
可观测每个 Handler 的进入数与拒绝数进入数为 0 说明前面的环节截断了

对症做法:

java
// 1. 链由一处组装,顺序成为显式定义;禁止在业务代码里散着 add
public static Handler newPublishChain(Deps d) {
    return new LengthLimit(d)
        .next(new SensitiveWord(d))
        .next(new ImageCompliance(d))
        .next(new RealNameCheck(d))
        .next(new RateLimit(d))
        .next(new Persist(d));            // 落库一定在最后,且明确写出来
}

// 2. Handler 基类固定“要么传递、要么显式拒绝”,不给静默 return 留空间
abstract class Handler {
    private Handler next;
    final Handler next(Handler h) { this.next = h; return h; }
    final void handle(Context c) {
        metrics.enter(name());
        Outcome o = doHandle(c);
        switch (o) {
            case PASS -> { if (next != null) next.handle(c); else tailMustBeTerminal(c); }
            case REJECT -> { c.reject(name(), o.reason()); metrics.reject(name(), o.reason()); }
        }
    }
    protected abstract Outcome doHandle(Context c);
}

// 3. 走到链尾的默认语义改成“拒绝”,避免遗漏变成放行
void tailMustBeTerminal(Context c) { throw new IllegalStateException("chain ended without persist: " + c.id()); }
java
// 4. 指标验证顺序与截断:每个 Handler 的进入数应单调不增,出现断层即有截断
assertThat(metrics.enter("SensitiveWord")).isLessThanOrEqualTo(metrics.enter("LengthLimit"));

复盘要点:链类改动必须有“结构断言”测试:chain.names() 序列等于预期清单,且每个 Handler 的进入数与上游比值合理。默认通过 vs 默认拒绝是责任链的关键取舍——校验类一律默认拒绝,补齐才允许通过。这与 MyBatis 插件机制 里插件嵌套顺序是同一个问题:组装顺序决定行为。

三、监听器互相依赖顺序:一个失败拖垮全部 ​

现象:OrderPaid 事件有三个同步监听器:发通知、记积分、更新报表。为省代码把“更新报表”写成依赖前两者的执行顺序;某次通知服务超时抛异常,Spring 默认顺序下后面的积分与报表监听器都没执行,且异常回滚了主事务——订单支付状态没落库。

排查:观察者只保证“发布方不依赖订阅者”,不保证顺序与隔离。

定位层取证据判定
是否同步同事务监听器异常是否导致主流程回滚回滚 → 订阅者失败被当成业务失败
顺序依赖是否有监听器读取“上一个监听器写的状态”有 → 隐式耦合,注册顺序一变即坏
失败隔离单个监听器异常是否影响其他影响 → 缺少逐个 try/catch 或异步边界
重复投递事件是否可能被消费多次(异步/重投)是 → 订阅者必须幂等
语义边界事件内容是否是“已发生的事实”写成“请执行某操作”→ 变成命令,责任错位

对症做法:

java
// 1. 事件只描述事实,命名用过去式;订阅者自行决定动作
public record OrderPaid(OrderId id, Money amount, Instant occurredAt) {}

// 2. 提交后再发,避免订阅者失败回滚主流程,也避免“未提交就被读到”
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
void onPaid(OrderPaid e) { notifier.sendAsync(e); }

// 3. 每个订阅者独立失败、独立幂等,禁止依赖他人执行顺序
@EventListener @Async("eventExecutor")
void awardPoints(OrderPaid e) {
    if (processed.exists("points:" + e.id())) return;      // 幂等键
    points.award(e.id(), e.amount());
    processed.mark("points:" + e.id());
}

// 4. 报表更新这种“可延迟”的订阅者改走消息表/任务,不与在线请求同链路
text
5. 真有顺序要求时,不要用观察者实现:把流程显式写进一个编排方法或状态机;
   观察者适用于“互不知晓的旁路订阅”,命令模式才适用于“要求某人做某事”。

复盘要点:判据是“任一监听器抛异常,主事务与其他监听器不受影响”,用一条测试固定:让通知监听器抛错,断言积分与报表仍执行、订单状态已提交。事件与命令的边界也要写进规范——发布方只发事实,动作由订阅者决定;需要保证顺序和结果的流程,交给编排服务或状态机,见 工程落地:分层架构·六边形·CQRS·事件溯源。

最小上手 ​

可运行的责任链组装(JDK 11+ 单文件):

java
import java.util.*;

public class ChainDemo {
    record Request(String token, boolean limited, List<String> log) {}
    interface Handler { void handle(Request r, Chain c); }
    static final class Chain {
        private final List<Handler> hs; private int i;
        Chain(List<Handler> hs) { this.hs = List.copyOf(hs); }
        void proceed(Request r) { if (i < hs.size()) hs.get(i++).handle(r, this); }
    }
    static Handler auth = (r, c) -> { if (r.token() == null) r.log().add("reject:token"); else c.proceed(r); };
    static Handler limit = (r, c) -> { if (r.limited()) r.log().add("reject:limit"); else c.proceed(r); };
    static Handler biz = (r, c) -> r.log().add("ok");

    public static void main(String[] args) {
        var log = new ArrayList<String>();
        new Chain(List.of(auth, limit, biz)).proceed(new Request("t", false, log));
        if (!log.equals(List.of("ok"))) throw new AssertionError(log);
    }
}

运行 java ChainDemo.java。把 auth 放到 biz 后面,鉴权就失去短路意义;把 limit 改成无条件 proceed,限流失效——链的行为由组装顺序和每个 Handler 的放行逻辑共同决定,单看 Handler 看不出来。

生产动作:误用与代价 ​

模式误用代价与处置
策略两个长期不变的枚举也建策略体系多接口、多类、多一次查找;变体少于 3 且稳定时保留 if-else
责任链链太长、顺序靠经验延迟累加、故障定位难;按场景分组,最快的失败检查放最前,给每个 Handler 打耗时
观察者默认同步监听器里做慢操作发布线程被拖慢;慢操作改 @Async 并配独立线程池,事务后置用 AFTER_COMMIT
观察者监听器异常影响主流程Spring 同步事件默认会把异常抛回发布方;明确哪些监听允许失败,必要时加错误处理器
模板方法子类越来越多,骨架被迫加钩子继承耦合;步骤差异变大时改用策略组合,别用继承硬套
命令为了「看起来解耦」把每个调用都包成命令类爆炸、调试多一层;只在需要队列/撤销/审计时使用
状态状态类里做 I/O 或持有可变全局状态状态对象难以复用、测试变慢;状态类只做迁移判断,副作用放应用层

边界 ​

  • 两三个分支的 if-else 不是坏味道:变更频率低时,策略模式增加的理解成本大于收益。
  • 责任链只适合顺序处理:需要并行扇出、聚合结果时,用并发编排或消息,不要硬塞进链。
  • 观察者不保证投递:进程内事件没有持久化,JVM 退出就丢;跨进程、需可靠投递的场景用 Outbox + MQ。
  • 模板方法受继承限制:Java 单继承,父类骨架一变,所有子类都受影响;组合 + 策略通常更灵活。
  • 状态模式不替代状态机引擎:状态和事件多、需要可视化/持久化/审计时,用 Spring StateMachine 或专用引擎。

讲清楚 ​

行为型模式解决「对象怎么协作而不硬编码」。策略把算法选择移到调用方之外,适合分支会持续增加;责任链把多个检查点串起来,关键在组装顺序和短路逻辑,Spring 拦截器、Servlet Filter、Netty Pipeline、MyBatis 插件都是它的不同形态;观察者让发布方只发事实,进程内用 Spring Event,注意同步异常和事务边界,跨进程用 MQ;模板方法把固定骨架放父类,可变步骤留给子类或回调,JdbcTemplate 和 AQS 都是;命令把请求变成对象,只在排队、撤销、审计时才需要;状态把迁移规则收进状态类,状态少时枚举加 switch 更简单。选型的标准是变化点和成本,不是模式数量。

初稿 2025-06 | 最后修订 2026-10

把一门技术讲透到既懂也会用