对象不只是 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 条记录,原报名的名单被副本覆盖了。
两个例子里用到的「模式」都没有错,问题在于责任放错了地方:工厂回答「用哪个实现」,Builder 回答「怎样凑齐参数」,对象自己的构造器回答「是否合法」,DI 容器回答「谁来装配、活多久」。三者可以同时出现,但不要让其中一个替另外几个做事。本文用 JDK 21 与 Spring Framework 7.0.9 的实测,把创建过程拆开看。
一、创建时的四个问题
| 问题 | 典型信号 | 交给谁 | 不要 |
|---|---|---|---|
| 用哪个实现 | 调用方知道渠道、厂商或配置,但不该知道具体类 | 静态工厂、工厂对象,或容器按类型注入 | 在业务代码里到处 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 里的原因。DI 容器也替代不了这一步:容器负责装配和生命周期,不知道业务不变量。
三、单例在哪个范围内唯一
「系统里只能有一个」这句话必须补上范围:
- 静态字段:一个类加载器内唯一。实测用两个
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 或方法参数传入 |
| 需要一个相似的新对象 | 以模板新建:重置身份、版本和事件;集合逐个复制 |
同样包一层、意图却不同的适配器、装饰器与代理,见 都是包一层,意图却不同。
配套实验
- 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