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 条记录,原报名的名单被副本覆盖了。

两个例子里用到的「模式」都没有错,问题在于责任放错了地方:工厂回答「用哪个实现」,Builder 回答「怎样凑齐参数」,对象自己的构造器回答「是否合法」,DI 容器回答「谁来装配、活多久」。三者可以同时出现,但不要让其中一个替另外几个做事。本文用 JDK 21 与 Spring Framework 7.0.9 的实测,把创建过程拆开看。

一、创建时的四个问题 ​

选哪个实现?按渠道、按配置怎样凑齐参数?可选项多、步骤多对象是否合法?跨字段不变量活多久、有几个?静态工厂 / 工厂对象隐藏具体类型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 里的原因。DI 容器也替代不了这一步:容器负责装配和生命周期,不知道业务不变量。

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

类加载器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 或方法参数传入
需要一个相似的新对象以模板新建:重置身份、版本和事件;集合逐个复制

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


配套实验

参考资料

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