少女祈祷中...
残云的学习笔记

装饰者模式:不改一行源码把职责叠上去,以及它和代理、适配器到底差在哪

接口不变,只往上叠职责,它是把「若干可选工序」做成正交组合的标准答案:4 个可选功能用继承是 2⁴ = 16 个子类,用装饰者只要 4 个类,而且运行时任意拼装。本文讲清四个角色与两个关键结构关系(既实现 Component、又持有 Component)、最容易被低估的顺序语义、三条边界与几个常见坑,梳理 java.io 流体系、Collections.unmodifiableList、MyBatis 的 CachingExecutor 等真实装饰者,最后把装饰者、代理、适配器三者的界限一次讲透。

一、问题的起点:需求是「叠加」的,继承会爆炸

先看一个几乎每个项目都会长出来的需求:报表导出。

基础功能:把数据导成 CSV
可选功能:压缩成 zip、加密、加水印、记审计日志

产品的要求是"每个客户勾选不同组合":A 客户要压缩,B 客户要压缩+加密,C 客户要加密+水印+日志……用继承来穷举会发生什么?

class CsvExporter { ... }
class CsvCompressedExporter extends CsvExporter { ... }
class CsvEncryptedExporter extends CsvExporter { ... }
class CsvCompressedEncryptedExporter extends CsvCompressedExporter { ... }
class CsvCompressedEncryptedWatermarkExporter extends ... { ... }
// 4 个可选功能 → 2⁴ = 16 个类,还不算顺序不同带来的变体

方案一和方案二的差距,一眼可见:

decorator-1-class-explosion

这就是类爆炸:类数量随功能数指数增长,而且每加一个功能(比如"加签名")就要翻倍。更糟的是这些类之间是编译期绑定的——客户半夜说"把加密换成签名",你得改代码重新发版。

换一个思路:把"导出"和"加工"拆开。导出的实现只有一份,压缩 / 加密 / 水印 / 日志各自写成一个独立的"包装层",需要哪些就一层层套上去,顺序还能调:

ReportExporter exporter = new LogDecorator(
                              new EncryptDecorator(
                                  new CompressDecorator(
                                      new CsvExporter())));

这就是装饰者模式(Decorator,也译作装饰器模式、装饰模式)。它要解决的从来不是"功能不够",而是**"功能要能自由叠加、运行时组装、且不改变原有对象"**。

二、结构:四个角色,两个关键关系

装饰者的结构是结构型模式里最"讲道理"的一个,四个角色各司其职:

decorator-2-structure

  • Component(抽象构件):ReportExporter,客户端唯一认识的东西。装饰后对象必须还是它,这是整个模式的地基。
  • ConcreteComponent(具体构件):CsvExporter,真正干活的实现。
  • Decorator(抽象装饰者):ExporterDecorator,既实现 Component 又持有一个 Component——这两个关系缺一不可:
    • 实现 Component → 装饰后的对象类型不变,客户端无需感知;
    • 持有 Component → 可以包住任意 Component,于是能一层层叠。
  • ConcreteDecorator(具体装饰者):CompressDecorator、EncryptDecorator,在转发前后加上自己的加工。

客户端的一次调用实际是一层层"洋葱式"下钻、再一层层回传加工:

decorator-3-sequence

要点:每一层都只认识自己的 inner,不知道外面套了几层、也不知道自己是最内层还是最外层。这就是它能自由组合的原因。

三、手写一条完整的装饰者链

下面是可直接运行的单文件版本(Java 8+):

import java.nio.charset.StandardCharsets;
import java.util.Base64;

/** Component:客户端唯一认识的抽象 */
interface ReportExporter {
    String export();
}

/** ConcreteComponent:真正干活的实现,只有一份 */
class CsvExporter implements ReportExporter {
    @Override
    public String export() {
        return "id,name,amount\n1,张三,199.00\n2,李四,88.50";
    }
}

/** Decorator:实现 Component + 持有 Component,两个关系缺一不可 */
abstract class ExporterDecorator implements ReportExporter {
    protected final ReportExporter inner;          // 被装饰者,类型仍是抽象

    protected ExporterDecorator(ReportExporter inner) {
        this.inner = inner;
    }

    @Override
    public String export() {                       // 默认纯转发,子类按需加工
        return inner.export();
    }
}

/** ConcreteDecorator:压缩 */
class CompressDecorator extends ExporterDecorator {
    CompressDecorator(ReportExporter inner) { super(inner); }

    @Override
    public String export() {
        String raw = inner.export();               // 先让内层干活
        return "[zip:" + raw.length() + "B]" + raw; // 这里用标记模拟压缩
    }
}

