跳到内容

9.2 可组合结构:Decorator、Composite 与 Flyweight

一组界面组件既要像树一样组合,又要按需叠加行为,还要避免复制大量相同状态。

Decorator、Composite 与 Flyweight 都依赖共享抽象,却解决三类不同问题:行为怎样层叠、叶子与容器怎样统一处理,以及大量对象怎样共享重复状态。

Decorator:围绕同一契约叠加职责

一个通知通道可能需要计时、追踪与重试。与其为每种组合创建子类,可以逐层包装:

java
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));
    }
}

装配时决定层次:

java
DeliveryChannel channel = new TimedDeliveryChannel(
        new RetryingDeliveryChannel(
                new HttpDeliveryChannel(client), retryPolicy),
        timer);

顺序属于语义:计时器放在重试器外层会测量全部尝试,放在内层会分别记录每次尝试。异常映射、缓存与事务装饰器也会受顺序影响,应集中装配并测试整条链。

Decorator 应维持被包装接口的基本契约。若包装后要求额外初始化步骤、拒绝原本合法输入或改变身份/相等语义,调用者可能无法透明使用。

Decorator 与 Proxy 为什么容易混淆

两者结构几乎相同,分类取决于意图:

  • Proxy 代表另一个对象并控制访问;
  • Decorator 在相同抽象上组合额外职责。

缓存、日志、重试既可能被称作代理,也可能被称作装饰器。与其争名称,不如写清:谁拥有目标对象、调用是否透明、顺序是否重要、失败和生命周期是否改变。

Java I/O 中 BufferedInputStream 包装另一个 InputStream,是 Decorator 的经典结构;关闭外层通常也会关闭内层,说明资源所有权也是契约的一部分。

Composite:统一处理叶子与组合节点

赛事奖励可以形成树:单项奖励是叶子,奖励包包含其他奖励。

java
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 把可共享、与上下文无关的内在状态做成不可变对象,把位置、拥有者等外在状态留给调用上下文。

java
public record BadgeStyle(
        String iconPath,
        String color,
        String label) {}

public record AwardedBadge(
        BadgeStyle style,
        PlayerId owner,
        java.time.Instant awardedAt) {}

BadgeStyle 可以通过规范化 key 共享:

java
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未测量就优化、共享可变状态、缓存无界增长

练习:先描述结构,再写模式名

对一个真实需求写四句话:

  1. 哪些对象必须保持同一接口?
  2. 变化是增加一层行为、形成递归结构,还是共享重复状态?
  3. 状态和资源由谁拥有?
  4. 包装顺序、树的所有权或缓存生命周期怎样验证?

只有这些问题有答案后,模式实现才不会停留在类图模仿。

参考资料

Built with VitePress | Software Systems Atlas