Java 中多参数对象的 Builder Pattern

1. 概述

当一个类有六个、七个参数,其中一些是可选的,普通构造函数很快就变得难以阅读且容易传错参数。Builder Pattern 解决了这个问题。你可以明确地为每个参数命名,设置默认值,并在一个唯一的地方进行验证。

本文分为四个部分。首先是多参数构造函数的问题。然后你将自己编写一个完整的 Builder 并使用 JUnit 5 测试它。最后是如何使用 Lombok @Builder 以及如何在 Java 21 中将 Builder 与 record 结合使用。

2. 多参数构造函数的问题

2.1. 伸缩式构造函数

假设你有一个 Order 类,其中包含字段:customerId、项目列表、shippingAddress、couponCode、giftWrap 和 priority。常见的做法是编写多个重载构造函数,每个构造函数添加一个参数。这种方式称为 伸缩式构造函数:

Order order = new Order("C001", List.of("SKU-1"), null, null, true, 0);

读这一行,你很难知道 true 是什么,0 是什么。更糟的是,shippingAddress 和 couponCode 都是 String 类型。如果交换这两个参数的位置,代码仍然会正常编译,错误只会在运行时才暴露。

2.2. JavaBeans 和 setter

第二种方式是使用空构造函数并调用 setter:

Order order = new Order();
order.setCustomerId("C001");
order.setGiftWrap(true);
// 忘记调用 setItems(...) — 编译器不报错

代码更易读,但有三个缺点:

  • 对象在各个 setter 调用之间处于“半成品”状态。
  • 无法声明字段为 final,所以对象不是不可变的。
  • 没有自然的地方来检查字段之间的约束。

Builder 保留了 setter 易读的优点,并克服了上述所有三个缺点。

3. 自己实现 Builder

3.1. 基本结构

Builder 通常是一个 static 嵌套类。它包含外部类字段的副本,有返回自身的方法(return this)用于链式调用,以及一个 build() 方法来创建最终对象:

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

public final class Order {

    private final String customerId;
    private final List<String> items;
    private final String shippingAddress;
    private final String couponCode;
    private final boolean giftWrap;
    private final int priority;

    private Order(Builder builder) {
        this.customerId = builder.customerId;
        this.items = List.copyOf(builder.items);
        this.shippingAddress = builder.shippingAddress;
        this.couponCode = builder.couponCode;
        this.giftWrap = builder.giftWrap;
        this.priority = builder.priority;
    }

    public static Builder builder(String customerId) {
        return new Builder(customerId);
    }

    public String getCustomerId() { return customerId; }
    public List<String> getItems() { return items; }
    public String getShippingAddress() { return shippingAddress; }
    public String getCouponCode() { return couponCode; }
    public boolean isGiftWrap() { return giftWrap; }
    public int getPriority() { return priority; }

    @Override
    public String toString() {
        return "Order[customerId=%s, items=%s, address=%s, coupon=%s, giftWrap=%s, priority=%d]"
                .formatted(customerId, items, shippingAddress, couponCode, giftWrap, priority);
    }

    public static final class Builder {
        private final String customerId;
        private final List<String> items = new ArrayList<>();
        private String shippingAddress;
        private String couponCode;
        private boolean giftWrap = false;
        private int priority = 0;

        private Builder(String customerId) {
            this.customerId = Objects.requireNonNull(customerId, "customerId 不能为 null");
        }

        public Builder item(String sku) {
            this.items.add(Objects.requireNonNull(sku));
            return this;
        }

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

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

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

        public Builder priority(int priority) {
            this.priority = priority;
            return this;
        }

        public Order build() {
            if (items.isEmpty()) {
                throw new IllegalStateException("订单必须至少有一个项目");
            }
            if (priority < 0 || priority > 5) {
                throw new IllegalStateException("priority 必须在 0..5 范围内");
            }
            return new Order(this);
        }
    }
}

上面代码中需要注意的几点:

  • Order 的构造函数是 private。 创建 Order 的唯一方式是通过 Builder,所以每个对象都已在 build() 中进行了检查。
  • 必需参数位于 builder(customerId)。 你不能忘记 customerId,因为缺少它代码就无法编译。只把真正必需的参数放在这里,最多一两个。如果更多的话,你就又回到了伸缩式构造函数的问题。
  • 默认值位于 Builder 中(giftWrap = false,priority = 0),而不是分散在调用处。
  • List.copyOf(builder.items) 创建一个不可修改的副本。如果 builder 在 build() 之后继续使用,已创建的 Order 仍然不会受到影响。
  • 所有约束都位于 build() 中。 这是检查涉及多个字段的条件的合理位置。

