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的完全替代品,而是对一类特定问题的更简洁的补充。

javarecordsjava-21spring-bootbest-practices