/** ConcreteDecorator:加密 */
class EncryptDecorator extends ExporterDecorator {
    EncryptDecorator(ReportExporter inner) { super(inner); }

    @Override
    public String export() {
        String raw = inner.export();
        return "[enc:" + Base64.getEncoder()
                .encodeToString(raw.getBytes(StandardCharsets.UTF_8)) + "]";
    }
}

/** ConcreteDecorator:审计日志(前置 + 后置都能加) */
class LogDecorator extends ExporterDecorator {
    LogDecorator(ReportExporter inner) { super(inner); }

    @Override
    public String export() {
        long t0 = System.nanoTime();               // 前置
        String out = inner.export();
        System.out.printf("[audit] 导出完成,耗时 %dus,长度 %d%n",
                (System.nanoTime() - t0) / 1000, out.length());  // 后置
        return out;
    }
}

public class DecoratorDemo {
    public static void main(String[] args) {
        // 运行时自由组装,顺序可调,客户端只认识 ReportExporter
        ReportExporter a = new LogDecorator(new CompressDecorator(new CsvExporter()));
        ReportExporter b = new EncryptDecorator(new CompressDecorator(new CsvExporter()));
        ReportExporter c = new CompressDecorator(new EncryptDecorator(new CsvExporter()));

        System.out.println(a.export());
        System.out.println(b.export());
        System.out.println(c.export());   // 注意:与 b 的结果不同
    }
}

三个细节值得停一下:

  1. 抽象装饰者的 export() 是纯转发,具体装饰者才选择"前置加工、后置加工、或者干脆替换返回值"。
  2. 装饰者的构造器接收的是抽象类型,所以 inner 既可以是 CsvExporter,也可以是另一个装饰者——这正是"能叠"的技术原因。
  3. 上一行代码里的 b 和 c 看起来只是换了位置,结果并不一样。这就是下一节要说的顺序语义。

四、顺序是装饰者最重要的语义

装饰者的每一层都是"对上层输入的加工、对下层输出的加工",因此嵌套顺序直接决定结果。加密和压缩谁先谁后,产出的是完全不同的东西:

decorator-4-order

实践里由此派生出三条经验:

  • 顺序要在代码里显式化。同一组装饰者用不同顺序拼装,是生产事故的常见来源——把拼装逻辑收在一个工厂方法里(ExporterFactory.of(config)),不要散落在调用点。
  • 有些层对顺序敏感,有些层不敏感。日志、计时这类"旁路"装饰者通常与顺序无关;加密、压缩、签名、限流这类"改变数据形态"的装饰者必须定序。
  • 可逆性要想清楚。如果装饰链是"加密→压缩",接收方就得按同一顺序逆着来(先解压再解密),这套约定要写进协议文档,而不是留在某个人的脑子里。

五、装饰者的三条边界

装饰者很容易被用歪,因为它"看起来就是包一层"。下面三条边界建议当成硬约束:

1. 不改变被装饰者的接口与语义。 装饰者必须仍然"是"Component(Liskov 替换原则)。如果包装后方法名变了、参数变了、或者返回类型被改了,那不是装饰者,是适配器(见本系列《适配器模式》)。

2. 不依赖具体类型,也不该用 instanceof 判断。 装饰层的类型是嵌套的,exporter instanceof CsvExporter 在最外层永远是 false。如果需要"识别"某层,说明设计里混进了别的需求(通常该用代理或显式的元数据),不要靠类型判断硬掰。

3. 每层只做一件事,且层数要克制。 三层以内通常清晰;超过五层,异常栈、日志和调试成本会急剧上升。如果发现需要十层装饰者,多半应该换成责任链(Chain of Responsibility)或策略组合。

另外三个容易被忽略的点:

  • equals/hashCode 语义会变。装饰后的对象与原对象不再相等(甚至两个"内容相同"的装饰链也不相等)。把它当集合键、或做缓存命中判断时,这个问题会直接变成 bug。JDK 的 Collections.unmodifiableList 也是同样的道理——它是视图,不是原对象。
  • 装饰链上的状态要分清归属。装饰者里可以放自己的状态(比如计时用的 t0),但不要修改 inner 的状态,否则"每层独立"的语义就被破坏了。
  • 线程安全由各层自己负责。装饰者持有的状态若在多次调用之间共享,就必须自己同步;更稳的做法是让每层保持无状态,把链做成不可变对象,每次请求按配置重新拼一条链。

六、JDK 与框架里的装饰者

装饰者是 JDK 里出现最多的结构型模式——没有之一。java.io 整个包就是一套装饰者教科书:

