设计模式:动机、取舍与语言相关性

设计模式

本文是设计模式的个人梳理笔记,不追求面面俱到,而是聚焦于动机(为什么)与取舍(何时不用)。适合有一定 Java/Spring 开发经验、想建立设计模式全局观的读者。文中"出现概率"均基于笔者在开发过程中的主观感受,仅供参考。

logged in 2020 and written with Claude.

设计模式是为了封装变化,让各个模块可以独立变化。多关注问题本身以及动机。


总纲

  1. SOLID
  2. 介绍方式:What(这个模式做什么),Why(为什么选这个),How(怎么用这个模式),Related(与其他模式的关联)
  3. 设计模式的争议
    1. Abuse:只有一把铁锤,任何东西看上去都像钉子。
    2. 往往设计模式是在常规思路、编程语言上没有很好的解决方案时需要采用的。
      1. Are Design Patterns Missing Language Features
      2. 往往是 Java 的语言约束导致了这些设计模式的诞生,例如:Java 不支持动态分派(访问者模式)。
        • 如果语言支持类型能够当参数进行传递(或动态分派),创建型、Visitor 模式基本失去了意义。
        • 如果语言支持函数作为参数传递(或函数为一等公民),行为型基本失去了意义。
        • 大部分结构型的方案基本就是将继承转换为组合
      3. 因此,在 Kotlin/Scala/Go 中,很多行为型模式可以直接用高阶函数或接口组合替代;本文的模式讨论以 Java/Spring 生态为主要背景。

各模式的语言相关性一览:

分为两类:「语言催生」的模式——本质上是对语言表达力不足的补丁,换一门语言可以天然消除;「问题催生」的模式——解决的是系统复杂度本身,任何语言都需要。

类型 模式 Java 中为何需要 可天然替代的语言 / 特性
语言催生 策略 无一等函数,必须定义接口+实现类 Kotlin / Python / JS:直接传 lambda
语言催生 命令 同上,参数化"动作"只能靠对象包装 同上
语言催生 模板方法 无高阶函数,只能靠继承参数化步骤 Kotlin:高阶函数直接作为参数传入
语言催生 工厂方法 / 抽象工厂 类不能作为值传递,绕过编译期类型约束 Python / Ruby:类本身即对象,可直接传递
语言催生 访问者 只支持单分派,双分派需手动模拟 Julia / Common Lisp:原生多分派,模式不再必要
语言催生 装饰 无委托语法,每个方法都要手写转发 Kotlin:by 委托一行搞定;Python:@decorator
语言催生 建造者 无命名参数 / 默认参数,构造器难以表达可选项 Kotlin / Python:命名参数+默认值直接解决
语言催生 单例 需要手动控制实例化和线程安全 Python 模块天然单例;Go:sync.Once 惯用法
语言催生 原型 Cloneable 设计缺陷,深拷贝需手动实现 Python:copy.deepcopy;JS:结构化克隆
问题催生 观察者 / 责任链 / 中介者 解决通信拓扑复杂度 无语言可天然消除,只是写法(channel、signal)不同
问题催生 状态 / 组合 解决领域建模中的状态爆炸 / 递归树结构问题 同上
问题催生 享元 / 代理 解决资源复用 / 访问控制 同上
问题催生 适配器 / 桥接 / 外观 解决接口兼容、多维变化、子系统隔离 同上,换语言只是少写几行,问题本身不消失
3. 设计模式可以说是为了弥补编程模型上的一些缺陷,并且各种设计模式往往比较适合局部、偏底层的地方。
  1. 减少继承

创建型(对象的生成)

1. 单例模式

  • 意图:全局只有一个实例对象。
  • 动机:仅需要一个单一的实例对象就足够。
  • 出现概率:99%
  • Pros:管理方便容易。
  • Cons:多线程模式下改变单例对象,需要谨慎。
  • ✅ 适用场景:线程池、配置中心、连接池等全局唯一资源。
  • ⛔ 反模式 / 误用:把业务对象做成单例(如把 Service 的有状态字段放在单例里),多线程下出现数据竞争;测试时难以 mock,降低可测性。

