Java Records: 何时应该使用以及何时不应该使用
1. 总览
在Java 14之前,要有一个只包含不可变数据的类,你必须自己写constructor、getter、equals()、hashCode()、toString() — 全是重复的样板代码。Record解决这个问题:你声明一行,Java自动生成所有代码。
本文介绍record的基本语法,如何自定义验证,与普通class和Lombok的对比,然后明确指出何时应该使用record,何时不适合。读完后,你就会知道如何在实际的Spring Boot应用中正确使用record。
2. Record是什么
Record是一种特殊的类,用来对不可变数据进行建模。声明一个record相当于定义它的“组件”:
public record Point(int x, int y) {
}
仅凭这一行,编译器就会自动生成:
- 接受
x、y的constructor。 - accessor方法
x()和y()(没有get前缀)。 - 基于所有组件的
equals()和hashCode()。 - 格式为
Point[x=1, y=2]的toString()。
Point p1 = new Point(1, 2);
Point p2 = new Point(1, 2);
System.out.println(p1);
System.out.println(p1.equals(p2));
System.out.println(p1.x());
Point[x=1, y=2]
true
1
注意: Record自动成为final — 不能继承record,其field也是final,不能在初始化后重新赋值。
3. Record的组件
3.1. Constructor和验证
Record有自动生成的canonical constructor,但你可以重写它来添加验证。这叫做compact constructor — 不需要重新声明参数:
public record Order(String id, BigDecimal amount) {
public Order {
if (amount == null || amount.signum() < 0) {
throw new IllegalArgumentException("金额必须 >= 0");
}
}
}
这行public Order { ... }没有参数列表 — 它与record的参数共享。验证在field被赋值之前运行,所以你可以在创建对象时立即阻止错误的数据。
new Order("O1", new BigDecimal("-10"));
Exception in thread "main" java.lang.IllegalArgumentException: 金额必须 >= 0
3.2. 添加自定义方法
Record仍然可以包含method、static field和static method,就像普通class一样:
public record Money(String currency, BigDecimal value) {
public Money add(Money other) {
if (!currency.equals(other.currency)) {
throw new IllegalStateException("货币类型不同");
}
return new Money(currency, value.add(other.value));
}
public static Money zero(String currency) {
return new Money(currency, BigDecimal.ZERO);
}
}
因为value是final,add()方法不会修改当前对象 — 它返回一个新的Money。这正是record提倡的不可变精神。
3.3. 实现interface
Record可以实现interface(但不能继承其他class):
public interface Shape {
double area();
}
public record Rectangle(double width, double height) implements Shape {
@Override
public double area() {
return width * height;
}
}
4. Spring Boot中的Record:DTO和request/response
Record最常见的用途是DTO — 通过REST API传入/传出的数据。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
public record CustomerRequest(
@NotBlank String name,
@Email String email
) {
}
@RestController
@RequestMapping("/customers")
public class CustomerController {
@PostMapping
public ResponseEntity<CustomerResponse> create(@Valid @RequestBody CustomerRequest request) {
Customer saved = new Customer(1L, request.name(), request.email());
return ResponseEntity.ok(new CustomerResponse(saved.id(), saved.name()));
}
}
public record CustomerResponse(Long id, String name) {
}
Jackson(Spring Boot已预配置)通过canonical constructor将JSON反序列化为record,不需要setter。Bean Validation注解(@NotBlank、@Email)直接放在record的参数上仍然有效,因为Spring会自动识别constructor上的注解。
POST /customers
{"name": "", "email": "a@b.com"}
HTTP 400 Bad Request
注意: 如果record用作JPA entity,你会遇到问题 — JPA需要无参constructor和可变field来支持lazy loading的代理。Record不支持这一点,所以不要将record用作
@Entity。
5. Record与普通class的对比
| 标准 | 普通class | Record |
|---|---|---|
| 样板代码 | 自己写getter、equals、hashCode、toString |
自动生成 |
| 可变性 | 可以 | 不可以(field总是final) |
| 继承 | 可以继承其他class | 不能继承(隐式final) |
| 适合场景 | Entity、状态会改变的class | DTO、value object、不可变数据 |
通过比较同一个value object FullName的例子:
// 旧的写法
public final class FullNameOld {
private final String first;
private final String last;
public FullNameOld(String first, String last) {
this.first = first;
this.last = last;
}
public String getFirst() { return first; }
public String getLast() { return last; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof FullNameOld other)) return false;
return Objects.equals(first, other.first) && Objects.equals(last, other.last);
}
@Override
public int hashCode() {
return Objects.hash(first, last);
}
@Override
public String toString() {
return "FullNameOld[first=" + first + ", last=" + last + "]";
}
}
// 用record写法
public record FullName(String first, String last) {
}
这两个class在行为上等价,但record简洁得多,也不容易在手写equals/hashCode时出错(这是经典的bug)。
6. Record与Lombok @Value的对比
很多团队在有record之前用Lombok @Value来减少样板代码。这两种方式解决类似的问题,但本质不同:
// Lombok
@Value
public class ProductDto {
String sku;
BigDecimal price;
}
// Record
public record ProductDto(String sku, BigDecimal price) {
}
主要区别:
- Record是Java的原生功能,不需要额外依赖,不需要注解处理器。
- Record生成的accessor方法是
sku()(没有get),而Lombok生成getSku()。 - Record不支持继承或通过builder pattern轻松添加可选field,而Lombok的
@Builder在这方面更灵活(有许多可选field时)。
注意: 如果项目已广泛使用Lombok且需要
@Builder作为多field的DTO,可以保留Lombok用于那些情况。当DTO简单、field少、不需要builder时,record更合适。
7. 何时应该使用record
当数据本身具有以下特征时,使用record:
- 本质上是不可变的:坐标、金额范围、时间范围、计算结果。
- 在各层之间传输的DTO:API的request/response、查询返回的数据、通过队列发送的消息。
- domain model中的value object:
Money、EmailAddress、Coordinates— 那些按值比较而不是按identity比较的东西。 - 逻辑处理中的临时结果:收集多个返回值但不想创建繁琐的class时。
public record ValidationResult(boolean valid, List<String> errors) {
public static ValidationResult ok() {
return new ValidationResult(true, List.of());
}
}
8. 何时不应该使用record
8.1. JPA entity
如第4节所述,JPA需要entity可变、有无参constructor,以及支持lazy loading的代理。Record无法满足这些需求。
// 不应该这样做
@Entity
public record Customer(@Id Long id, String name) {
}
Hibernate在尝试为record创建代理时会出错或产生意外行为。对于entity,应该使用普通class(或Lombok @Entity + @Getter/@Setter)。
8.2. 需要频繁改变状态的对象
如果对象的状态在生命周期中需要多次改变(比如ShoppingCart频繁添加/删除item),强制使用record意味着每次修改都要创建新对象 — 浪费资源且不自然。
// 不合理:每次add item时创建新的cart
public record ShoppingCart(List<Item> items) {
public ShoppingCart addItem(Item item) {
List<Item> newItems = new ArrayList<>(items);
newItems.add(item);
return new ShoppingCart(newItems);
}
}
这种情况下,一个普通的可变class配合addItem()方法直接修改List会更自然和高效。
8.3. 需要继承
Record隐式是final的,不能继承其他class。如果domain model需要类层次结构(Employee → Manager、Developer),record不合适 — 应该使用普通class或通过sealed interface/class配合record作为叶子节点。
public sealed interface Shape permits Circle, Square {}
public record Circle(double radius) implements Shape {}
public record Square(double side) implements Shape {}
这是在层次结构中使用record的合理方式:record作为不可变的叶子节点,sealed interface管理可能的子类型。
8.4. 需要复杂的序列化
Record可以实现Serializable,但如果需要复杂的自定义序列化(版本控制、多年的向后兼容性),普通class具有更细粒度的控制仍然更安全。
9. Record的测试
因为equals()和hashCode()是基于组件自动生成的,用AssertJ进行测试非常简单:
class MoneyTest {
@Test
void shouldBeEqualWhenSameValues() {
Money a = new Money("VND", new BigDecimal("1000"));
Money b = new Money("VND", new BigDecimal("1000"));
assertThat(a).isEqualTo(b);
assertThat(a.hashCode()).isEqualTo(b.hashCode());
}
@Test
void shouldThrowWhenAddingDifferentCurrency() {
Money vnd = new Money("VND", BigDecimal.TEN);
Money usd = new Money("USD", BigDecimal.ONE);
assertThatThrownBy(() -> vnd.add(usd))
.isInstanceOf(IllegalStateException.class)
.hasMessage("货币类型不同");
}
}
assertThat(a).isEqualTo(b)验证了自动生成的equals()行为 — 两个组件值相同的record必须相等,尽管它们是memory中的不同实例。
10. 总结
Record是Java为不可变数据提供的最好工具:代码少、bug少、意图清晰。用它处理DTO、value object和临时返回结果。不要用于JPA entity、频繁改变状态的对象或需要通过继承实现复杂class hierarchy的场景。为正确的任务选择正确的工具 — record不是普通class的完全替代品,而是对一类特定问题的更简洁的补充。