Java I/O 流套了7层装饰器——这不是设计模式,这是依赖地狱

Java I/O 流套了7层装饰器——这不是设计模式,这是依赖地狱 如果你写过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 而不是 Decoratorjava 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 的体验一模一样搜搜看就懂了。