跳到内容

第 6 卷 / 第 8 章 / 第 2 课

8.2 Builder、Prototype 与实例作用域 ​

Factory 解决“选择哪种实现”,Builder 解决“怎样把一个复杂对象组装完整”,Prototype 解决“怎样从现有对象派生新对象”。Singleton 则不是组装算法,而是对实例作用域施加限制。

Builder:把可选配置与最终不变量分开 ​

构造器参数很多并不自动需要 Builder。若参数都必填,一个参数对象或记录类型可能更诚实。Builder 更适合:

  • 可选项较多;
  • 创建过程分多步收集信息;
  • 构建前需要跨字段校验;
  • 希望最终对象保持不可变。

将完整类型保存为 TournamentPlan.java,使用 Java 21:

java
public final class TournamentPlan {
    private final String name;
    private final int capacity;
    private final java.time.Duration matchDuration;
    private final boolean ranked;
    private final Integer minimumLevel;

    private TournamentPlan(Builder b) {
        this.name = b.name;
        this.capacity = b.capacity;
        this.matchDuration = b.matchDuration;
        this.ranked = b.ranked;
        this.minimumLevel = b.minimumLevel;
    }

    public String name() { return name; }
    public int capacity() { return capacity; }
    public java.time.Duration matchDuration() { return matchDuration; }
    public boolean ranked() { return ranked; }
    public java.util.OptionalInt minimumLevel() {
        return minimumLevel == null ? java.util.OptionalInt.empty()
                : java.util.OptionalInt.of(minimumLevel);
    }

    public static Builder builder(String name, int capacity) {
        return new Builder(name, capacity);
    }

    public static final class Builder {
        private final String name;
        private final int capacity;
        private java.time.Duration matchDuration =
                java.time.Duration.ofMinutes(15);
        private boolean ranked;
        private Integer minimumLevel;

        private Builder(String name, int capacity) {
            if (name == null || name.isBlank()) {
                throw new IllegalArgumentException("name is blank");
            }
            if (capacity < 2) {
                throw new IllegalArgumentException("capacity < 2");
            }
            this.name = name;
            this.capacity = capacity;
        }

        public Builder matchDuration(java.time.Duration value) {
            this.matchDuration = java.util.Objects.requireNonNull(value);
            return this;
        }

        public Builder rankedFromLevel(int level) {
            this.ranked = true;
            this.minimumLevel = level;
            return this;
        }

        public TournamentPlan build() {
            if (matchDuration.isZero() || matchDuration.isNegative()) {
                throw new IllegalStateException("match duration must be positive");
            }
            if (ranked && minimumLevel < 1) {
                throw new IllegalStateException("minimum level < 1");
            }
            return new TournamentPlan(this);
        }
    }
}

必填参数在 builder() 入口出现;可选参数有领域化名称;最终对象只在 build() 验证通过后产生。下面的语句放入 main,可以观察 Builder 复用不会改写已生成的计划:

java
var builder = TournamentPlan.builder("Open", 16);
var first = builder.build();
var second = builder.matchDuration(java.time.Duration.ofMinutes(30))
        .rankedFromLevel(18).build();
System.out.println(first.matchDuration().toMinutes()); // 15
System.out.println(first.ranked());                    // false
System.out.println(second.minimumLevel().orElseThrow()); // 18

这里所有字段值都不可变,所以复制字段足够。零时长或等级 0 会在 build() 被拒绝,调用者修正 Builder 后可以重新构建;失败不会制造一个半合法的 TournamentPlan。

Builder 自身通常可变且不保证线程安全,不应跨线程共享。构建完成后也不要让最终对象保留对 Builder 可变集合的引用;使用 List.copyOf 等方式取得快照。

Builder 不是 setter 的另一种拼法 ​

假设另一个 API 允许下面这种写法,又完全不校验,就仍能制造非法对象。它不是上面 TournamentPlan.Builder 的接口:

java
builder.name(null).capacity(-1).build();

如果所有字段都可任意设置,build() 也不校验,它只是把 setter 改成链式语法。真正价值在于:

  • 区分必填与可选;
  • 用方法名表达有效组合;
  • 在一个位置维护跨字段不变量;
  • 隐藏中间态,不让半成品进入业务系统。

当构建顺序本身必须受类型约束时,可以使用 staged builder,但接口数量和泛型复杂度会增加。多数业务对象用运行时校验已经足够。

Prototype:先定义复制语义 ​

“复制”至少有三种可能:

  • 浅复制:新外层对象,共享内部引用;
  • 深复制:递归复制整张可达对象图;
  • 领域复制:只复制被定义为模板的值,重置 ID、时间戳和运行状态。

业务系统通常需要第三种。深复制还必须规定如何处理循环引用、共享子图和不能复制的资源句柄;递归调用几次构造器并没有解决这些问题。

下面是项目片段,假设 RewardRule 是不可变值,TournamentId 和 Tournament.draft(...) 由领域模型提供:

