Skip to content

对象不只是 new 出来:工厂、Builder、作用域与复制 ​

创建一个对象,往往同时在回答四个问题:用哪个实现、怎样凑齐参数、创建出来的对象是否合法、它活多久以及有几个。把这四件事都交给构造器、Builder 或者 DI 容器中的某一个,是创建类 bug 的主要来源。

先看两个都能编译、也都能运行的例子。第一个是通知客户端的配置,用一个不做校验的 Builder 创建:

java
LooseBuilder builder = new LooseBuilder()
        .connectTimeout(Duration.ofSeconds(5))
        .readTimeout(Duration.ofSeconds(1))      // 读超时比连接超时还短
        .channel("sms");
LooseConfig cfg = builder.build();               // 成功
builder.channel("email");                        // build 之后继续用 Builder
cfg.channels                                     // [sms, email]:已建成的对象也变了

第二个是复制一条活动报名作为模板:深复制了所有集合,却保留了原来的 id 和版本号。保存副本之后,仓储里只剩 1 条记录,原报名的名单被副本覆盖了。

两个例子里用到的「模式」都没有错,问题在于责任放错了地方。本文用 JDK 21 与 Spring Framework 7.0.9 的实测,把创建过程拆开看。

一、先说结论 ​

  • 合法性只能由对象自己保证。 Builder 负责收集参数,build() 应当把校验交给对象的构造器。实测交给 record 紧凑构造器后,非法配置在 build() 时被拒绝,渠道列表也被复制成不可修改的副本。
  • 「单例」必须写明在什么范围内唯一。 实测同一个静态单例在两个类加载器里各有一个;Spring 的 singleton 在两个容器里各有一个。跨进程、跨节点的唯一,不是创建模式能解决的。
  • 生命周期短的对象不要直接注入生命周期长的对象。 实测 prototype 直接注入 singleton 后始终是同一个实例;需要每次新建时用 ObjectProvider。
  • 复制要先决定身份。 浅复制共享集合,改副本会污染原对象;复制聚合时要决定是「同一个对象的快照」还是「以它为模板的新对象」,后者必须重置 id、版本和未发布的事件。
  • 工厂回答「用哪个实现」,Builder 回答「怎样凑齐参数」,DI 容器回答「谁来装配、活多久」。 三者可以同时出现,但不要让其中一个替另外两个做事。

二、创建时的四个问题 ​

选哪个实现?按渠道、按配置怎样凑齐参数?可选项多、步骤多对象是否合法?跨字段不变量活多久、有几个?静态工厂 / 工厂对象隐藏具体类型Builder收集参数,build() 交出对象自己的构造器record 紧凑构造器容器作用域 / 显式单例build() 把校验交给构造器不要:Builder 自己放行实测造出 connect > read 的配置不要:以为 singleton 全局唯一
图 1 · 「选哪个实现」「怎样凑齐参数」「对象是否合法」「活多久、有几个」是四个不同的问题:前两个交给工厂和 Builder,合法性只能由对象自己保证,生命周期交给容器或显式的作用域
问题典型信号交给谁不要
用哪个实现调用方知道渠道、厂商或配置,但不该知道具体类静态工厂、工厂对象,或容器按类型注入在业务代码里到处 new VendorXClient
怎样凑齐参数可选参数多、有默认值、构造步骤有先后Builder;参数少且都必填时用 record 或构造器字段多就无脑加 Builder
是否合法跨字段约束(连接超时不超过读超时)、必填、范围对象自己的构造器只在 Builder 或调用方里校验
活多久、有几个连接池、客户端、配置快照、请求上下文DI 容器的作用域,或显式传递静态单例、全局可变状态

工厂和 Builder 的写法在 设计模式决策图 里已经给过;构造器、Builder、类型状态 Builder 分别在编译期、构建期和运行期暴露错误的实测,见 API 设计与 DSL。本文只看它们之间的责任边界。

三、Builder 收集参数,不保证合法 ​

开篇那个 Builder 有两个问题:build() 不检查任何约束;它把自己内部的列表直接交给了新对象。改法是让 Builder 只做收集,把校验与复制交给对象的构造器:

java
public record ClientConfig(String endpoint, Duration connectTimeout, Duration readTimeout, List<String> channels) {
    public ClientConfig {
        Objects.requireNonNull(endpoint, "endpoint");
        if (connectTimeout.compareTo(readTimeout) > 0) {
            throw new IllegalArgumentException("connectTimeout " + connectTimeout + " > readTimeout " + readTimeout);
        }
        if (channels.isEmpty()) throw new IllegalArgumentException("至少一个渠道");
        channels = List.copyOf(channels);          // 防御性复制,同时变成不可修改
    }
}

public ClientConfig build() {                        // Builder 只负责传参
    return new ClientConfig(endpoint, connectTimeout, readTimeout, channels);
}