3.2. 使用 Builder

public class OrderDemo {
    public static void main(String[] args) {
        Order order = Order.builder("C001")
                .item("SKU-1")
                .item("SKU-2")
                .shippingAddress("12 Lý Thường Kiệt, Hà Nội")
                .giftWrap(true)
                .build();

        System.out.println(order);
    }
}

每个值都带有它的名称,所以你不需要记住参数的顺序。couponCode 和 priority 没有被调用,所以它们获得默认值:

Order[customerId=C001, items=[SKU-1, SKU-2], address=12 Lý Thường Kiệt, Hà Nội, coupon=null, giftWrap=true, priority=0]

如果你忘记调用 item(...),build() 立即抛出错误:

Exception in thread "main" java.lang.IllegalStateException: 订单必须至少有一个项目

错误在创建对象时立即出现,而不是在某个服务遇到空列表后的三层之后。

4. 测试 Builder

你应该测试三种行为:默认值、验证和已构建对象的不可变性。如果项目还没有,请添加测试依赖。对于 Spring Boot,spring-boot-starter-test 已经包含了 JUnit 5 和 AssertJ:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class OrderBuilderTest {

    @Test
    void givenOnlyRequiredFields_whenBuild_thenDefaultsApplied() {
        Order order = Order.builder("C001").item("SKU-1").build();

        assertThat(order.isGiftWrap()).isFalse();
        assertThat(order.getPriority()).isZero();
        assertThat(order.getCouponCode()).isNull();
    }

    @Test
    void givenNoItems_whenBuild_thenThrowsException() {
        assertThatThrownBy(() -> Order.builder("C001").build())
                .isInstanceOf(IllegalStateException.class)
                .hasMessageContaining("至少有一个项目");
    }

    @Test
    void givenInvalidPriority_whenBuild_thenThrowsException() {
        assertThatThrownBy(() -> Order.builder("C001").item("SKU-1").priority(9).build())
                .isInstanceOf(IllegalStateException.class);
    }

    @Test
    void givenBuilderReused_whenAddMoreItems_thenBuiltOrderUnchanged() {
        Order.Builder builder = Order.builder("C001").item("SKU-1");
        Order first = builder.build();

        builder.item("SKU-2");

        assertThat(first.getItems()).containsExactly("SKU-1");
        assertThatThrownBy(() -> first.getItems().add("SKU-3"))
                .isInstanceOf(UnsupportedOperationException.class);
    }
}

最后一个测试是最重要的。如果你用 this.items = builder.items 替换 List.copyOf(...),first.getItems() 将包含 SKU-2,测试将失败。这是自己编写 Builder 时常见的错误。

5. 使用 Lombok @Builder

手动编写 Builder 相当冗长。实际上,许多团队使用 Lombok 来生成此代码。按如下方式添加依赖(Spring Boot parent 已管理版本):

<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>
import lombok.Builder;
import lombok.Getter;
import lombok.NonNull;
import lombok.Singular;
import lombok.ToString;

import java.util.List;

@Getter
@ToString
@Builder(toBuilder = true)
public class Customer {

    @NonNull
    private final String email;

    private final String fullName;

    @Builder.Default
    private final String tier = "STANDARD";

    @Singular
    private final List<String> tags;
}
Customer customer = Customer.builder()
        .email("lan@example.com")
        .fullName("Nguyễn Thị Lan")
        .tag("newsletter")
        .tag("early-adopter")
        .build();

Customer vip = customer.toBuilder().tier("VIP").build();

System.out.println(customer);
System.out.println(vip);
Customer(email=lan@example.com, fullName=Nguyễn Thị Lan, tier=STANDARD, tags=[newsletter, early-adopter])
Customer(email=lan@example.com, fullName=Nguyễn Thị Lan, tier=VIP, tags=[newsletter, early-adopter])

每个注解负责一项工作:

  • @Builder 生成 Customer.builder() 方法、各个值设置方法和 build() 方法。
  • @NonNull 在构造函数中生成 null 检查。如果缺少 email,build() 会抛出 NullPointerException。
  • @Builder.Default 保留初始化值 "STANDARD"。如果缺少这个注解,Lombok 会忽略初始化值,tier 将是 null。
  • @Singular 生成 tag(...) 方法来添加单个元素。Lombok 在构建时也会自动创建不可修改的列表。
  • toBuilder = true 允许从现有对象创建修改后的副本。这种方式在处理不可变对象时非常方便。