2. 建造者模式

  • 意图:使你能够分步骤创建复杂对象。该模式允许你使用相同的创建代码生成不同类型和形式的对象。
  • 动机:减少过多的构造器实现 / 不同的参数,创建不同的对象。
  • 出现概率:90%
  • ExamplesExecutors.newFixedThreadPool() / ThreadPoolExecutor.Builder
  • ✅ 适用场景:参数超过 4 个、有可选参数的复杂对象构建,如 ThreadPoolExecutor、HTTP 请求体。
  • ⛔ 反模式 / 误用:对只有 1~2 个参数的简单对象引入 Builder,徒增模板代码;Builder 内部没有做参数校验,把错误延迟到 build() 后才暴露。

3. 工厂方法(Factory Method)

  • 意图:在父类中提供一个创建对象的接口,允许子类决定实例化对象的类型。
  • 动机:容易扩展创建产品的方式。
  • Pros:解耦创建者和具体产品之间的关联。
  • Cons:引入了更多的子类。
  • ✅ 适用场景:框架需要允许扩展点,让调用方决定创建哪种实现,如日志框架 LoggerFactory
  • ⛔ 反模式 / 误用:每新增一种产品就要新增一个子类,产品类型稳定时用普通静态工厂即可,过度引入子类层级。

4. 抽象工厂模式

  • 意图:一个接口提供一套完整的产品,且不需要知道产品的具体种类。
  • 出现概率:10%
  • Cons:每次新增一个产品时,所有子类都需要进行实现。
  • Examples:Spring 中的 BeanFactory
  • ✅ 适用场景:需要同时切换一整套相关产品族(如跨数据库方言、跨平台 UI 组件)。
  • ⛔ 反模式 / 误用:产品种类频繁变动时使用——每次新增一种产品,所有工厂实现都要改,违反开闭原则。

5. 原型模式(Prototype)

  • 意图:使你能够复制已有对象,而又无需使代码依赖它们所属的类。
  • 结构:实现接口方法 clone()
  • 出现概率:90%
  • Pros:方便地复制对象。
  • Examples:Java 的序列化
  • ✅ 适用场景:对象创建成本高(深层嵌套、IO 初始化),需要频繁产出相似对象。
  • ⛔ 反模式 / 误用:浅拷贝当深拷贝用——共享了内部可变引用,导致"克隆体"互相干扰;Java 的 Cloneable 接口本身设计存在缺陷,优先考虑拷贝构造函数或序列化方案。

总结

将类的创建管理收拢到比较少的对象中,例如 Spring IoC 其实也是一种创建型的思路。


结构型(对象如何组合)

1. 代理模式(Proxy)

  • 意图:用来修改对原对象方法的屏蔽或者增强。(对外暴露的接口保持一致)
  • 动机:RPC 框架代理原本的本地方法、AOP 机制切面增强。
  • 结构:实现一致接口,内部进行组合。
  • 出现概率:99%
  • Pros:开闭原则。
  • Cons:创建成本、调用成本增加。
  • 关联:装饰器、外观模式、适配器
  • Examples:Spring AOP 的核心实现。
  • ✅ 适用场景:RPC 透明调用、AOP 日志/鉴权/事务切面、懒加载。
  • ⛔ 反模式 / 误用:代理层中混入业务逻辑(代理不应该知道业务);动态代理链过长导致栈帧膨胀,排查困难。

2. 适配器(Adapter)

  • 意图:使得不兼容的接口之间能够进行合作。
  • 动机:当某个对象无法直接访问另一个接口方法时,可以采用适配器进行衔接。
  • 出现概率:50%
  • Pros:单一职责原则 / 开闭原则。
  • Cons:复杂度增加,性能降低。
  • 关联:桥接模式往往用在开发初期阶段,适配器模式一般在维护阶段使用,保证重构的可靠性。
  • ExamplesArrays#asList() / Collections#singletonList()
  • ✅ 适用场景:集成第三方 SDK、遗留接口迁移、单元测试中替换外部依赖。
  • ⛔ 反模式 / 误用:在新系统开发初期就引入适配器——说明接口设计本身有问题,应先对齐接口;适配器套适配器,产生多层适配地狱。

