Java IO 为什么是装饰器模式的经典例子?
简化版
Java IO 是装饰器模式的经典例子,因为各种输入输出流都围绕统一抽象进行包装,每一层装饰器增加一种能力,比如缓冲、数据类型读取、对象序列化等。通过组合不同流对象,可以在运行时灵活叠加功能。
详细版
Java IO 中常见写法:
InputStream in = new BufferedInputStream(
new FileInputStream("a.txt")
);
这里:
InputStream是抽象组件;FileInputStream是具体组件,负责从文件读取字节;FilterInputStream是抽象装饰器;BufferedInputStream是具体装饰器,增加缓冲能力。
再比如:
DataInputStream in = new DataInputStream(
new BufferedInputStream(
new FileInputStream("a.dat")
)
);
调用链中:
FileInputStream提供文件读取;BufferedInputStream提供缓冲;DataInputStream提供读取基本数据类型的能力。
如果不用装饰器,而是用继承表示所有组合,就可能需要 BufferedFileInputStream、DataBufferedFileInputStream、ObjectBufferedFileInputStream 等大量类。
Java IO 用装饰器避免了类爆炸,让能力可以按需组合。
完整版教学
一、Java IO 的问题背景
输入流有很多维度的变化:
- 数据来源:文件、内存、网络;
- 读取方式:按字节、按基本类型、按对象;
- 性能增强:缓冲;
- 额外处理:解压、解密、统计。
如果用继承组合这些能力,类数量会迅速失控。
比如文件输入 + 缓冲 + 数据类型读取,可能要一个类;网络输入 + 缓冲 + 数据类型读取,又要一个类。能力一多,组合数量呈倍数增长。
装饰器模式把每种能力拆成独立包装层。
二、InputStream 体系中的装饰结构
InputStream 是所有字节输入流的抽象基类。
FileInputStream 是具体组件,负责真正读取文件。
FilterInputStream 是一个典型的装饰器基类,它内部持有另一个 InputStream:
protected volatile InputStream in;
它的方法通常会委托给内部流:
public int read() throws IOException {
return in.read();
}
BufferedInputStream、DataInputStream 等类在这个基础上增加自己的能力。
这就是标准装饰器结构:装饰器和被装饰对象同属一个抽象类型,装饰器内部持有被装饰对象。
三、BufferedInputStream 增强了什么
FileInputStream 每次读取可能涉及系统调用,频繁小读取成本较高。
BufferedInputStream 增加一个内存缓冲区。程序读取时,先从缓冲区拿数据;缓冲区不够时,再批量从底层流读取。
调用方仍然使用 InputStream:
InputStream in = new BufferedInputStream(new FileInputStream("a.txt"));
这体现了装饰器的透明性:加了缓冲能力,但接口没有变。
四、DataInputStream 增强了什么
InputStream 基础能力是读字节。
DataInputStream 在字节读取基础上提供更高级的方法:
int value = dataInputStream.readInt();
double d = dataInputStream.readDouble();
它把多个字节组合解释成 Java 基本类型。
这类增强和 BufferedInputStream 不同:缓冲增强性能,DataInputStream 增强读取语义。但它们都可以作为装饰层叠加。
五、装饰顺序为什么重要
在 IO 中,装饰顺序会影响语义和性能。
new DataInputStream(new BufferedInputStream(new FileInputStream(file)))
通常比直接:
new BufferedInputStream(new DataInputStream(new FileInputStream(file)))
更符合直觉:先让底层字节读取具备缓冲能力,再在缓冲字节流上读取数据类型。
更复杂的场景如压缩、加密、编码转换,顺序更关键。先压缩再加密,和先加密再压缩,效果可能完全不同。
六、Java IO 例子给我们的启发
Java IO 展示了装饰器模式的工程价值:
- 基础流负责数据来源;
- 装饰流负责附加能力;
- 每层只做一件事;
- 组合代替继承;
- 使用统一抽象类型连接所有层。
这就是为什么面试中一问装饰器模式,经常会追问 Java IO。
七、IO 流如何逐层增加能力
逐字节读取 8192 字节可能触发 8192 次底层读取;使用 8 KB 缓冲区时,理想情况下可降为约 1 次块读取。
FileInputStream -> BufferedInputStream -> DataInputStream -> client
这个推演把模式带来的收益和成本放在同一条调用链上。设计评审时既要确认结果正确,也要确认额外层次没有改变原有契约。
八、边界、代价与验证
| 检查维度 | 应确认的内容 |
|---|---|
| 正确性 | 字节流是基础能力,缓冲和按类型读取是可叠加职责,这正是 Java IO 结构的价值。 |
| 适用边界 | 关闭最外层流通常会沿链关闭底层资源,但重复包装、提前关闭和缓冲未刷出仍需谨慎处理。 |
| 测试证据 | 覆盖正常路径、异常路径、空值或未知类型,并断言调用次数与顺序 |
| 运行成本 | 记录额外对象、调用层数、延迟、线程或内存开销,不用模式名称代替测量 |
模式落地后要用单元测试验证独立职责,用集成测试验证对象装配和真实调用入口。涉及并发、远程或异步时,还要补充竞态、超时、重复执行与资源释放测试。
易错点:字节流是基础能力,缓冲和按类型读取是可叠加职责,这正是 Java IO 结构的价值。
九、常见误区与追问
- 误区:BufferedInputStream 改变了底层文件内容。 它改变的是访问方式和调用次数,不改变数据语义。
- 误区:装饰器可以任意改变组件接口。 透明装饰依赖共同的 Component 契约;随意增加只能向下转型调用的方法,会破坏可替换性和继续组合的能力。
- 误区:示例代码能运行,就代表模式边界设计正确。 能运行只证明一条路径,仍要检查异常、并发、顺序、生命周期以及新增实现时是否修改稳定代码。
- 追问:为什么通常只关闭最外层流? 装饰流的 close 会委托下层;try-with-resources 还能保证异常时按规则释放。
- 追问:装饰器链越长,扩展性就一定越好吗? 不一定;链越长,顺序、异常传播和排障成本越高,应由装配层集中管理并限制职责粒度。
- 追问:怎样验证这套设计没有改变原有语义? 先做基线行为测试,再对新增层验证返回值、异常类型、调用次数和顺序;性能敏感处还要比较改造前后的量化指标。
十、加强记忆
Java IO 的装饰器思想是:文件流、网络流负责“从哪读”,缓冲流、数据流、对象流负责“怎么增强读取”。每个能力单独一层,需要什么就包什么,避免为所有组合写子类。