注意: Lombok 没有简单的地方来进行多字段验证,就像自己编写的 build() 方法一样。如果需要复杂的约束,你可以自己声明带有检查的构造函数并在该构造函数上放置 @Builder,或者回到手动编写的方式。

如果使用 Jackson 将 JSON 反序列化到带有 @Builder 的类中,你需要添加 @Jacksonized。缺少它,Jackson 不知道如何创建对象,因为该类没有无参构造函数。

6. Builder 与 record 结合

从 Java 16 开始,record 是不可变数据类的自然选择。当 record 有许多可选字段时,你仍然可以添加 Builder。验证放在紧凑构造函数中,所以即使有人直接调用构造函数,它也会运行:

import java.math.BigDecimal;
import java.util.Objects;

public record PaymentRequest(String orderId, BigDecimal amount, String currency, String note) {

    public PaymentRequest {
        Objects.requireNonNull(orderId, "orderId");
        if (amount == null || amount.signum() <= 0) {
            throw new IllegalArgumentException("amount 必须大于 0");
        }
        currency = currency == null ? "VND" : currency;
    }

    public static Builder builder() {
        return new Builder();
    }

    public static final class Builder {
        private String orderId;
        private BigDecimal amount;
        private String currency;
        private String note;

        public Builder orderId(String orderId) { this.orderId = orderId; return this; }
        public Builder amount(BigDecimal amount) { this.amount = amount; return this; }
        public Builder currency(String currency) { this.currency = currency; return this; }
        public Builder note(String note) { this.note = note; return this; }

        public PaymentRequest build() {
            return new PaymentRequest(orderId, amount, currency, note);
        }
    }
}
PaymentRequest request = PaymentRequest.builder()
        .orderId("ORD-42")
        .amount(new BigDecimal("150000"))
        .build();

System.out.println(request);
PaymentRequest[orderId=ORD-42, amount=150000, currency=VND, note=null]

通过这种方式,Builder 只负责易读性部分,而 record 负责不可变性和验证部分。两个职责被清晰地分离。如果你想更简洁,Lombok @Builder 也可以直接放在 record 上。

7. 常见错误和何时不应该使用

常见错误:

  • 可变集合泄漏。 直接将 builder 的列表赋给对象会导致“不可变”对象仍然可以被修改。始终使用 List.copyOf、Set.copyOf 或 Map.copyOf。
  • 忘记调用 build()。 你会收到 Builder 而不是 Order。编译器通常会捕捉这个错误,因为类型不匹配,但在泛型代码或使用 var 时,错误可能会漏过。
  • 验证分散各处。 在 builder 的每个 setter 中检查不会捕捉到字段之间的约束。请将检查集中在 build() 中或紧凑构造函数中。
  • 在多个线程之间共享 builder。 Builder 是可变对象,不是线程安全的。每次创建对象时,都要创建一个新的 builder。
  • 在 JPA entity 上使用 @Builder。 Hibernate 需要无参数构造函数。单独使用 @Builder 会移除该构造函数,所以你必须添加 @NoArgsConstructor(access = AccessLevel.PROTECTED) 和 @AllArgsConstructor。总的来说,应该限制在 entity 中使用 Builder。

注意: 不要对只有两三个必需参数的类使用 Builder,例如 Money(amount, currency)。构造函数或静态工厂方法如 Money.of(amount, currency) 更简洁。当所有参数都是必需时,Builder 也不合适,因为它会将“缺少参数”的错误从编译时转到运行时。

简单规则:当类有四个或更多参数,大多数是可选的,或有许多相同类型的参数容易传错时,考虑使用 Builder。

8. 结论

Builder Pattern 帮助以易读的方式创建具有许多参数的对象,具有清晰的默认值和唯一的验证点。自己编写 Builder 时,保持构造函数为 private,将必需参数放在 builder(...) 方法中,复制集合,并在 build() 中检查约束。

Lombok @Builder 帮助你删除重复代码,但要记住 @Builder.Default 和 @Singular。使用 Java 21,将 record 和 Builder 结合,你可以获得带有紧凑构造函数中即时验证的不可变对象。最后,只有当参数数量确实造成困难时才使用 Builder。对于小类,构造函数或静态工厂仍然是更好的选择。

design-patternjavabuilderlombokclean-code