3. 桥接(Bridge)

  • 意图:一个类有多个维度的变化时,需要让其进行独立变化。
  • 动机:降低维护变化的成本,将每个变化隔离在一个具体的实现中。
  • 出现概率:10%
  • Pros:单一职责原则 / 开闭原则。
  • ✅ 适用场景:渲染引擎(形状 × 颜色)、消息发送(渠道 × 格式)等多维度正交扩展。
  • ⛔ 反模式 / 误用:只有一个维度变化时强行引入桥接,过度设计;与继承混用,导致类层级仍然爆炸。

4. 组合(Composite)

  • 意图:对象按树状结构组织,并且可以独立地访问某个节点。(类比军队管理)
  • 动机:文件系统的管理,套娃行为的场景。
  • 结构:递归
  • 出现概率:20%
  • Pros:开闭原则。
  • 关联:装饰器模式 / 责任链模式
  • ✅ 适用场景:文件系统、组织架构树、菜单/权限树、表达式树。
  • ⛔ 反模式 / 误用:叶节点和容器节点行为差异大时强行统一接口,导致叶节点里出现大量空实现或抛异常。

5. 装饰(Decorator)

  • 意图:对原始对象进行封装,并提供增强的接口(包括原来的,以及新增的方法)。
  • 动机:无需改动原始类,就可以增强其功能。避免使用子类来扩展,因为继承往往是静态的、单一的,容易导致后期维护成本上升。
  • 结构:组合
  • 出现概率:70%
  • Pros:单一职责原则 / 利用组合替代子类继承。
  • Cons:初始化的行为比较不 elegant。
  • Examples:Java IO 包 / Collections.unmodifiableXXXX() / 编码与压缩
  • ✅ 适用场景InputStream 流式增强、缓存层叠加、权限校验叠加。
  • ⛔ 反模式 / 误用:装饰链太深、顺序敏感但文档不清晰;与代理混用时职责不明(装饰关注功能增强,代理关注访问控制);debug 时调用栈难以阅读。

6. 外观(Facade)

  • 意图:为复杂子系统提供一个简化的统一接口,降低调用方与子系统的耦合。
  • 动机:SPI 机制,可以灵活地采用不同的实现方案(日志、数据库等)。
  • 出现概率:99%
  • Pros:实现与接口独立,容易替换。
  • Cons:该接口与所有对象绑定(因此,这种接口往往是公共的)。
  • ✅ 适用场景:提供统一的子系统入口(如 SLF4J 屏蔽具体日志实现)、微服务 BFF 层。
  • ⛔ 反模式 / 误用:外观类承担了太多协调逻辑,变成"上帝类";接口粒度过粗导致调用方必须拿到不需要的数据。

7. 享元(Flyweight)

  • 意图:共享多个对象中共有且相同的状态,降低内存消耗。
  • 动机:同时存在大量的对象,且存在较多的相同的状态。
  • 关联:很容易想到单例模式,但是单例模式是享元模式的一种特例。在享元模式中,尽管存在相同的状态,但是仍有不同的一些状态需要独立维护。
  • ✅ 适用场景:字符串常量池、Integer 缓存(-128~127)、棋子/粒子等大量相似对象。
  • ⛔ 反模式 / 误用:外部状态被误放入享元对象(享元只能保存内部状态,线程安全性会被破坏);过早优化——对象数量并不大时引入享元徒增复杂度。

