不久前翻看一个老项目的代码仓库,发现 `model` 包下面躺着将近四十个类,每个都是一样的套路:私有字段、全参构造器、Getter、Setter、`equals`、`hashCode`、`toString`。大部分由 Lombok 的 `@Data` 注解生成,少部分手写的还藏着几个因为字段增减没同步更新而导致的 `equals` 不一致的 bug。这些类九成以上都是单纯的数据载体,创建之后不会被修改,却背着一整套可变对象的沉重包袱。当时就想把它们全部换成 Java Records,但因为老项目还在用 Java 11 只能作罢。最近公司技术栈终于统一升级到了 Java 17,我立刻把订单相关的核心模型全部用 Records 重写了一遍,整个过程既踩了序列化兼容的坑,也摸索出了一些和 Spring Boot 配合的实践模式。这篇文章就把这套迁移经验和 Records 的深入用法完整呈现出来。
一、Records 到底替我们省掉了什么
传统的 JavaBean 或者 DTO,一个简单的数据类至少需要十几行代码,带上 Lombok 能缩减到几行,但依赖编译器插件总归是个隐患。Records 把这件事从语言层面解决了:
public record OrderItem(String productId, String productName, int quantity, BigDecimal price) {}
这一行代码自动生成了:所有字段的私有最终存储、全参构造器(称为规范构造器)、与字段同名的访问器方法(注意不是 `getXxx` 而是直接 `productId()`)、`equals`、`hashCode` 和 `toString`。所有生成的方法在编译期就确定下来,不会出现 Lombok 版本不兼容或 IDE 识别问题。
核心特征就两个:一是不可变性——Records 的所有字段都是 `final`,一旦创建就不能修改;二是透明性——访问器方法名直接和字段名一致,这让它在模式匹配和解构赋值(未来特性)中天然适配。对于项目中占比最大的 DTO、VO、返回结果等纯数据载体,Records 几乎是零代价的替代方案。
二、从订单系统开始:用 Records 替换传统 POJO
我们以一个简化版的订单系统为例。原来有三个核心类:`OrderCreateRequest`(下单请求)、`OrderDetailResponse`(订单详情响应)、`OrderSummaryDTO`(订单列表摘要)。每个都塞满了 Getter/Setter 和一堆注解。
改用 Records 之后,下单请求变成了:
public record OrderCreateRequest(
String customerId,
List<OrderItem> items,
String shippingAddress,
PaymentInfo paymentInfo
) {
public OrderCreateRequest {
// 紧凑构造器:在赋值前进行校验
Objects.requireNonNull(customerId, "客户ID不能为空");
if (items == null || items.isEmpty()) {
throw new IllegalArgumentException("订单必须包含至少一个商品");
}
// 防御性复制:防止外部修改传入的列表
items = List.copyOf(items);
}
}
这里出现了 Records 的一个重要特性:紧凑构造器。它的参数列表是隐式的(和 record 头部声明一致),方法体中的代码会在自动生成的赋值逻辑之前执行,所以我们可以在这里做参数校验和防御性复制。经过 `List.copyOf` 后,传入的 `items` 字段就变成了一个真正的不可变列表,外部再怎么修改原始列表也不会影响 record 内部的状态。
`PaymentInfo` 本身也可以定义为一个嵌套的 record:
public record PaymentInfo(String method, String account, BigDecimal amount) {}
`OrderDetailResponse` 和 `OrderSummaryDTO` 也照此处理:
public record OrderDetailResponse(
String orderId,
String customerId,
List<OrderItem> items,
BigDecimal totalAmount,
String status,
LocalDateTime createdAt
) {}
public record OrderSummaryDTO(
String orderId,
String customerName,
BigDecimal totalAmount,
String status,
LocalDateTime createdAt
) {}
三个类加起来不到二十行,而原来的 POJO 版本每个都要三四十行。
三、序列化兼容:Jackson 与 Spring Boot 的无缝对接
迁移过程中最大的顾虑是 JSON 序列化。项目中大量使用 Jackson,而 Jackson 默认通过 Getter 方法识别属性。Records 的访问器方法名是 `productId()` 而不是 `getProductId()`,会不会导致序列化失败?
实际测试发现,Jackson 从 2.12 版本开始已经原生支持 Records。序列化时,它直接读取 record 的访问器方法(`productId()`),生成的 JSON 字段名就是去掉小括号后的 `productId`。反序列化时,Jackson 会调用 record 的规范构造器,把 JSON 属性一一映射到构造参数上。整个过程和使用普通 POJO 的体验几乎一致。
唯一需要注意的是,如果因为历史原因 JSON 字段使用了下划线命名(如 `product_id`),可以用 `@JsonProperty` 注解调整:
public record OrderItem(
@JsonProperty("product_id") String productId,
@JsonProperty("product_name") String productName,
int quantity,
BigDecimal price
) {}
在 Spring Boot 2.5+ 和 3.x 中,控制器可以直接接收和返回 Record 类型:
@PostMapping("/orders")
public OrderDetailResponse createOrder(@RequestBody OrderCreateRequest request) {
// 业务处理
return orderService.placeOrder(request);
}
不需要额外的配置类或序列化适配器,开箱即用。
四、与数据库交互:MyBatis-Plus 和 JPA 的兼容策略
持久化层是 Records 的一个天然短板,因为 JPA 和 MyBatis 通常依赖无参构造器和 Setter 来创建和填充实体。Records 不可变且没有无参构造器,所以它们并不适合直接作为 `@Entity` 使用。
实践中,我们会把实体类和 DTO 分开。数据库实体仍然用传统的可变 POJO(带 `@Entity` 和 `@Table`),但在 Service 层和 Controller 层之间传递数据时,全部使用 Records。实体与 Record 之间的转换可以借助 MapStruct 或者手动映射:
public OrderDetailResponse toResponse(OrderEntity entity) {
return new OrderDetailResponse(
entity.getOrderId(),
entity.getCustomerId(),
entity.getItems().stream()
.map(i -> new OrderItem(i.getProductId(), i.getProductName(), i.getQuantity(), i.getPrice()))
.toList(),
entity.getTotalAmount(),
entity.getStatus(),
entity.getCreatedAt()
);
}
如果觉得手写转换代码繁琐,MapStruct 1.5+ 已经支持 Record 的映射,只需定义一个接口即可。
五、扩展 Records:实例方法与静态工厂
Records 除了自动生成的方法,还可以定义自己的实例方法和静态方法。例如在 `OrderDetailResponse` 中添加一个格式化总金额的方法:
public record OrderDetailResponse(...) {
public String formattedTotal() {
return String.format("¥%.2f", totalAmount);
}
}
也可以提供静态工厂方法来封装常见的构造逻辑:
public record OrderSummaryDTO(...) {
public static OrderSummaryDTO fromEntity(OrderEntity entity) {
return new OrderSummaryDTO(
entity.getOrderId(),
entity.getCustomerName(),
entity.getTotalAmount(),
entity.getStatus(),
entity.getCreatedAt()
);
}
}
这种方式让 Record 不仅是数据的容器,还可以携带一些就近的内聚逻辑,避免了传统贫血模型下工具类和 Service 层到处散落的尴尬。
六、不可变集合的陷阱与正确姿势
回到前面那个 `OrderCreateRequest`,我用了 `List.copyOf(items)` 来做防御性复制。这产生了一个不可变列表。如果在这之后试图修改它——比如在 Service 层里对 `items` 做 `add` 或 `sort`——会直接抛出 `UnsupportedOperationException`。
这个设计在起初迁移时让我吃了不少苦头,因为旧代码里有一处对订单商品列表做了排序,迁移后直接崩掉。解决方式有两种:一是把排序逻辑移到构造 Record 之前;二是在 Record 内部提供一个返回可变副本的方法,但后者会破坏不可变性设计原则。我的选择是:将排序放在调用方完成后再传入,保持 Record 内部始终干净。
如果必须对集合进行操作,可以这样返回一个副本:
public List<OrderItem> sortedItems() {
return items.stream().sorted(Comparator.comparing(OrderItem::productName)).toList();
}
`toList()` 返回的也是一个不可变列表,既能满足业务需求,又不会破坏原始数据的不可变性。
七、Records 的局限与适用边界
Records 不是万能药。它明确的设计意图是“名义元组”——有名字的数据聚合体。以下是它不太适合的场景:
- 需要对字段进行大量修改的对象(如累计计算中的状态对象),Records 的不可变性反而会增加代码复杂度。
- 需要继承层次的数据模型。Records 隐式继承 `java.lang.Record`,不能再继承其他类,不过可以实现接口。
- JPA 实体类,目前仍建议使用传统 POJO。
- 字段数量特别多(比如超过十几个)的类,此时规范构造器的参数列表会显得冗长,但可以通过 Builder 模式或者拆分组合 Record 来解决。
除此之外的大多数数据承载场景——DTO、VO、DTO、配置对象、缓存键、返回值包装——Records 都能完美胜任。
八、迁移后的实际收益
整个订单模型迁移完成后,`model` 包下的类数量从 38 个减少到了 14 个(其中大部分是数据库实体)。代码行数减少了约 60%,Lombok 依赖从项目中彻底移除。更重要的是,不可变性消除了一个常见的隐患:在不同 Service 之间传递 DTO 时,某个方法偷偷改了字段导致下游逻辑异常。现在每当看到 `OrderDetailResponse`,就知道它里面的数据从出生那一刻起就不会再变,调试时可以放心地排除“谁改了这个字段”的疑问。
测试方面也受益不少。因为 Records 自动实现了 `equals` 和 `hashCode`,在断言期望结果时不需要手动比较每个字段,一个 `assertEquals` 就能验证整个对象。
九、总结
Records 是 Java 语言演进中一次非常务实的改进。它没有引入颠覆性的新概念,而是把开发者做了十几年的一件事——写数据载体类——变成了编译器自动完成的工作。对于任何还在使用 Lombok 或者手写 Getter/Setter 的项目,升级到 Java 17+ 后把 DTO 层换成 Records 是一个低风险、高收益的重构动作。迁移过程可以从最外层的响应对象开始,逐步向内部推进,数据库实体暂时保留原样。每替换掉一个类,项目中就少了一个可能因可变性引发的 bug。
如果你的团队还在犹豫要不要用 Records,不妨先用它写几个简单的 DTO,感受一下一行代码定义一个完整数据对象带来的轻快感。一旦习惯了这种写法,大概很难再回到那个充斥着 `@Data` 和重复代码的旧世界了。

