Builder 构建者模式详解:不可变对象、Lombok 设计与使用边界


一、先别急着背概念:Builder 模式到底想解决什么?

构建者模式(Builder Pattern)是一种 创建型设计模式

但如果你现在只记住这句话,其实一点用都没有

我们换一种更接地气的说法:

当一个对象“参数很多、组合很多、还希望不可变”时,Builder 就出现了。


二、没有 Builder,我们会遇到什么问题?

1. 构造函数爆炸(新手最先踩的坑)

假设我们要写一个 Computer 类:

public class Computer {
    private String cpu;
    private String ram;
    private String storage;
    private boolean hasGraphicsCard;
}

如果你用“传统构造函数”来解决,很快就会变成这样:

new Computer(cpu);
new Computer(cpu, ram);
new Computer(cpu, ram, storage);
new Computer(cpu, ram, storage, hasGraphicsCard);

👉 构造函数数量不断膨胀,维护成本极高。

这就是经典的 Telescoping Constructor(伸缩构造函数问题)


2. Setter 看似灵活,其实隐患更多

很多新手会退一步,选择 setXxx()

Computer pc = new Computer();
pc.setCpu("i9");
pc.setRam("32GB");

问题是:

  • 对象在构建过程中处于 不完整状态

  • 无法保证“必填字段”

  • 对象很难设计成 不可变(Immutable)

👉 对象什么时候才算“构建完成”?代码本身并不知道。


三、Builder 模式的核心思想(一句话版)

把“构建过程”从“最终对象”中拆出来。

  • 构建过程:可以慢慢来、可以校验、可以修改

  • 最终对象:一次性生成,生成后不再变化

这正是 Builder 模式的精髓。


四、Builder 模式的经典结构(先认识角色)

在完整的设计模式定义中,Builder 包含 4 个角色:

  1. Product(产品类):最终要创建的复杂对象

  2. Builder(抽象建造者):定义构建步骤

  3. ConcreteBuilder(具体建造者):真正执行构建

  4. Director(指挥者):控制构建顺序(了解即可)

👉 但在日常 Java 开发中,我们几乎不会写完整的四件套。


五、最常见、最实用的写法:静态内部类 Builder

这是你在真实项目中 99% 会看到的 Builder 形式

1. 产品类(不可变)

public class Computer {
    private final String cpu;
    private final String ram;
    private final String storage;
    private final boolean hasGraphicsCard;

    private Computer(Builder builder) {
        this.cpu = builder.cpu;
        this.ram = builder.ram;
        this.storage = builder.storage;
        this.hasGraphicsCard = builder.hasGraphicsCard;
    }

关键点:

  • 构造器是 private

  • 所有字段都是 final

  • 外部无法直接 new Computer()


2. Builder:专门负责“拼装”的角色

    public static class Builder {
        private String cpu;
        private String ram;
        private String storage;
        private boolean hasGraphicsCard = false;

        public Builder setCpu(String cpu) {
            this.cpu = cpu;
            return this;
        }

        public Builder setRam(String ram) {
            this.ram = ram;
            return this;
        }

        public Builder setStorage(String storage) {
            this.storage = storage;
            return this;
        }

        public Builder setGraphicsCard(boolean hasGraphicsCard) {
            this.hasGraphicsCard = hasGraphicsCard;
            return this;
        }

        public Computer build() {
            return new Computer(this);
        }
    }
}

Builder 的职责非常清晰:

  • 暂存参数

  • 支持链式调用

  • 控制对象创建时机


3. 客户端调用(一眼就懂)

Computer pc = new Computer.Builder()
        .setCpu("Intel i9")
        .setRam("32GB")
        .setStorage("2TB SSD")
        .setGraphicsCard(true)
        .build();

👉 读代码的人,只关心:我要什么配置


六、Builder 和 build() 到底是什么关系?(重点理解)

Builder 是“过程”,build() 是“结果”。


1. Builder 是什么?

Builder = 构建过程的临时容器

  • 它是可变的

  • 它可以反复修改

  • 它本身并不是最终对象

Order.builder()
     .orderNo("NO123")
     .remark("test");

👉 到这里为止:

  • 对象还没创建

  • 只是配置在 Builder 里


2. build() 是什么?

build() = 对象诞生的唯一入口

Order order = Order.builder()
                   .orderNo("NO123")
                   .build();

build() 通常负责:

  1. 参数校验(必填项)

  2. 调用私有构造器

  3. 返回不可变对象