对比讨论

  • 适配器 vs 代理 vs 装饰:适配器改变接口;代理不改变接口,但控制访问;装饰不改变接口,但增强功能。
  • 组合 vs 桥接:组合解决"整体-部分"的递归树问题,桥接解决"多维度独立变化"的组合爆炸问题。
  • 享元 vs 单例:单例是享元的特例(整个对象共享),享元允许对象携带不同的外部状态。
  • 外观:不是对单一对象的包装,而是对整个子系统的统一入口,隐藏内部复杂性。

行为型(对象之间的通信)

1. 责任链(Chain of Responsibility)

  • 意图:请求按照一定的处理链进行处理。
  • 动机:对不同的请求安排不同的处理链。所有的请求都有固定的处理链。
  • 出现概率:50%
  • Pros:单一职责原则 / 开闭原则。
  • 关联:命令模式 / 中介者模式 / 观察者模式
  • Examples:Dubbo Filters / Netty handlers
  • ✅ 适用场景:过滤器/拦截器链(Servlet Filter、Dubbo Filter)、审批流、风控规则引擎。
  • ⛔ 反模式 / 误用:链条过长且没有短路机制,所有处理器都要执行;处理器之间隐式依赖顺序却没有文档说明,维护时容易踩雷。

2. 命令(Command)

  • 意图:将不同的请求转换到统一的命令对象,交给执行器进行执行。
  • 动机:将请求与具体的执行者进行解耦,实现参数化操作。
  • 出现概率:20%
  • ✅ 适用场景:支持撤销/重做(编辑器)、任务队列、宏录制。
  • ⛔ 反模式 / 误用:业务逻辑简单时引入命令对象——一个 execute() 方法包一层,纯粹增加间接层;命令对象持有过多上下文,退化成贫血模型。

3. 迭代器(Iterator)

  • 意图:对集合类的结构能够采用一种统一的方式进行遍历访问。
  • 动机:隐藏具体的迭代过程。
  • 出现概率:99%
  • ✅ 适用场景:统一遍历各种集合/树/图结构,隐藏底层实现。
  • ⛔ 反模式 / 误用:在已有 Stream API 的场景下手写迭代器;在迭代过程中修改集合(ConcurrentModificationException)。

4. 中介者(Mediator)

  • 意图:由中介者来协调不同对象之间的合作。
  • 动机:对象之间的依赖过于复杂。
  • 出现概率:60%
  • Cons:中介者变为上帝对象。
  • ExamplesExecutor#execute() / Timer#scheduleXXX() / Method#invoke() / 连接池 / YARN 资源管理器
  • ✅ 适用场景:聊天室、航空调度、GUI 组件协调(表单联动)、连接池。
  • ⛔ 反模式 / 误用:中介者承担过多职责变成上帝对象;系统中只有少量对象时引入,增加间接层而没有实质收益。

5. 备忘录(Memento)

  • 意图:使得数据的状态得以快速保存和恢复。
  • 出现概率:5%
  • ✅ 适用场景:游戏存档、编辑器撤销、事务回滚的状态保存。
  • ⛔ 反模式 / 误用:状态对象过大时频繁快照,内存暴涨;没有控制快照数量上限,历史记录无限增长。

6. 观察者(Observer / Listener)

  • 意图:实现一种订阅机制,使得被观察对象发生事件时通知到订阅者。
  • 动机:有不同的对象要观察监听同一个对象的事件 / 观察者可能动态加入、离开观察者队列。
  • 出现概率:80%
  • Pros:开闭原则。
  • Cons:无法控制通知的顺序。
  • Examples:DataStream / RxJava
  • ✅ 适用场景:事件驱动、消息总线、MVC 中 Model 通知 View。
  • ⛔ 反模式 / 误用:观察者链式触发(A 通知 B,B 又通知 C),调试极其困难;观察者持有被观察者的强引用,忘记取消订阅导致内存泄漏;同步通知中观察者执行耗时操作,阻塞主流程。

