Skip to content

设计模式决策图:从工程问题选择模式,而不是背分类 ​

23 种模式的定义到处都能查到,但真正需要判断的是两件事:我现在这个问题对应哪个模式,以及这个模式的代价值不值得。这篇按工程问题组织:先用一张决策图定位,再详细讲八个高频模式各自的边界和代价,其余模式只做速查,并说明现代 Java 用什么替代了它们的经典写法。

本文是设计系列的第六篇。模式是在原则之后使用的工具,判断标准见 设计原则,封装、组合与多态这些基础手段见 封装、组合与多态。实验环境:JDK 21.0.5,Spring 相关描述以 Spring Framework 7.0.9 文档为准。

一、先说结论 ​

  • 模式是问题的答案,不是设计目标。 先能用一句具体的话描述问题(「每加一种支付渠道要改三处」),再考虑用哪个模式。
  • 高频模式只有八个左右:工厂、建造者、适配器、装饰器、代理、策略、责任链、观察者。其余的知道存在、遇到时能认出来就够。
  • 每个模式都有代价:更多的类、更深的调用栈、更难跟踪的执行路径。实测一个异常经过三层装饰器,抛出点到调用方之间从 1 帧变成 4 帧,经过 JDK 动态代理变成 5 帧。
  • 现代 Java 让很多模式变轻了:Lambda 替代单方法的策略和命令,record 替代简单的建造者,密封类型加 switch 替代封闭集合上的访问者。问题还在,写法变短了。
  • 模式只用了一次不说明用错了。一个真实存在的变化方向,哪怕只在一处出现,也值得用模式隔离;反过来,没有对应变化的模式,用十次也是负担。

二、决策图:四类工程问题 ​

创建:对象怎么来参数多、可选项多建造者调用方不该知道具体类工厂方法 / 静态工厂全局只需要一个交给容器管理结构:怎么包一层接口对不上适配器叠加功能而不改原类装饰器控制访问:权限、延迟、远程代理协作:谁来处理按类型分派算法策略(或 Lambda)多个处理者依次尝试责任链流程固定、步骤可变固定流程 + 回调状态与事件状态决定允许的行为状态机(enum / sealed)一个变化通知多方观察者 / 事件请求要排队、重放命令没有对应问题时使用模式,得到的只是更难读的代码
图 1 · 先把问题归到四类之一,再在类里找匹配的描述;每个格子上面一行是问题,下面一行是对应的模式。描述不出问题时,就还不需要模式
类别问题的描述方式候选模式先考虑的更轻写法
创建「构造参数太多,可选项多」建造者参数少时用 record 或静态工厂
创建「调用方不应该知道具体实现类」工厂方法、静态工厂DI 容器注入接口
结构「第三方接口和我们的接口对不上」适配器—
结构「想在不改原类的情况下叠加功能」装饰器一两处调用时直接写在调用方
结构「要控制对某个对象的访问」代理框架已有的 AOP 能力
协作「一组 if-else 按类型选择算法」策略Lambda、Map<Key, Function>,或密封类型加 switch
协作「多个处理者依次尝试,每个可以决定是否继续」责任链—
协作「流程固定,某几步因场景而异」固定流程 + 回调,或模板方法把可变步骤作为函数参数传入
状态与事件「对象能做什么取决于它的状态」状态机enum 加转换表
状态与事件「一个变化要通知多个关注方」观察者、事件框架的事件机制
状态与事件「请求需要排队、撤销或重放」命令Lambda 加任务队列

其中五类问题各有一篇深挖文章,用实验说明每类模式最容易出错的地方:

这些模式在真实组件里怎样组合,见 三个可复用的服务端组件。

三、高频模式详解 ​

每个模式按同一个结构说明:解决什么、怎么写、JDK 或 Spring 里的例子、什么时候不用它、调试和观测上的代价。

3.1 工厂方法与静态工厂 ​

解决:调用方不应该知道具体创建哪个实现,或者创建逻辑需要根据条件变化。

java
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) ​

解决:构造参数多、可选项多,并且希望对象创建后不可变。

java
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 转换成这个接口。更换供应商时,只需要换一个适配器。这也是依赖倒置的具体做法。

java
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) ​

解决:在不修改原类的前提下,叠加缓存、重试、计量等功能。

java
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 帧:

text
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 最常用的方式。

java
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 管理时,函数式接口更轻:

java
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 加转换表),并用条件更新保证并发安全,见 订单、库存与数据一致性 与 把状态分支变成可验证模型。

五、语言能力让模式变轻 ​

Lambda / 函数式接口策略、命令、回调:一个方法的接口不必再写实现类record不可变值对象;字段少时不再需要建造者sealed + switch 模式匹配封闭类型上的访问者、状态分派enum单例、有限状态机DI 容器 / ServiceLoader单例、工厂、插件发现与组装
图 2 · 很多经典写法是为了弥补早期 Java 缺少的能力;有了 Lambda、record、密封类型和 DI 容器之后,同样的意图可以用更少的类表达,但背后的问题并没有消失

很多经典写法是为了弥补早期 Java 缺少的能力。能力补上之后,模式背后的问题依然存在,但写法可以短得多:

语言或框架能力影响的模式变化
Lambda、函数式接口策略、命令、回调只有一个方法的接口不必再写实现类
record值对象、简单的建造者不可变数据类一行声明;字段少时不需要建造者
密封类型 + switch 模式匹配访问者、状态封闭类型集合上的分派集中在一处,由编译器检查穷举
enum单例、状态机天然单例;有限状态可以带行为
DI 容器单例、工厂、组装对象的生命周期和依赖关系由容器管理
ServiceLoader工厂、插件按接口发现运行时可用的实现

判断要不要写成经典形式,看两点:这个行为是否需要状态或多个方法(需要就写成类),以及是否需要被容器管理(需要注入依赖、需要代理增强,就写成 Bean)。都不需要时,一个 Lambda 就够了。

六、什么时候不要用模式 ​

  • 问题描述不出来时。 说不清「现在哪里痛」,就没有必要引入模式。
  • 变化方向不确定时。 抽象的方向错了,比不抽象更难改。
  • 只有一个实现、也看不到第二个时。 策略模式只有一个策略,等于多写了两个文件。
  • 团队不熟悉时。 模式的价值之一是共同的词汇,如果反而增加理解成本,收益是负的。
  • 语言特性能解决时。 见上一节。

七、常见误区 ​

  • 「模式用得越多设计越好」:模式换来的是某个方向的变化变得容易,代价是更多的类和更深的调用栈。
  • 「模式只用一次说明不需要」:判断标准是有没有对应的真实变化,而不是使用次数。
  • 「unmodifiableList 返回不可变集合」:它是只读视图,底层列表变了它也会变。
  • 「JdbcTemplate 是模板方法」:它是固定流程加回调,官方文档明确说明不需要继承它。
  • 「用 switch 分派类型就是没用好策略模式」:封闭的类型集合上,穷举 switch 往往更清楚。

小结 ​

用模式的顺序是:先描述问题 → 再匹配模式 → 最后评估代价。描述问题时用具体的句子,而不是「这里设计得不好」。匹配到模式之后,还要问两句:这个模式换来的是不是一个真实存在的变化方向?现代 Java 有没有更轻的写法?如果答案分别是「不是」和「有」,简单的写法就是更好的设计。


配套实验

可以用 make verify 一条命令复现;链接固定在实验仓库的 c9f6692 版本。

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权