3. 状态转换的本质

阶段 状态
Builder 可变(Mutable)
build() 之后 不可变(Immutable)

这一步,是 Builder 模式最核心的价值


七、为什么 Builder 非常适合不可变对象?

很多人第一次学 Builder 模式时,会觉得它只是**“让构造函数更好看”**。
但实际上,Builder 最核心的价值之一,是——

它天生就是为“不可变对象(Immutable Object)”服务的。

要理解这一点,我们需要先弄清楚一个关键问题:


1. 不可变对象,难在哪?

所谓不可变对象,指的是:

对象一旦创建完成,内部状态就不能再被修改。

典型特征包括:

  • 所有字段都是 final

  • 没有 setter

  • 状态只能在构造阶段被一次性确定

public class User {
    private final String name;
    private final int age;
    private final String email;

    public User(String name, int age, String email) {
        this.name = name;
        this.age = age;
        this.email = email;
    }
}

这种对象的好处非常明显:

  • 线程安全

  • 状态可预测

  • 不会被“偷偷改掉”

但问题也很明显:

👉 构造函数会变得非常痛苦。

  • 参数一多,构造函数就“爆炸”

  • 参数顺序极易传错

  • 可选参数处理非常别扭


2. Builder 把「构造过程」和「最终对象」分开了

Builder 的核心思想不是“换一种写法”,而是:

把“一步步设置参数的过程”,和“对象真正诞生的那一刻”彻底分离。

User user = User.builder()
        .name("Tom")
        .age(18)
        .email("tom@example.com")
        .build();

在这个过程中,其实发生了两件事:

  1. Builder 是可变的

    • 你可以一步步调用 name()age()

    • 中间状态可以随意调整

  2. 最终的 User 是不可变的

    • 只有在 build() 时,才一次性创建 User

    • 创建完成后,对象状态被“冻结”

👉 可变只存在于 Builder,永远不会泄漏到最终对象中。


3. build(),其实是一个「状态冻结点」

这是很多人忽略的一点。

build() 方法,本质上是一个 “状态转换”

可变的 Builder
        ↓ build()
不可变的 User

build() 之前:

  • 参数可以缺失

  • 参数可以反复修改

  • 对象还不存在

build() 之后:

  • 所有字段一次性赋值

  • 所有 final 字段被初始化

  • 对象状态不再允许变化

public User build() {
    return new User(name, age, email);
}

📌 这正是 Builder 和不可变对象“天然契合”的关键原因。


4. 没有 Builder,不可变对象会退化成什么样?

如果你不用 Builder,通常只能在这两种方案里选:

方案一:超长构造函数
new User("Tom", 18, "tom@example.com", true, false, null, ...);
  • 可读性极差
  • 参数顺序一旦错,Bug 极隐蔽
方案二:放弃不可变性,使用 setter
User user = new User();
user.setName("Tom");
user.setAge(18);
  • 对象在“半成品状态”下暴露

  • 线程安全、约束性全部丢失

👉 Builder 是唯一一个:

同时保住「不可变性」和「可读性」的方案。


5. 一个一句话总结(非常适合收尾)

Builder 允许我们用“可变的方式去构造”,却得到一个“不可变的结果”。