7. 状态(State)

  • 意图:使得对象在改变自身状态的时候,改变自身的行为。
  • 动机:将状态变更与对象的自身逻辑分离。
  • 出现概率:70%
  • Pros:单一职责原则 / 开闭原则 / 解耦。
  • Cons:在状态较多或变更条件复杂的时候才适合采用。
  • Examples:业务对象的 draft → submitted → effective → to be offline → offline 状态变化,简化整体业务代码。
  • ✅ 适用场景:订单状态机、工作流引擎、TCP 连接状态、游戏角色状态。
  • ⛔ 反模式 / 误用:状态数量只有 2~3 个时引入,if-else 更直观;状态之间的转移逻辑散落在各个 State 类里,全局迁移图难以一眼看清(建议配状态机图)。

8. 策略(Strategy)

  • 意图:能够根据上下文情况,方便切换具体的实现算法。
  • 动机:有较多的实现方式,并且希望能够在运行时动态切换。
  • 出现概率:50%
  • Pros:实现与流程隔离。
  • Cons:Client 需要自己清楚什么时候采用什么策略。
  • ✅ 适用场景:排序算法切换、支付方式选择、促销规则计算、路由策略。
  • ⛔ 反模式 / 误用:策略类激增但大多数场景只用 1 种策略——可用函数式接口(Function / Predicate)替代;Context 里残留大量 if-else 选策略的逻辑,没有真正解耦。

9. 模板方法(Template Method)

  • 意图:在超类中定义了一个算法的框架,允许子类在不修改结构的情况下重写算法的特定步骤。
  • 动机:多个类的执行过程,只有在局部有一些变化,可以采用模板方法进行抽象。
  • 结构:继承
  • 出现概率:80%
  • Pros:减少了大量 duplicate code。
  • Cons:必须在编译时就要能够明确具体是谁执行。
  • 关联:策略模式是在运行时进行切换,而模板方法很明显是在编译时就决定了。
  • ✅ 适用场景:数据处理框架(读取 → 处理 → 写出)、JUnit 的 setUp/tearDownAbstractList
  • ⛔ 反模式 / 误用:子类重写了不应该重写的步骤(“脆弱的基类"问题);钩子方法过多,子类需要理解整个父类流程才能正确实现;在需要运行时切换算法时误用模板方法(该用策略)。

10. 访问者(Visitor)

  • 意图:将算法与访问的对象隔离。
  • 动机:同一组对象会有不同的方式被访问(aka. 基于其进行计算)。
  • 出现概率:10%
  • Cons:访问者是强关联到这个数据结构上的。
  • Examples:Druid SQL 树的计算
  • ✅ 适用场景:编译器 AST 遍历、SQL 语法树分析(如 Druid)、文档格式导出。
  • ⛔ 反模式 / 误用:数据结构频繁变动时使用——每加一种元素类型,所有 Visitor 都要改;访问者强依赖数据结构的 accept 方法,破坏了封装性。

总结

设计模式本身并不重要,而在于它采用了哪些策略来降低系统复杂度、提高系统的可维护性。

  1. 减少继承,多采用组合的方式来丰富类的功能。
  2. 面对各种功能的变更,能够将变化(影响)的范围尽可能的缩小到 1 个或者少量的类中。
  3. 面向接口编程。

何时应该克制:设计模式的引入本身也是一种成本——间接层增加、代码量上升、新人理解曲线变陡。在以下情形应克制使用:

  1. 团队规模小、迭代快,过度抽象反而拖慢速度;
  2. 变化点还不明确,过早抽象容易抽错方向(YAGNI 原则);
  3. 语言/框架已经原生支持,例如 Java 8+ 的函数式接口基本覆盖策略模式的使用场景。

参考

  1. https://refactoringguru.cn/design-patterns/prototype
  2. https://design-patterns.readthedocs.io/zh_CN/latest/index.html
  3. https://www.cnblogs.com/zuoxiaolong/p/pattern26.html
  4. https://www.zhihu.com/question/23757906
  5. Are Design Patterns Missing Language Features
  6. 往往是 Java 的语言约束导致了这些设计模式的诞生

最后修改于 2020-03-30