如果你写过new BufferedReader(new InputStreamReader(new FileInputStream("file.txt"))),你就用了三个装饰器。Java I/O 是装饰器模式的教科书示例——每本教材引用它,每个教程赞美其灵活性。但没人说需要7层装饰器读一个文件时会怎样。
这是装饰器模式的真实问题:它的代价与你想组合的功能数量呈平方级增长。每个装饰器包裹一个接口、加一个功能、把其余全部传递。要7个功能,你需要7个装饰器、7个构造调用、7层深的链。调试它需要解包7层。测试它需要 mock 7个接口。理解它需要读7个类。
装饰器模式强大。但它的强大伴随着大多数教程忽略的代价。
Java I/O:装饰器链的千层套路
读一个有缓冲、字符转换和行读取的文本文件:
java BufferedReader reader = new BufferedReader( new InputStreamReader( new FileInputStream("data.txt"), StandardCharsets.UTF_8 ) );
三层。每个加一个能力: - FileInputStream:从文件读字节 - InputStreamReader:字节转字符 - BufferedReader:缓冲字符并提供 readLine()
理论上优雅。每个装饰器简单,每个加一个功能,你可以自由组合——加 GZIP 解压、加进度追踪、加行计数,全是装饰器。
但"自由组合"意味着"无限叠加"。当你需要缓冲、gzip、UTF-8、行号、从带认证的 URL 读:
java LineNumberReader lineReader = new LineNumberReader( new BufferedReader( new InputStreamReader( new GZIPInputStream( new URLInputStream( new URL("https://..."), authHeaders ) ), StandardCharsets.UTF_8 ) ) );
六层。这还算简单的情况。真实生产代码里,我见过 10+ 层深的 InputStream 链——每层由不同开发者在不同时间加,每层包裹上一层因为"我们需要在加密上面加压缩上面加校验上面加缓冲上面加日志"。
链变得不可读。不看最终流做什么,你必须脑内逐层拆包。看哪层导致 bug,你不能从堆栈判断。换一层,你不能不拆整条链。
装饰器模式的真实代价
装饰器模式有三个随链深度增长的代价:
代价1:构造复杂度
每个装饰器需要内部装饰器作为构造参数。构造链镜像包裹链。建7层装饰器链,需要7个嵌套构造调用,顺序很重要——BufferedReader 在 LineNumberReader 里面能工作,LineNumberReader 在 BufferedReader 里面意味着行号得不到缓冲读。
没有编译期强制顺序。你可以把 BufferedReader 包在一个已经缓冲的 InputStream 上(双重缓冲——无用但无害),或者把 InputStreamReader 包在一个 Reader 上(类型不匹配——但编译器不报因为某些配置下两者都实现了 Reader 的父接口)。
代价2:调试地狱
读操作失败时,异常通过每层装饰器传播。堆栈显示7层InputStream.read()逐层委托。从堆栈看不出哪个装饰器导致问题。
java java.io.IOException: Stream closed at java.io.BufferedInputStream.read(BufferedInputStream.java:265) at sun.net.www.protocol.http.HttpURLConnection$InputStream.read(...) at java.util.zip.GZIPInputStream.read(GZIPInputStream.java:174) at java.io.InputStreamReader.read(InputStreamReader.java:184) at java.io.BufferedReader.read(BufferedReader.java:202) at com.example.DataParser.parse(DataParser.java:45)
哪层关闭了流?看不出来。你必须给每层加日志,或者在调试器里逐层走,或者用更简单的链重现。这些花费的时间随链深度增长。
代价3:接口膨胀
每个装饰器实现与所包裹组件相同的接口。但装饰器也加自己的方法——BufferedReader 有readLine(),LineNumberReader 有getLineNumber(),GZIPInputStream 有closeEntry()。这些方法只有持有特定装饰器引用才能用,不是泛型 InputStream 接口。
java InputStream in = new GZIPInputStream(new FileInputStream("data.gz")); // 不能调 in.closeEntry()——InputStream 没有这个方法 // 不能调 ((GZIPInputStream) in).closeEntry()——但如果有人插了另一层装饰器呢?
这迫使你要么: - 每个装饰器单独保留引用(违背装饰器模式承诺的透明性) - 对链做向下转型(脆弱,如果有人插了装饰器就断) - 用门面暴露所有功能(其实你一开始就该建的)
装饰器什么时候是正确选择
装饰器模式在特定场景确实有用:
给稳定接口加横切关注点。核心接口定义清晰且很少变化时,装饰器适合加可选功能。Java 的 InputStream 是稳定接口。加缓冲、压缩或加密作为装饰器有意义,因为它们真正可选——大多数 InputStream 不需要全部三个。
功能真正可组合。如果任意功能组合都有效且有意义,装饰器可行。加密+压缩有效。压缩+缓冲有效。加密+压缩+缓冲有效。组合有意义,不是随意拼凑。
每个装饰器足够简单。每个装饰器应加一个清晰功能,逻辑最少。如果一个装饰器做多个事情,它不是装饰器——它是伪装成透明层的服务类。
关键测试:你能从链中移除任何装饰器,对象仍然工作、有意义吗?能,装饰器模式合适。不能——移除一个就坏功能因为它们耦合——你该用不同方式。
什么时候不该用装饰器链
替代方案1:组合配置对象
不包裹装饰器,定义一次性指定所有功能的配置:
```java public class StreamConfig { private boolean buffered = true; private boolean gzipped = false; private Charset charset = StandardCharsets.UTF_8; private boolean trackLineNumbers = false;
public InputStream create(InputStream source) { InputStream in = source; if (gzipped) in = new GZIPInputStream(in); if (buffered) in = new BufferedInputStream(in); return in; } public Reader createReader(InputStream source) { return new InputStreamReader(create(source), charset); }} ```
这不消除装饰器链——内部仍用装饰器。但把构造复杂度藏在一个配置对象后面。调用者不需要知道顺序或数量。需要改链,改一处。
替代方案2:显式阶段的 Pipeline
装饰器有复杂相互依赖时,Pipeline 比链更清晰:
```java public class ProcessingPipeline { private final List stages = new ArrayList<>();
public ProcessingPipeline addStage(ProcessingStage stage) { stages.add(stage); return this; } public Result process(Input input) { Context ctx = new Context(input); for (ProcessingStage stage : stages) { stage.execute(ctx); if (ctx.isTerminated()) break; } return ctx.getResult(); }} ```
每个阶段显式可见。你在 Pipeline 定义里看到完整处理流。你可以改列表重排阶段。你可以加或移除阶段不用嵌套构造调用。
替代方案3:基于 Strategy 的功能选择
功能是互斥选项(不是可叠加增加)时,用 Strategy 而不是 Decorator:
```java public interface CompressionStrategy { InputStream compress(InputStream in); OutputStream decompress(OutputStream out); }
public class GzipCompression implements CompressionStrategy { ... } public class NoCompression implements CompressionStrategy { ... }
// 一个选择,不是一条链 CompressionStrategy compression = config.isGzipEnabled() ? new GzipCompression() : new NoCompression(); ```
Strategy 处理"选一个"的情况。Decorator 处理"加一个"的情况。如果你用 Decorator 做功能选择(同一时间只有一个加密方式生效),你用错了模式。
MyBatis 的装饰器链:层数失控的真实案例
MyBatis 缓存体系是装饰器模式的典型应用,也是"层数失控"的典型案例:
java // MyBatis Cache 装饰器链 // LoggingCache -> SynchronizedCache -> SerializedCache -> LruCache -> PerpetualCache
五个装饰器,每个加一个功能:日志、同步、序列化、LRU淘汰、持久存储。看起来合理。但:
- 不能跳过任何层——即使你的场景不需要序列化
- 装饰器顺序硬编码,不是可配置的
- LruCache 和 SoftCache 是互斥的,但它们都是装饰器,不是策略。只能选一个,但装饰器模式没有"选一个"的语义
MyBatis 的解法:用 Builder 封装装饰器链构建。CacheBuilder抓住创建顺序,让你通过配置选淘汰策略,不是手动构造装饰器链。
```java public class CacheBuilder { private Cache cache;
public CacheBuilder blocking() { cache = new BlockingCache(cache); return this; } public CacheBuilder logging() { cache = new LoggingCache(cache); return this; } public Cache build() { return cache; }} ```
Builder 是装饰器模式的补救——不改变装饰器链本身,但把构造复杂性封装在可控的地方。如果必须用多层装饰器,至少用 Builder 管理构造。
装饰器的边界:什么时候该换模式
装饰器模式有清晰的适用边界:
- 装饰器数量 ≤ 3:没问题,链式构造可读
- 装饰器数量 4-5:需要 Builder 或 Factory 封装构造
- 装饰器数量 ≥ 6:该换模式了,用 Pipeline 或 Composite
这不是理论上的,是实践中的。每多一层装饰器,你多一层构造复杂度、调试复杂度、接口歧义。三层以内成本可控。六层以上成本和收益倒挂。
Java I/O 的教训不是"装饰器模式不好",而是"装饰器模式没有成本控制机制"。模式本身没说"超过五层就停下"。它只说"透明地添加功能"。但透明不等于免费——每多一层代价都在累积,直到整条链不可维护。
下次写new XInputStream(new YInputStream(new ZInputStream(...)))的时候,数一下层数。超过五层,停下来想:这些功能真的需要透明叠加,还是应该用更结构化的方式组织?
我最近在做的「爪爪代码冒险记」小程序里,装饰器那期画的就是卡皮巴拉穿7层马甲最后找不到自己的场景——跟 Java I/O 的体验一模一样,搜搜看就懂了。