跳到内容

第 6 卷 / 第 9 章 / 第 2 课

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

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

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

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

一个通知通道可能需要计时、追踪与重试。与其为每种组合创建子类,可以逐层包装。下例使用 Java 21 和 Micrometer micrometer-core 的 Timer API;项目需提供上一节的通道及消息类型,并在装配处注入计时器:

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

装配时决定层次:

java
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 表示非负整数游戏币,不涉及货币汇率:

java
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:

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

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

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

这里 PlayerId 是项目类型,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) {
        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未测量就优化、共享可变状态、缓存无界增长

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

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

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

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

参考资料 ​

Built with VitePress | Software Systems Atlas