对象不只是 new 出来:工厂、Builder、作用域与复制
创建一个对象,往往同时在回答四个问题:用哪个实现、怎样凑齐参数、创建出来的对象是否合法、它活多久以及有几个。把这四件事都交给构造器、Builder 或者 DI 容器中的某一个,是创建类 bug 的主要来源。
先看两个都能编译、也都能运行的例子。第一个是通知客户端的配置,用一个不做校验的 Builder 创建:
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 容器回答「谁来装配、活多久」。 三者可以同时出现,但不要让其中一个替另外两个做事。
二、创建时的四个问题
| 问题 | 典型信号 | 交给谁 | 不要 |
|---|---|---|---|
| 用哪个实现 | 调用方知道渠道、厂商或配置,但不该知道具体类 | 静态工厂、工厂对象,或容器按类型注入 | 在业务代码里到处 new VendorXClient |
| 怎样凑齐参数 | 可选参数多、有默认值、构造步骤有先后 | Builder;参数少且都必填时用 record 或构造器 | 字段多就无脑加 Builder |
| 是否合法 | 跨字段约束(连接超时不超过读超时)、必填、范围 | 对象自己的构造器 | 只在 Builder 或调用方里校验 |
| 活多久、有几个 | 连接池、客户端、配置快照、请求上下文 | DI 容器的作用域,或显式传递 | 静态单例、全局可变状态 |
工厂和 Builder 的写法在 设计模式决策图 里已经给过;构造器、Builder、类型状态 Builder 分别在编译期、构建期和运行期暴露错误的实测,见 API 设计与 DSL。本文只看它们之间的责任边界。
三、Builder 收集参数,不保证合法
开篇那个 Builder 有两个问题:build() 不检查任何约束;它把自己内部的列表直接交给了新对象。改法是让 Builder 只做收集,把校验与复制交给对象的构造器:
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 里的原因。
四、单例在哪个范围内唯一
「系统里只能有一个」这句话必须补上范围:
- 静态字段:一个类加载器内唯一。实测用两个
URLClassLoader加载同一个Registry类,得到两个INSTANCE,各自计数 3 和 1。应用服务器、插件系统、测试框架都可能出现多个类加载器。 - Spring 的 singleton:一个容器内唯一。实测同一容器两次
getBean得到同一个对象,两个容器各有一个。父子容器、多个SpringApplication、测试中缓存的多个上下文都会让它不止一个。 - 进程和集群:创建模式管不到。多个实例同时运行时,「只有一个在做」需要锁、选主或数据库唯一约束,见 分布式锁的三种实现。
作用域还有一个常见的错位:生命周期短的对象注入到生命周期长的对象里。实测把一个 prototype 的请求上下文直接注入 singleton 的发送器,之后每次取到的都是同一个上下文,它只在发送器创建时取了一次。改用 ObjectProvider<RequestContext>,每次 getObject() 都得到新实例:
@Bean
Sender sender(RequestContext injected, ObjectProvider<RequestContext> provider) {
return new Sender(injected, provider); // injected 不会再变;provider.getObject() 每次新建
}五、复制:先决定身份
复制对象时,要先回答「副本是谁」。
浅复制共享可变集合。 实测逐字段复制一条报名后往副本的名单里加人,原报名的名单也变成了 [alice, mallory]。record 的 withXxx 写法、拷贝构造器,只要没有复制集合,都会有同样的问题。
深复制保留了身份。 复制所有集合解决了共享,但 id 和版本号原样保留。实测把这样的副本当作模板改完名单后保存:仓储按 id 保存,结果只有 1 条记录,原报名的名单被覆盖;再保存原对象时,版本号已经对不上,抛出版本冲突。
以模板新建。 如果副本是一个新的业务对象,就要显式地清掉身份相关的东西:
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,是否合法交给对象自己的构造器,活多久、有几个交给容器,并写清楚范围。复制对象之前,先决定副本是同一个对象的快照,还是一个新对象。
同样包一层、意图却不同的适配器、装饰器与代理,见 都是包一层,意图却不同。
配套实验
- codesphere-labs/design/object-creation-lifecycle:Builder 的校验与防御性复制、两个类加载器的静态单例、Spring 的 singleton 与 prototype、浅复制与聚合复制(验证记录)
参考资料
- Joshua Bloch,《Effective Java》第 3 版,第 2 章(创建和销毁对象)
- Java Language Specification 21:Record Classes(紧凑构造器)
- Spring Framework Reference:Bean Scopes
- Spring Framework API:ObjectProvider