也正因为如此:

  • Java 的不可变对象
  • Lombok 的 @Builder
  • Java 记录类(record
  • 以及很多领域模型(Domain Model)

几乎都会天然地向 Builder 靠拢。


八、Lombok 简化构建者模式

在 Java 开发中,手动编写 Builder 已经很少见了。大多数开发者会使用 Lombok ,只需一个注解即可自动生成:

@Builder

只需在类上加一个注解,Lombok 就会在编译时自动生成私有构造函数、静态内部 Builder 类以及所有的链式赋值方法。

@Getter               // 1. 构建后通常只需要读取,属性应设为私有  
@ToString             // 2. 方便日志排查  
@Builder
@AllArgsConstructor(access = AccessLevel.PRIVATE) // 3. 重点:隐藏全参构造
public class Animal {  
    private final String name;  
    private final Integer age;  
    private final String color;  
    private final String type;  
}

使用 @Builder 注解后,Lombok 会在编译时自动生成上述复杂的内部类代码。

测试@Builder

@Test  
void contextLoads() {  
    Animal animal = Animal.builder()  
            .name("狗")  
            .age(20)  
            .color("黄色")  
            .type("狗类")  
            .build();  
  
    System.out.println(animal);
}
  • 结果输出
Animal(name=, age=20, color=黄色, type=狗类)

@Builder(toBuilder=true):构建新对象

痛点解决:修改不可变对象 (toBuilder = true)

正如之前提到的,如果你用了 final 关键字,对象就无法修改。开启 toBuilder 功能可以基于现有对象创建一个新的 Builder,从而实现“局部修改”。

@Getter               // 1. 构建后通常只需要读取,属性应设为私有  
@ToString             // 2. 方便日志排查  
@Builder(toBuilder = true) // 3. 允许从对象实例反向创建 
@AllArgsConstructor(access = AccessLevel.PRIVATE) // 4. 重点:隐藏全参构造
public class Animal {  
    private final String name;  
    private final Integer age;  
    private final String color;  
    private final String type;  
}

测试@Builder(toBuilder=true)

@Test  
void contextLoads() {  
    Animal animal = Animal.builder()  
            .name("狗")  
            .age(20)  
            .color("黄色")  
            .type("狗类")  
            .build();  
    System.out.println(animal);  
    
    // 基于现有对象animal,创建Builder一个新的cat对象
    Animal cat = animal.toBuilder()  
		    // 只修改名字
            .name("猫") 
            // 只修改类型 
            .type("猫类")  
            .build();  
    System.out.println("cat = " + cat);  
}
  • 结果输出:
    只修改名字和类型,age和color保持不变
cat = Animal(name=, age=20, color=黄色, type=猫类)

@Builer.Default:默认值

这是新手最容易踩的坑。 在使用 @Builder 时,你在类成员变量上直接写的初始值(如 private int age = 18;)会被忽略,build() 出来的对象该属性会变成 0null

Lombok 允许你通过 @Builder.Default 注解为某些字段提供默认值。这样,当你没有为这些字段提供值时,构建器将使用默认值。

@Getter               // 1. 构建后通常只需要读取,属性应设为私有  
@ToString             // 2. 方便日志排查  
@Builder(toBuilder = true) // 3. 允许从对象实例反向创建
@AllArgsConstructor(access = AccessLevel.PRIVATE) // 4. 重点:隐藏全参构造 
public class Animal {  
    private final String name;  
    @Builder.Default  
    private final Integer age = 18;  
    private final String color;  
    private final String type;  
}

**测试 @Builder.Default **

Animal aDefault = Animal.builder().name("default").build();  
System.out.println("aDefault = " + aDefault);
  • 结果为:年龄age默认值:18
aDefault = Animal(name=default, age=18, color=null, type=null)

@Singular:集合属性

处理集合属性 (@Singular)

手写 Builder 时,处理 ListMap 非常麻烦。Lombok@Singular 注解可以让你一次只添加一个元素,且它会自动帮你处理不可变集合

@Getter               // 1. 构建后通常只需要读取,属性应设为私有  
@ToString             // 2. 方便日志排查  
@Builder(toBuilder = true) // 3. 允许从对象实例反向创建 Builder
@AllArgsConstructor(access = AccessLevel.PRIVATE) // 4. 重点:隐藏全参构造 
public class Animal {  
    private final String name;  
    @Builder.Default  
    private final Integer age = 18;  
    private final String color;  
    private final String type;  
    @Singular  
    private final List<Integer> friends;  
}

测试@Singular注解

Animal friend = Animal.builder()  
        .friends(List.of(1))  
        .friends(List.of(1,2))  
        .friends(List.of(3)).build();  
  
System.out.println("friend = " + friend);
  • 测试结果:
friend = Animal(name=null, age=18, color=null, type=null, friends=[1, 1, 2, 3])

@NonNull:字段必填

如果你使用了 Lombok,可以在字段上加上 @NonNull 注解。Lombok 会自动在生成的 Builder 方法和构造函数中插入空检查(Null Check)。

@Getter               // 1. 构建后通常只需要读取,属性应设为私有  
@ToString             // 2. 方便日志排查  
@Builder(toBuilder = true) // 3. 允许从对象实例反向创建 Builder
@AllArgsConstructor(access = AccessLevel.PRIVATE) // 4. 重点:隐藏全参构造 
public class Animal {  
    private final String name;  
    @Builder.Default  
    private final Integer age = 18;  
    private final String color;  
    private final String type;  
    @Singular  
    private final List<Integer> friends;  
  
    @NonNull  
    private final Long id;  
}

id字段为Null,报错信息为:

java.lang.NullPointerException: id is marked non-null but is null

测试@NonNull代码:

Animal animal = Animal.builder()  
        .name("狗")  
        .age(20)  
        .color("黄色")  
        .type("狗类")  
        .build();  
  
System.out.println(animal);
  • 结果为:
Animal(name=, age=20, color=黄色, type=狗类, friends=[], id=1)

九、什么时候该用 Builder?

Builder 不是“写代码更高级”的象征,也不是所有对象都值得用 Builder。
真正的问题只有一个:

👉 这个对象的“创建过程”复杂吗?

如果复杂,Builder 很合适;
如果不复杂,Builder 反而是负担。

下面我们从 「什么时候该用」「什么时候不该用」 两个方向来判断。


(一)这 5 种场景,非常适合用 Builder

1. 构造参数多(尤其是 3 个以上)

当一个类的构造参数开始变多时,构造函数的可读性会急剧下降:

new User("Tom", 18, "tom@example.com", "Beijing", true);

你很难一眼看出:

  • "Beijing" 是地址还是城市?
  • true 代表什么含义?

使用 Builder 后:

User user = User.builder()
        .name("Tom")
        .age(18)
        .email("tom@example.com")
        .city("Beijing")
        .vip(true)
        .build();

👉 参数“自带语义”,几乎不会传错。


2. 参数有明显的「可选项」

当一个对象:

  • 有些字段经常不用

  • 有些字段有默认值

  • 不同场景需要不同组合

Builder 非常合适。

User user = User.builder()
        .name("Tom")
        .build(); // 只关心必须字段

👉 不需要为了“少传几个参数”,去写多个构造函数。


3. 对象希望设计成「不可变」

如果你希望对象:

  • 字段都是 final

  • 没有 setter

  • 创建后状态不可更改

👉 Builder 几乎是标配方案。

@Builder
public class Order {
    private final String orderNo;
    private final BigDecimal amount;
}

Builder 负责“可变构建”,对象本身保持“不可变”。


4. 创建时需要做校验或约束

有些对象在创建时就需要保证合法性

  • 必填字段不能为空

  • 数值范围合法

  • 字段之间存在约束关系

public Order build() {
    if (orderNo == null) {
        throw new IllegalStateException("orderNo is required");
    }
    return new Order(orderNo, amount);
}

👉 Builder 是“创建规则的守门人”。


5. 对象创建逻辑可能演进、扩展

当你预期这个类:

  • 将来可能新增字段

  • 构造规则可能变化

  • 不想破坏已有代码

Builder 非常稳妥:

  • 新增字段 → 新增一个链式方法

  • 老代码不受影响

👉 对扩展友好,对修改不敏感。


(二)这 3 种场景,不建议用 Builder

Builder 很好,但不是银弹

1. 对象非常简单
new Point(10, 20);
  • 字段少

  • 语义明确

  • 没有可选项

👉 用 Builder 只会增加噪音。


2. 对象只是临时数据载体(DTO / VO)

如果一个类:

  • 只是用来传参

  • 没有业务约束

  • 生命周期很短

public class LoginDTO {
    public String username;
    public String password;
}

👉 用 Builder 没有明显收益。


3. 对象必须频繁、大量创建(性能敏感)

Builder 会多创建一个 Builder 实例。

在以下场景要慎用:

  • 高频循环

  • 性能极度敏感的底层代码

  • 大量对象批量创建

👉 此时简单构造函数更直接。


(三)新手最容易踩的 3 个误区

误区一:所有类都用 Builder,看起来更“高级”

❌ 错。

设计模式是为了解决问题,而不是装饰代码。


误区二:有 Builder 就不需要构造函数设计了

❌ 错。

  • 构造函数依然是“最终入口”
  • Builder 只是调用它的“代理人”

误区三:Builder = 工厂模式

❌ 错。

  • Builder 关注的是 “如何一步步构建”
  • 工厂关注的是 “创建哪一种对象”

(四)一句「使用判断口诀」(强烈建议保留)

参数多、可选多、想不可变、要校验、怕扩展 —— 用 Builder。

简单对象、临时数据、高频创建 —— 别用 Builder。


十、给新手的最终总结

Builder 模式不是为了炫技,而是为了让“复杂对象的创建”这件事变得可控、可读、可维护。

如果你只记住一句话:

Builder 管过程,build() 给结果。

这篇文章的目的就达到了。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