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。对于小类,构造函数或静态工厂仍然是更好的选择。