9.2 可组合结构:Decorator、Composite 与 Flyweight
一组界面组件既要像树一样组合,又要按需叠加行为,还要避免复制大量相同状态。
Decorator、Composite 与 Flyweight 都依赖共享抽象,却解决三类不同问题:行为怎样层叠、叶子与容器怎样统一处理,以及大量对象怎样共享重复状态。
Decorator:围绕同一契约叠加职责
一个通知通道可能需要计时、追踪与重试。与其为每种组合创建子类,可以逐层包装:
public final class TimedDeliveryChannel implements DeliveryChannel {
private final DeliveryChannel target;
private final io.micrometer.core.instrument.Timer timer;
public TimedDeliveryChannel(
DeliveryChannel target,
io.micrometer.core.instrument.Timer timer) {
this.target = target;
this.timer = timer;
}
@Override
public DeliveryResult deliver(Message message, Recipient recipient) {
return timer.record(() -> target.deliver(message, recipient));
}
}装配时决定层次:
DeliveryChannel channel = new TimedDeliveryChannel(
new RetryingDeliveryChannel(
new HttpDeliveryChannel(client), retryPolicy),
timer);顺序属于语义:计时器放在重试器外层会测量全部尝试,放在内层会分别记录每次尝试。异常映射、缓存与事务装饰器也会受顺序影响,应集中装配并测试整条链。
Decorator 应维持被包装接口的基本契约。若包装后要求额外初始化步骤、拒绝原本合法输入或改变身份/相等语义,调用者可能无法透明使用。
Decorator 与 Proxy 为什么容易混淆
两者结构几乎相同,分类取决于意图:
- Proxy 代表另一个对象并控制访问;
- Decorator 在相同抽象上组合额外职责。
缓存、日志、重试既可能被称作代理,也可能被称作装饰器。与其争名称,不如写清:谁拥有目标对象、调用是否透明、顺序是否重要、失败和生命周期是否改变。
Java I/O 中 BufferedInputStream 包装另一个 InputStream,是 Decorator 的经典结构;关闭外层通常也会关闭内层,说明资源所有权也是契约的一部分。
Composite:统一处理叶子与组合节点
赛事奖励可以形成树:单项奖励是叶子,奖励包包含其他奖励。
public sealed interface RewardComponent
permits CoinReward, RewardBundle {
RewardValue total();
}
public record CoinReward(RewardValue value)
implements RewardComponent {
@Override
public RewardValue total() { return value; }
}
public final class RewardBundle implements RewardComponent {
private final java.util.List<RewardComponent> children;
public RewardBundle(java.util.List<RewardComponent> children) {
this.children = java.util.List.copyOf(children);
}
@Override
public RewardValue total() {
return children.stream()
.map(RewardComponent::total)
.reduce(RewardValue.ZERO, RewardValue::add);
}
}调用者只依赖 total(),无需区分单个奖励还是嵌套奖励包。
安全接口与透明接口
把 add(child) 放进共同接口看起来更统一,却迫使叶子实现不支持的操作并抛异常。更安全的设计只让组合节点暴露子节点修改;共同接口保留叶子和组合都真正支持的操作。
Composite 还必须决定:
- 是否允许空组合;
- 是否允许同一节点被多个父节点共享;
- 怎样防止环导致无限递归;
- 遍历顺序是否稳定;
- 修改期间并发读取怎样处理;
- 聚合失败是整体失败还是保留部分结果。
如果结构可能形成任意图而不是树,简单 Composite 已不足以表达所有权和环语义。
Flyweight:共享内在状态,外置上下文状态
当系统创建数百万个相似对象,重复保存相同字体、规则或资源描述可能浪费内存。Flyweight 把可共享、与上下文无关的内在状态做成不可变对象,把位置、拥有者等外在状态留给调用上下文。
public record BadgeStyle(
String iconPath,
String color,
String label) {}
public record AwardedBadge(
BadgeStyle style,
PlayerId owner,
java.time.Instant awardedAt) {}BadgeStyle 可以通过规范化 key 共享:
public final class BadgeStyleCatalog {
private final java.util.concurrent.ConcurrentMap<String, BadgeStyle> styles =
new java.util.concurrent.ConcurrentHashMap<>();
public BadgeStyle getOrCreate(String key,
java.util.function.Supplier<BadgeStyle> factory) {
return styles.computeIfAbsent(key, ignored -> factory.get());
}
}在一次成功的原子计算中,每个 key 的映射函数至多执行一次;若函数抛出异常,映射不会建立,后续调用可能再次执行。因此工厂不应依赖一次性副作用。
共享前先测量
Flyweight 会增加 key 规范化、缓存生命周期和间接访问。只有对象数量、重复率和内存剖析证明收益时才值得使用。JVM 字符串池、数据库连接池和普通缓存与 Flyweight 有相似的共享概念,但生命周期、可变性和资源语义不同,不能只因“共享”就视为同一模式。
缓存还要防止无界增长:限定大小、使用弱引用、按版本替换或在租户卸载时清理。共享对象必须不可变,或拥有严格同步;否则一个调用者的修改会污染所有使用者。
三种模式的失败信号
| 模式 | 失败信号 |
|---|---|
| Decorator | 装配顺序散落、错误语义改变、链过深难诊断 |
| Composite | 叶子被迫支持无意义操作、所有权不清、形成环 |
| Flyweight | 未测量就优化、共享可变状态、缓存无界增长 |
练习:先描述结构,再写模式名
对一个真实需求写四句话:
- 哪些对象必须保持同一接口?
- 变化是增加一层行为、形成递归结构,还是共享重复状态?
- 状态和资源由谁拥有?
- 包装顺序、树的所有权或缓存生命周期怎样验证?
只有这些问题有答案后,模式实现才不会停留在类图模仿。