设计模式决策图:从工程问题选择模式,而不是背分类
23 种模式的定义到处都能查到,但真正需要判断的是两件事:我现在这个问题对应哪个模式,以及这个模式的代价值不值得。这篇按工程问题组织:先用一张决策图定位,再详细讲八个高频模式各自的边界和代价,其余模式只做速查,并说明现代 Java 用什么替代了它们的经典写法。
本文是设计系列的第六篇。模式是在原则之后使用的工具,判断标准见 设计原则,封装、组合与多态这些基础手段见 封装、组合与多态。实验环境:JDK 21.0.5,Spring 相关描述以 Spring Framework 7.0.9 文档为准。
一、先说结论
- 模式是问题的答案,不是设计目标。 先能用一句具体的话描述问题(「每加一种支付渠道要改三处」),再考虑用哪个模式。
- 高频模式只有八个左右:工厂、建造者、适配器、装饰器、代理、策略、责任链、观察者。其余的知道存在、遇到时能认出来就够。
- 每个模式都有代价:更多的类、更深的调用栈、更难跟踪的执行路径。实测一个异常经过三层装饰器,抛出点到调用方之间从 1 帧变成 4 帧,经过 JDK 动态代理变成 5 帧。
- 现代 Java 让很多模式变轻了:Lambda 替代单方法的策略和命令,
record替代简单的建造者,密封类型加switch替代封闭集合上的访问者。问题还在,写法变短了。 - 模式只用了一次不说明用错了。一个真实存在的变化方向,哪怕只在一处出现,也值得用模式隔离;反过来,没有对应变化的模式,用十次也是负担。
二、决策图:四类工程问题
| 类别 | 问题的描述方式 | 候选模式 | 先考虑的更轻写法 |
|---|---|---|---|
| 创建 | 「构造参数太多,可选项多」 | 建造者 | 参数少时用 record 或静态工厂 |
| 创建 | 「调用方不应该知道具体实现类」 | 工厂方法、静态工厂 | DI 容器注入接口 |
| 结构 | 「第三方接口和我们的接口对不上」 | 适配器 | — |
| 结构 | 「想在不改原类的情况下叠加功能」 | 装饰器 | 一两处调用时直接写在调用方 |
| 结构 | 「要控制对某个对象的访问」 | 代理 | 框架已有的 AOP 能力 |
| 协作 | 「一组 if-else 按类型选择算法」 | 策略 | Lambda、Map<Key, Function>,或密封类型加 switch |
| 协作 | 「多个处理者依次尝试,每个可以决定是否继续」 | 责任链 | — |
| 协作 | 「流程固定,某几步因场景而异」 | 固定流程 + 回调,或模板方法 | 把可变步骤作为函数参数传入 |
| 状态与事件 | 「对象能做什么取决于它的状态」 | 状态机 | enum 加转换表 |
| 状态与事件 | 「一个变化要通知多个关注方」 | 观察者、事件 | 框架的事件机制 |
| 状态与事件 | 「请求需要排队、撤销或重放」 | 命令 | Lambda 加任务队列 |
其中五类问题各有一篇深挖文章,用实验说明每类模式最容易出错的地方:
- 对象由谁创建、单例在什么范围内唯一、复制时身份怎么处理:对象不只是 new 出来
- 适配器、装饰器、代理的意图、顺序与失效:都是包一层,意图却不同
- 策略、回调、责任链怎样形成扩展点:可扩展流程怎样设计
- 观察者、框架事件与跨进程消息的边界:一次状态变化怎样通知多方
- 状态机的转换表、并发与副作用:把状态分支变成可验证模型
这些模式在真实组件里怎样组合,见 三个可复用的服务端组件。
三、高频模式详解
每个模式按同一个结构说明:解决什么、怎么写、JDK 或 Spring 里的例子、什么时候不用它、调试和观测上的代价。
3.1 工厂方法与静态工厂
解决:调用方不应该知道具体创建哪个实现,或者创建逻辑需要根据条件变化。
public interface PaymentGateway { PaymentResult pay(PaymentRequest req); }
public final class PaymentGateways {
private PaymentGateways() {}
public static PaymentGateway of(Channel channel, GatewayConfig cfg) {
return switch (channel) {
case ALIPAY -> new AlipayGateway(cfg.alipay());
case WECHAT -> new WechatGateway(cfg.wechat());
};
}
}例子:List.of()、Path.of()、NumberFormat.getInstance(locale)。Spring 的 BeanFactory 是整个容器的工厂;FactoryBean 是一种特殊的 Bean,容器暴露的是它生产的对象,而不是它本身。
不用它的时候:只有一个实现,new 就能说清楚。在 Spring 项目里,大部分「根据配置选择实现」的需求直接交给容器注入更简单。
代价:对象在哪里被创建不再一目了然。排查问题时要先找到工厂,再找到分支条件。
3.2 建造者(Builder)
解决:构造参数多、可选项多,并且希望对象创建后不可变。
public final class HttpRequestSpec {
private final String url;
private final Duration timeout;
private final Map<String, String> headers;
private HttpRequestSpec(Builder b) {
this.url = Objects.requireNonNull(b.url);
this.timeout = b.timeout;
this.headers = Map.copyOf(b.headers); // 防御性复制
}
public static Builder builder(String url) { return new Builder(url); }
public static final class Builder {
private final String url;
private Duration timeout = Duration.ofSeconds(5);
private final Map<String, String> headers = new LinkedHashMap<>();
private Builder(String url) { this.url = url; }
public Builder timeout(Duration t) { this.timeout = t; return this; }
public Builder header(String k, String v) { headers.put(k, v); return this; }
public HttpRequestSpec build() { return new HttpRequestSpec(this); }
}
}必填参数放在 builder(...) 的参数里,可选参数用链式方法设置,这样漏掉必填项在编译期就会暴露。
例子:java.net.http.HttpRequest.newBuilder()、Stream.Builder、StringBuilder(可变的构建器,严格说不产出不可变对象)。
不用它的时候:字段少、都是必填时,record 或构造器更直接。
代价:每个字段要写两遍(对象和 Builder);Builder 本身可变,不能在线程之间共享。
3.3 适配器(Adapter)
解决:一个类的功能正确,但接口和调用方期望的不一样。
最常见的用途是接入第三方 SDK:业务层定义自己需要的接口,适配器把 SDK 转换成这个接口。更换供应商时,只需要换一个适配器。这也是依赖倒置的具体做法。
public interface SmsSender { void send(PhoneNumber to, String text); } // 业务层定义
class VendorSmsAdapter implements SmsSender { // 外层实现
private final VendorSmsClient client;
public void send(PhoneNumber to, String text) {
VendorResponse r = client.sendMessage(new VendorRequest(to.e164(), text, "SIGN"));
if (!"OK".equals(r.code())) throw new SmsFailedException(r.code(), r.message());
}
}例子:InputStreamReader(字节流适配成字符流)、Arrays.asList()(数组适配成 List 视图)。
不用它的时候:第三方接口已经和业务需要一致,或者只在一处使用且不会更换。
代价:错误码和异常的翻译容易做得不完整,第三方的失败信息在转换中丢失。适配器里要保留原始错误码。
3.4 装饰器(Decorator)
解决:在不修改原类的前提下,叠加缓存、重试、计量等功能。
record CachingDataSource(DataSource delegate, Cache<String, String> cache) implements DataSource {
public String read(String key) { return cache.get(key, delegate::read); }
}
DataSource ds = new MetricsDataSource(new CachingDataSource(new JdbcDataSource(…), cache));例子:java.io 的整套流:new BufferedInputStream(new GZIPInputStream(new FileInputStream(f)))。
不用它的时候:功能只需要加在一两个调用点上,直接写在调用方更清楚。
代价:实测一个异常从最内层抛出,经过三层装饰器,抛出点到调用方之间从 1 帧变成 4 帧:
at Frames$Db.read(Frames.java:5)
at Frames$Logging.read(Frames.java:6)
at Frames$Caching.read(Frames.java:7)
at Frames$Metrics.read(Frames.java:8)
at Frames.main(Frames.java:25)层数还算清楚。更难排查的是顺序:缓存在计量外面还是里面,决定了命中缓存的请求会不会被计入耗时。装配顺序要集中写在一处,而不是散落在各个配置里。
3.5 代理(Proxy)
解决:控制对象的访问:权限校验、延迟加载、远程调用、事务。
和装饰器的区别在意图:装饰器增加功能,代理控制访问。实现上两者都持有被包装的对象。
例子:Spring AOP 就是代理:@Transactional、@Cacheable 都通过代理织入。Collections.unmodifiableList() 返回的是底层列表的只读视图,也可以看作一个限制访问的包装。
注意只读视图和不可变集合不是一回事。实测:
| 修改底层列表之后 | add | 允许 null | |
|---|---|---|---|
Collections.unmodifiableList(backing) | 跟着变化 | 抛 UnsupportedOperationException | 允许 |
List.copyOf(backing) | 不受影响 | 抛 UnsupportedOperationException | 不允许 |
List.of(...) | —(没有底层列表) | 抛 UnsupportedOperationException | 不允许,构造时抛 NullPointerException |
需要一个真正不可变的值时,用 List.copyOf 或 List.of;只是不想让调用方修改、但自己还要继续修改时,才用只读视图。
不用它的时候:自调用、final 方法、private 方法都绕过代理,这时注解不会生效,见 Spring AOP 为什么会失效。
代价:实测 JDK 动态代理让抛出点到调用方之间变成 5 帧,其中包括反射调用和一个没有源码的 $Proxy0。Spring 的 CGLIB 代理在栈里显示为 $$SpringCGLIB$$ 类。排查时要先确认调用有没有经过代理。
3.6 策略(Strategy)
解决:同一件事有多种算法,需要在运行时选择。这是替换按类型分派的 if-else 最常用的方式。
public interface ShippingFee {
boolean supports(ShippingMethod method);
BigDecimal calculate(Order order);
}
@Component
class ShippingFeeRouter {
private final List<ShippingFee> strategies; // Spring 注入所有实现
ShippingFeeRouter(List<ShippingFee> strategies) { this.strategies = strategies; }
BigDecimal fee(Order order) {
return strategies.stream()
.filter(s -> s.supports(order.shippingMethod()))
.findFirst()
.orElseThrow(() -> new IllegalStateException("未知配送方式:" + order.shippingMethod()))
.calculate(order);
}
}策略只有一个方法、也不需要被 Spring 管理时,函数式接口更轻:
Map<ShippingMethod, Function<Order, BigDecimal>> fees = Map.of(
ShippingMethod.STANDARD, o -> new BigDecimal("8"),
ShippingMethod.EXPRESS, o -> o.weightKg().multiply(new BigDecimal("3")));不用它的时候:分支只有两个且不会增加。类型集合封闭、由自己掌控时,密封类型加穷举 switch 更直接,新增类型时编译器会指出所有漏处理的地方。
代价:选择逻辑(supports)分散在各个实现里,多个策略同时匹配时的优先级容易出错。建议在启动时检查每个键恰好有一个策略。
3.7 责任链(Chain of Responsibility)
解决:多个处理者依次处理同一个请求,每个处理者可以决定继续传递还是就此结束。
例子:Servlet 的 Filter、Spring Security 的过滤器链、Netty 的 ChannelPipeline、OkHttp 的 Interceptor。
关键设计点:每个节点要明确「处理后是否继续」,以及链条中断时返回什么。节点的顺序本身就是业务规则(先鉴权还是先限流),要显式配置。
不用它的时候:处理步骤固定、没有「提前结束」的需求时,一个按顺序调用的方法更清楚。
代价:请求在哪个节点被拦截,从调用方看不出来。链超过五六个节点时,建议给每个节点记录是否放行和耗时。
3.8 观察者(Observer)与事件
解决:一个状态变化需要通知多个关注方,并且发布方不应该知道订阅方是谁。
例子:Spring 的 ApplicationEventPublisher 与 @EventListener(Spring 4.2 起,任意对象都可以作为事件发布,不必继承 ApplicationEvent)、Guava 的 EventBus。
使用时要回答三个问题:
- 同步还是异步:Spring 事件默认同步执行,订阅方的耗时直接加在发布方身上。
- 异常怎么传播:同步时,一个订阅方抛出的异常会回到发布方。
- 和事务的关系:需要在提交后才执行的,用
@TransactionalEventListener。
不用它的时候:只有一个订阅方、且发布方本来就知道它,直接调用更清楚。
代价:执行路径被打散,从发布方看不到谁会被触发。进程内事件总线的异常隔离与内存泄漏,见 Guava EventBus。
四、其余模式速查
这些模式在业务代码里出现得少,要么是因为问题本身少见,要么是因为语言和框架已经提供了更轻的写法。
| 模式 | 解决 | 为什么低频 | 现在更常见的写法 |
|---|---|---|---|
| 单例 | 全局只需要一个实例 | Spring Bean 默认就是单例;手写单例难以测试 | 交给容器;必须手写时用 enum |
| 抽象工厂 | 创建一组配套的对象 | 需要「产品族」的场景少 | 按配置注入一组 Bean |
| 原型 | 从已有对象复制出新对象 | 业务对象的创建成本通常不高;Cloneable 设计有缺陷 | 拷贝构造器,或在 record 上手写 withXxx 方法 |
| 外观 | 给复杂子系统一个简单入口 | 很常见,但通常不被当作「模式」 | 应用服务 |
| 桥接 | 两个维度独立变化,避免类爆炸 | 需要两个独立变化维度的场景少 | 组合两个接口 |
| 组合 | 统一处理树形结构的叶子和容器 | 只在树形结构里出现 | 递归的 record 或密封类型 |
| 享元 | 大量细粒度对象共享不变部分 | 大多数场景对象数量不足以成为问题 | Integer.valueOf 的缓存这类库内优化 |
| 模板方法 | 流程固定,步骤由子类实现 | 依赖继承,子类和父类耦合紧 | 固定流程 + 回调或函数参数,如 JdbcTemplate |
| 状态 | 对象的行为随状态变化 | 状态类的数量和切换逻辑难以一眼看清 | enum 状态机,或密封类型加 switch |
| 命令 | 把请求封装成对象,支持排队、撤销 | 撤销和重放的需求少 | Runnable、Callable 加任务队列 |
| 迭代器 | 遍历集合而不暴露内部结构 | 语言已经内置 | Iterable、增强 for、Stream |
| 中介者 | 多个对象互相引用时集中协调 | 容易变成承担一切的大类 | 应用服务,或事件 |
| 备忘录 | 保存并恢复对象状态 | 需要撤销的场景少 | 不可变对象的快照 |
| 访问者 | 不修改元素类就能增加新操作 | 双重分派写法复杂 | 密封类型加 switch 模式匹配 |
| 解释器 | 解释一种小语言 | 自己实现语言的场景少 | 现成的表达式引擎,如 SpEL |
两个需要补充说明的:
- 模板方法:Spring 的
JdbcTemplate常被归到这里,但它的官方文档把自己描述为执行 JDBC 核心流程的「中心委托对象」,调用方通过RowMapper这类回调接口提供 SQL 和结果提取,并明确说明不需要继承它。它体现的是「固定流程 + 回调」,而不是经典的「子类重写步骤」。 - 状态:订单、工单、审批流都有状态,但状态流转规则最好集中写在一处(
enum加转换表),并用条件更新保证并发安全,见 订单、库存与数据一致性 与 把状态分支变成可验证模型。
五、语言能力让模式变轻
很多经典写法是为了弥补早期 Java 缺少的能力。能力补上之后,模式背后的问题依然存在,但写法可以短得多:
| 语言或框架能力 | 影响的模式 | 变化 |
|---|---|---|
| Lambda、函数式接口 | 策略、命令、回调 | 只有一个方法的接口不必再写实现类 |
record | 值对象、简单的建造者 | 不可变数据类一行声明;字段少时不需要建造者 |
密封类型 + switch 模式匹配 | 访问者、状态 | 封闭类型集合上的分派集中在一处,由编译器检查穷举 |
enum | 单例、状态机 | 天然单例;有限状态可以带行为 |
| DI 容器 | 单例、工厂、组装 | 对象的生命周期和依赖关系由容器管理 |
ServiceLoader | 工厂、插件 | 按接口发现运行时可用的实现 |
判断要不要写成经典形式,看两点:这个行为是否需要状态或多个方法(需要就写成类),以及是否需要被容器管理(需要注入依赖、需要代理增强,就写成 Bean)。都不需要时,一个 Lambda 就够了。
六、什么时候不要用模式
- 问题描述不出来时。 说不清「现在哪里痛」,就没有必要引入模式。
- 变化方向不确定时。 抽象的方向错了,比不抽象更难改。
- 只有一个实现、也看不到第二个时。 策略模式只有一个策略,等于多写了两个文件。
- 团队不熟悉时。 模式的价值之一是共同的词汇,如果反而增加理解成本,收益是负的。
- 语言特性能解决时。 见上一节。
七、常见误区
- 「模式用得越多设计越好」:模式换来的是某个方向的变化变得容易,代价是更多的类和更深的调用栈。
- 「模式只用一次说明不需要」:判断标准是有没有对应的真实变化,而不是使用次数。
- 「
unmodifiableList返回不可变集合」:它是只读视图,底层列表变了它也会变。 - 「
JdbcTemplate是模板方法」:它是固定流程加回调,官方文档明确说明不需要继承它。 - 「用
switch分派类型就是没用好策略模式」:封闭的类型集合上,穷举switch往往更清楚。
小结
用模式的顺序是:先描述问题 → 再匹配模式 → 最后评估代价。描述问题时用具体的句子,而不是「这里设计得不好」。匹配到模式之后,还要问两句:这个模式换来的是不是一个真实存在的变化方向?现代 Java 有没有更轻的写法?如果答案分别是「不是」和「有」,简单的写法就是更好的设计。
配套实验
- codesphere-labs/design/jdk-api-facts:装饰器与动态代理的栈帧数、只读视图与不可变集合(验证记录)
可以用 make verify 一条命令复现;链接固定在实验仓库的 c9f6692 版本。
参考资料
- Gamma 等,《Design Patterns: Elements of Reusable Object-Oriented Software》
- Joshua Bloch,《Effective Java》第 3 版(第 2 章、第 4 章)
- Spring Framework API:JdbcTemplate
- Spring Framework API:FactoryBean
- Spring Framework Reference:Standard and Custom Events
- Spring Framework Reference:Aspect Oriented Programming with Spring
- JDK 21 API:java.util.List(不可修改的列表)