9.2 可组合结构:Decorator、Composite 与 Flyweight
一组界面组件既要像树一样组合,又要按需叠加行为,还要避免复制大量相同状态。
Decorator、Composite 与 Flyweight 都依赖共享抽象,却解决三类不同问题:行为怎样层叠、叶子与容器怎样统一处理,以及大量对象怎样共享重复状态。
Decorator:围绕同一契约叠加职责
一个通知通道可能需要计时、追踪与重试。与其为每种组合创建子类,可以逐层包装。下例使用 Java 21 和 Micrometer micrometer-core 的 Timer API;项目需提供上一节的通道及消息类型,并在装配处注入计时器:
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 = java.util.Objects.requireNonNull(target, "target");
this.timer = java.util.Objects.requireNonNull(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);这里假设 deliver 同步返回,重试器、HTTP 通道和策略由项目实现。若它返回 CompletionStage,此处只会量到提交异步操作所花的时间,必须在完成回调中停止计时。Micrometer Timer 文档 说明了同步包装与 Timer.Sample 的用法。
顺序属于语义:一次失败后重试成功,外层计时器记录 1 次,内层记录 2 次。外层耗时包括所有尝试和退避等待,内层每次记录通常不包括两次尝试之间的等待。异常映射、缓存与事务装饰器也会受顺序影响,应集中装配并测试整条链。
Decorator 应维持被包装接口的基本契约。若包装后要求额外初始化步骤、拒绝原本合法输入或改变身份/相等语义,调用者可能无法透明使用。
Decorator 与 Proxy 为什么容易混淆
两者结构几乎相同,分类取决于意图:
- Proxy 代表另一个对象并控制访问;
- Decorator 在相同抽象上组合额外职责。
缓存、日志、重试既可能被称作代理,也可能被称作装饰器。与其争名称,不如写清:谁拥有目标对象、调用是否透明、顺序是否重要、失败和生命周期是否改变。
Java I/O 中 BufferedInputStream 包装另一个 InputStream,是 Decorator 的经典结构;关闭外层通常也会关闭内层,说明资源所有权也是契约的一部分。
Composite:统一处理叶子与组合节点
赛事奖励可以形成树:单项奖励是叶子,奖励包包含其他奖励。下面是完整 Java 21 类型,每个 public 类型单独存放;RewardValue 表示非负整数游戏币,不涉及货币汇率:
public record RewardValue(long coins) {
public static final RewardValue ZERO = new RewardValue(0);
public RewardValue {
if (coins < 0) throw new IllegalArgumentException("negative coins");
}
public RewardValue add(RewardValue other) {
java.util.Objects.requireNonNull(other, "other");
return new RewardValue(Math.addExact(coins, other.coins));
}
}
public sealed interface RewardComponent
permits CoinReward, RewardBundle {
RewardValue total();
}
public record CoinReward(RewardValue value)
implements RewardComponent {
public CoinReward {
java.util.Objects.requireNonNull(value, "value");
}
@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(),无需区分单个奖励还是嵌套奖励包。将下面语句放入 main:
var leaf = new CoinReward(new RewardValue(10));
var pair = new RewardBundle(java.util.List.of(leaf, leaf));
var root = new RewardBundle(java.util.List.of(leaf, pair));
System.out.println(root.total().coins()); // 30
System.out.println(new RewardBundle(java.util.List.of()).total().coins()); // 0空包合计为 0;同一叶子出现几次就计算几次,不按对象身份去重。所有节点不可变并自底向上构造,正常构造路径无法造环,共享子树则是允许的。加法溢出抛出 ArithmeticException,不会返回绕回负数的“优惠价”;极深的嵌套仍可能耗尽调用栈。
安全接口与透明接口
把 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) {}这里 PlayerId 是项目类型,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) {
java.util.Objects.requireNonNull(key, "key");
java.util.Objects.requireNonNull(factory, "factory");
return styles.computeIfAbsent(key, ignored ->
java.util.Objects.requireNonNull(factory.get(), "style"));
}
}同一 key 必须代表相同图标、颜色、标签及必要的版本/租户范围;如果两个调用者给同一个 key 提供不同工厂,已存入的值会胜出,缓存不会替你裁决哪种颜色才对。
具体实现是 ConcurrentHashMap:键不存在时原子地计算并写入,已存在时不调用工厂。本例明确拒绝空 key、空工厂和空结果;工厂抛异常或返回空导致失败时不建立映射,后续调用可以重试。计算期间部分更新可能阻塞,工厂应短小,不做慢网络调用,也不在计算过程中修改该 map 的其他映射或递归更新自身。该规则不等于进程生命周期内只调用一次,更不提供外部副作用的 exactly-once 保证。
共享前先测量
Flyweight 会增加 key 规范化、缓存生命周期和间接访问。只有对象数量、重复率和内存剖析证明收益时才值得使用。JVM 字符串池、数据库连接池和普通缓存与 Flyweight 有相似的共享概念,但生命周期、可变性和资源语义不同,不能只因“共享”就视为同一模式。
示例没有淘汰策略,只适合规模受控的样式集合,不能直接接收无限种用户输入。实际缓存要通过容量上限、适当的弱引用策略、版本替换或租户卸载清理控制保留量;这些机制不会由 ConcurrentHashMap 自动提供。共享对象必须不可变,或拥有严格同步;否则一个调用者的修改会污染所有使用者。
三种模式的失败信号
| 模式 | 失败信号 |
|---|---|
| Decorator | 装配顺序散落、错误语义改变、链过深难诊断 |
| Composite | 叶子被迫支持无意义操作、所有权不清、形成环 |
| Flyweight | 未测量就优化、共享可变状态、缓存无界增长 |
练习:先描述结构,再写模式名
对一个真实需求写四句话:
- 哪些对象必须保持同一接口?
- 变化是增加一层行为、形成递归结构,还是共享重复状态?
- 状态和资源由谁拥有?
- 包装顺序、树的所有权或缓存生命周期怎样验证?
只有这些问题有答案后,模式实现才不会停留在类图模仿。