位置Component具体装饰者说明
java.io 字节流InputStream / OutputStreamBufferedInputStream、DataInputStream、GZIPInputStream、DigestInputStream缓冲、类型化读写、压缩、摘要,全部靠"套"
java.io 字符流Reader / WriterBufferedReader、LineNumberReader、PrintWriter同上,字符侧
java.util.CollectionsList / Set / MapunmodifiableList、synchronizedList、checkedList只读、加锁、类型检查,都是装饰者(返回的是视图)
Servlet 规范HttpServletRequestHttpServletRequestWrapper过滤器里改请求参数/头部的标准做法
MyBatisExecutorCachingExecutor 等装饰链查询缓存、懒加载等能力靠装饰者叠加
Spring WebFluxServerHttpRequestServerHttpRequestDecorator读取并重新包装请求体,供下游多次读取

最典型的一行代码,一眼就是装饰者:

// 三层装饰:文件字节流 → 字符转换 → 缓冲
try (BufferedReader reader = new BufferedReader(
        new InputStreamReader(new FileInputStream("data.csv"), StandardCharsets.UTF_8))) {
    String line;
    while ((line = reader.readLine()) != null) {   // readLine() 是 BufferedReader 加的
        System.out.println(line);
    }
}

// 集合侧同理:返回的是只读视图,不是新集合
List<String> view = Collections.unmodifiableList(rawList);
// view.add("x");   // 运行期 UnsupportedOperationException

这条链的每一层「加了什么」,可以拆开看:

decorator-5-java-io

顺带说一句:Collections.unmodifiableList 返回的对象不是 ArrayList 的副本,而是一个包装了原集合的装饰者——原集合变了,视图跟着变。这既是它的优点(零拷贝),也是它最常被误用的地方。

七、装饰者 vs 代理 vs 适配器

这三个模式是"包一层"三兄弟,也是面试与软考里最爱对比的一组。本系列前两篇分别站在代理、适配器视角讲过,这里把三者放到一张表里:

decorator-6-three-patterns

维度装饰者代理适配器
接口是否变化不变(必须还是 Component)不变改变(翻译成另一方认得的形式)
能否叠加多层能,且不限制顺序通常单层通常单层(双向适配器是两个方向)
客户端是否感知不需要感知不需要感知需要依赖目标接口
核心意图动态增加职责控制对真实对象的访问让不兼容的接口能协作
典型场景Java IO、只读集合视图Spring AOP 事务、RPC stubInputStreamReader、JDBC Driver

一句话记法:装饰者加能力、代理控访问、适配器改接口。 前两者接口都不变,区别在"意图"和"能不能叠"——装饰者是被设计成可以层层叠加的,而代理一般只包一层,也不会去改变原对象的职责。

八、什么时候不要用装饰者

  1. 组合是固定的一两种,且不会变。 直接写死一个类更简单,别为了"看起来灵活"引入装饰者。
  2. 需要的是"加强整体能力",而不是"叠加工序"。 比如"更好的缓存策略",那是一个策略/实现替换问题,不是装饰链。
  3. 装饰链超过五层。 调试与排错成本会超过收益,改用责任链或显式的管道模型(Pipeline)更合适。
  4. 接口不稳定。 Component 每加一个方法,所有装饰者都要跟着改。接口没稳定之前,别急着上装饰者。
  5. 用它掩盖下游问题。 比如"下游返回的字段不对,我在装饰者里顺手改一下"——这是把 bug 藏进链路里,迟早找不到。

再补一条边界:装饰者 ≠ 责任链,也 ≠ 策略。 装饰者每一层都会经手并加工同一个请求,最终产出仍是"一个"结果;责任链是"谁接住谁处理",可能中途终止;策略是"多选一"。需求一旦变成"选一个实现"或"谁先接住谁处理",就不该继续往上套装饰者了。

一个正面的判断标准:如果需求是"若干可选工序的任意组合",装饰者几乎总是对的;如果需求是"若干互斥的实现选一个",那是策略或工厂,不是装饰者。

九、参考资料

  • GoF《设计模式:可复用面向对象软件的基础》第 4 章 Decorator(对象结构型)
  • 《Head First 设计模式》第 3 章 Decorator(星巴克咖啡例子,顺序语义讲得最清楚)
  • JDK 源码:java.io.BufferedInputStream、java.io.GZIPInputStream、java.util.Collections#unmodifiableList
  • Servlet 规范:javax.servlet.http.HttpServletRequestWrapper
  • MyBatis:org.apache.ibatis.executor.CachingExecutor
  • 本系列相关篇目:《设计模式(Java)· 代理模式》《设计模式(Java)· 适配器模式》——其中都有"包一层"三兄弟的对比图
分享到

评论