java
public record TournamentTemplate(
        String name,
        int capacity,
        java.time.Duration matchDuration,
        java.util.List<RewardRule> rewardRules) {

    public TournamentTemplate {
        if (name == null || name.isBlank() || capacity < 2) {
            throw new IllegalArgumentException("invalid name or capacity");
        }
        java.util.Objects.requireNonNull(matchDuration, "matchDuration");
        if (matchDuration.isZero() || matchDuration.isNegative()) {
            throw new IllegalArgumentException("match duration must be positive");
        }
        rewardRules = java.util.List.copyOf(rewardRules);
    }

    public TournamentTemplate withName(String newName) {
        return new TournamentTemplate(
                newName, capacity, matchDuration, rewardRules);
    }

    public Tournament instantiate(TournamentId newId,
                                  java.time.Instant createdAt) {
        java.util.Objects.requireNonNull(newId, "newId");
        java.util.Objects.requireNonNull(createdAt, "createdAt");
        return Tournament.draft(
                newId, name, capacity, matchDuration,
                rewardRules, createdAt);
    }
}

不可变 RewardRule 可以安全共享;List.copyOf 只固定列表内容,不会深复制元素。withName() 派生配置,instantiate() 使用调用者提供的新 ID 和时间创建草稿;它本身不生成 ID,也不防止调用者重复使用旧 ID。复制哪些值、由谁产生新身份,都写在 API 契约里,才不会复制出两个同名同姓还共用身份证的赛事。

Java 的 Cloneable 只是一种标记,并不声明公开 clone() 方法;Object.clone() 默认执行字段级浅复制。对拥有可变引用、资源句柄或继承层次的对象,显式复制构造器、命名复制方法或不可变值通常更安全。

序列化再反序列化不是通用深拷贝方案:它把复制语义绑定到序列化格式,还会带来性能、版本、类型和安全问题。

Singleton:先问“在哪个范围唯一” ​

“系统只有一个实例”必须说明范围:

text
每次方法调用?每个请求?每个线程?
每个 DI 容器?每个 class loader?每个 JVM 进程?
整个集群?整个租户?

静态字段属于具体的类身份:类名与定义它的 class loader。多个子加载器若委托同一个父加载器,可能共享同一个类;两个加载器分别定义该类,则各有静态字段。单凭一个静态字段也不阻止别处调用公开构造器。多进程部署更不会因此共享实例,它不能实现集群唯一,也不能替代分布式锁或数据库唯一约束。

全局访问与单实例是两件事 ​

java
import java.time.Clock;

public final class RegistrationService {
    private final Clock clock;

    public RegistrationService(Clock clock) {
        this.clock = java.util.Objects.requireNonNull(clock, "clock");
    }
}

装配层可以只创建一个 Clock 或服务实例,再注入所有调用者;业务代码无需 GlobalClock.getInstance()。这样既控制实例数量,又保持依赖显式和可测试。

全局 Singleton 常见问题包括:

  • 隐藏依赖和初始化顺序;
  • 可变状态在测试间泄漏;
  • 并发规则不清楚;
  • 以后需要多租户、多配置或多个连接池时难拆;
  • 生命周期与关闭顺序难管理。

日志 facade、枚举常量或无状态共享对象可能适合全局入口,但应把可变配置和资源生命周期放在明确边界。

Java 中线程安全初始化不等于线程安全对象 ​

java
public enum ProcessIdentity {
    INSTANCE;

    private final java.util.UUID id = java.util.UUID.randomUUID();

    public java.util.UUID id() { return id; }
}

把枚举保存为 ProcessIdentity.java。类初始化协议保证常量的安全初始化,但这里的名字不代表跨 class loader 的“整个进程唯一”。枚举序列化只保存常量名,反序列化取得所解析枚举类中的同一个常量;id 字段不会写入流。另一进程初始化该枚举时会生成自己的 UUID。

若 Singleton 内部持有可变 Map、计数器或连接,仍需单独设计同步、可见性和关闭行为。“单例创建是线程安全的”不能推出“所有方法都线程安全”。参见 JLS 类初始化规则 与 枚举序列化规范。

五种模式的选择表 ​

模式主要变化点典型风险
Factory Method父类流程中的产品创建步骤为简单选择引入不必要继承
Abstract Factory整族产品的具体实现给产品族增加新种类代价高
Builder复杂对象的组装与校验半成品泄露、只换皮的 setter
Prototype从现有值派生对象浅/深/领域复制语义不清
Singleton某一范围内的实例数量全局状态、隐藏依赖、误判作用域

模式名不是目标。先写清楚变化点、生命周期、所有权和不变量,再选择最小的创建机制。

练习 ​

选择一个真实对象,分别回答:

  1. 哪些参数必填,哪些可选?
  2. 直接构造为何不足?
  3. 如果使用 Builder,哪些非法组合必须在 build() 拒绝?
  4. 如果从模板复制,哪些字段共享、复制或重置?
  5. 如果只创建一个实例,这个“一”具体属于哪个范围?

如果这些问题答不清楚,先不要选择模式。

参考资料 ​

Built with VitePress | Software Systems Atlas