实测:同样 5 秒连接超时、1 秒读超时的参数,build() 抛出 connectTimeout PT5S > readTimeout PT1S;合法的配置建好之后再往 Builder 里加渠道,配置里的渠道仍是 [sms],而且 channels().add(...) 抛出 UnsupportedOperationException。绕过 Builder、直接调用构造器时,同样的校验和复制依然生效,这是把规则放在对象里而不是 Builder 里的原因。

四、单例在哪个范围内唯一 ​

类加载器static INSTANCE实测 2 个加载器、2 个实例Spring 容器singleton 作用域实测 2 个容器、2 个 Beanprototype 注入 singleton只在创建时取一次实测之后一直是同一个进程 / 集群创建模式管不到需要锁、选主或数据库约束JVM 内:写清楚「在哪个范围内唯一」,并用 ObjectProvider 按需获取短生命周期对象实测 ObjectProvider.getObject() 每次返回新实例见分布式锁与数据库约束
图 2 · 静态字段只在一个类加载器里唯一,Spring 的 singleton 只在一个容器里唯一,prototype 注入 singleton 后就不会再变;跨进程、跨节点的唯一需要锁或选主,不是创建模式能解决的

「系统里只能有一个」这句话必须补上范围:

  • 静态字段:一个类加载器内唯一。实测用两个 URLClassLoader 加载同一个 Registry 类,得到两个 INSTANCE,各自计数 3 和 1。应用服务器、插件系统、测试框架都可能出现多个类加载器。
  • Spring 的 singleton:一个容器内唯一。实测同一容器两次 getBean 得到同一个对象,两个容器各有一个。父子容器、多个 SpringApplication、测试中缓存的多个上下文都会让它不止一个。
  • 进程和集群:创建模式管不到。多个实例同时运行时,「只有一个在做」需要锁、选主或数据库唯一约束,见 分布式锁的三种实现。

作用域还有一个常见的错位:生命周期短的对象注入到生命周期长的对象里。实测把一个 prototype 的请求上下文直接注入 singleton 的发送器,之后每次取到的都是同一个上下文,它只在发送器创建时取了一次。改用 ObjectProvider<RequestContext>,每次 getObject() 都得到新实例:

java
@Bean
Sender sender(RequestContext injected, ObjectProvider<RequestContext> provider) {
    return new Sender(injected, provider);   // injected 不会再变;provider.getObject() 每次新建
}

五、复制:先决定身份 ​

复制对象时,要先回答「副本是谁」。

浅复制共享可变集合。 实测逐字段复制一条报名后往副本的名单里加人,原报名的名单也变成了 [alice, mallory]。record 的 withXxx 写法、拷贝构造器,只要没有复制集合,都会有同样的问题。

深复制保留了身份。 复制所有集合解决了共享,但 id 和版本号原样保留。实测把这样的副本当作模板改完名单后保存:仓储按 id 保存,结果只有 1 条记录,原报名的名单被覆盖;再保存原对象时,版本号已经对不上,抛出版本冲突。

以模板新建。 如果副本是一个新的业务对象,就要显式地清掉身份相关的东西:

java
public Registration copyAsNew(String newActivity) {
    return new Registration(null, 0, newActivity, new ArrayList<>(attendees), new ArrayList<>());
    //                       ↑id  ↑version                                  ↑不继承未发布的事件
}

实测保存后得到新 id 和 version 1,原报名不受影响。未发布的领域事件也不应该被复制,否则一次「报名已开放」会被发布两次。聚合、版本号与事件的更多约束见 从统一语言到限界上下文。

六、创建决策表 ​

场景建议
参数少、都必填、没有跨字段约束构造器或 record
参数多、有默认值Builder,build() 调用会校验的构造器
调用方不该知道实现类静态工厂;在 Spring 里按接口注入
创建逻辑依赖运行时条件(渠道、租户)工厂对象,结果可以缓存
全局只要一个先写清楚范围;JVM 内交给容器,跨进程用锁或数据库约束
长生命周期对象需要短生命周期的依赖ObjectProvider 或方法参数传入
需要一个相似的新对象以模板新建:重置身份、版本和事件;集合逐个复制

七、常见误区 ​

  • 「有 Builder 就不会创建出非法对象」:实测不校验的 Builder 造出了连接超时大于读超时的配置。
  • 「Spring Bean 默认单例,所以全局只有一个」:它只在一个容器内唯一,实测两个容器各有一个。
  • 「深复制就安全了」:深复制解决共享引用,但保留了 id 和版本,保存时会覆盖原对象。
  • 「prototype Bean 每次都是新的」:直接注入到 singleton 后只创建一次。
  • 「DI 容器可以替代对象自己的校验」:容器负责装配和生命周期,不知道业务不变量。

小结 ​

创建对象时先把四个问题分开:用哪个实现交给工厂,怎样凑齐参数交给 Builder,是否合法交给对象自己的构造器,活多久、有几个交给容器,并写清楚范围。复制对象之前,先决定副本是同一个对象的快照,还是一个新对象。

同样包一层、意图却不同的适配器、装饰器与代理,见 都是包一层,意图却不同。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权