「为什么这个 DTO 里的字段改不动了」
1 月底,一个新同事在群里问:他给订单查询的返回 DTO 加了个 @Data,然后用 Jackson 反序列化的时候报了一堆奇怪的错,改成 record 之后就好了,问我能不能把所有 DTO 都换成 record。
我当时的回答是:DTO 可以用,但不是所有。我们新服务跑在 JDK 17 上,record 已经是正式特性(Java 16 转正)。
record 编译出来是什么
写一行:
public record Money(long amount, String currency) {}
javap 反编译看看编译器生成了什么:
$ javap -p Money.class
public final class Money extends java.lang.Record {
private final long amount;
private final java.lang.String currency;
public Money(long, java.lang.String);
public java.lang.String toString();
public final int hashCode();
public final boolean equals(java.lang.Object);
public long amount();
public java.lang.String currency();
}
五个编译器生成的东西:私有 final 字段、全参构造器、equals / hashCode / toString,以及与字段同名的访问器。
注意最后那两个:访问器是 amount() 而不是 getAmount()。这是个取舍——record 不遵循 JavaBean 规范,好处是命名简洁,代价是很多依赖 getter 约定的框架要额外适配。
还有两个隐含约束:
- 类是
final的,不能被继承,也不能继承别的类(隐式继承java.lang.Record)。 - 字段是
final的,构造之后不可变。可以声明静态字段,但不能声明额外的实例字段(可以有静态方法和实例方法)。
跟 Lombok 的对比
先说结论:它们解决的是不同层面的问题,不存在谁取代谁。
| 对比项 | record | Lombok @Value / @Data |
|---|---|---|
| 实现方式 | 语言特性,编译器生成 | 注解处理器,改 AST |
| 不可变 | 强制,字段隐式 final | @Value 强制;@Data 可变 |
| 访问器 | amount() | getAmount() |
| 无参构造器 | 没有,需手写 | @NoArgsConstructor 可生成 |
| Builder | 没有,需手写 | @Builder 直接生成 |
| 部分字段可变 | 不行 | 可以 |
| IDE / 工具链依赖 | 无 | 需要装插件、配置注解处理器 |
| JDK 版本 | 16+ | 8 也能用 |
我最看重第一行的差别。Lombok 是注解处理器改写 AST,本质上是编译器行为的 hack。它的好处是能用新语法写老版本 JDK,代价是:IDE 必须装插件、每次升级 JDK 或 javac 都可能踩到兼容问题、生成的代码你在源码里看不见、debug 时行号对不上。我就遇到过一次 Lombok 1.18.16 在 JDK 16 上编译失败,最后是升级到 1.18.20 解决的。
record 是语言层面的东西,编译器保证语义,不存在兼容问题。
紧凑构造器:做校验的地方
如果要在构造时做参数校验,用紧凑构造器(compact constructor),不用重复声明参数列表:
public record Money(long amount, String currency) {
public Money { // 没有参数列表
if (amount < 0) {
throw new IllegalArgumentException("金额不能为负: " + amount);
}
if (currency == null || currency.length() != 3) {
throw new IllegalArgumentException("币种必须是三位代码: " + currency);
}
currency = currency.toUpperCase(); // 可以重新赋值给参数,末尾自动赋给字段
}
}
这个语法看着别扭,其实很好理解:紧凑构造器的参数就是那几个字段,你在方法体里对参数做的修改,在构造器末尾会被自动赋给对应字段。所以上面的 currency = currency.toUpperCase() 会生效,不需要也不能写 this.currency = ...(此时字段还没初始化)。
如果需要保留全参构造器的完整形式(比如要调用 this(...) 委托),就得写全:
public record Money(long amount, String currency) {
public Money(long amount) {
this(amount, "CNY"); // 委托构造
}
public Money(long amount, String currency) {
// 完整形式,此时不能再用紧凑形式
if (amount < 0) throw new IllegalArgumentException();
this.amount = amount;
this.currency = currency;
}
}
DTO 场景实测:Jackson、校验、MyBatis
我们用的是 Spring Boot 2.6.3,内置的 Jackson 是 2.13.1。我逐个试了一遍。
Jackson 反序列化:支持,但要注意版本
public record OrderQuery(Long merchantId, String status, LocalDate start, LocalDate end) {}
@PostMapping("/orders")
public List<Order> query(@RequestBody OrderQuery query) { ... }
curl -X POST http://localhost:8080/orders \
-H 'Content-Type: application/json' \
-d '{"merchantId":882341,"status":"PAID","start":"2022-01-01","end":"2022-01-31"}'
Jackson 从 2.12.0 开始原生支持 record,它用构造器参数的名字做绑定,不需要 @JsonCreator 也不需要 @JsonProperty。前提是编译的时候带上 -parameters:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<release>17</release>
<parameters>true</parameters> <!-- 关键 -->
</configuration>
</plugin>
Spring Boot 的 spring-boot-starter-parent 已经默认开了 -parameters,所以大部分项目不用管。
2.12 以下会报:
InvalidDefinitionException: No serializer found for class OrderQuery and no properties discovered
Bean Validation:可以用,注解要加对地方
public record OrderQuery(
@NotNull Long merchantId,
@NotBlank String status,
@NotNull LocalDate start,
@NotNull LocalDate end) {}
写在组件(component)声明上的注解,默认会被传播到字段、构造器参数、访问器方法(取决于注解的 @Target)。Hibernate Validator 6.2 之后对 record 支持正常。我用 @Valid @RequestBody 测过,校验能触发。
有一个坑:如果你的注解 @Target 里没有 PARAMETER,写在 record 组件上会报编译错误。这时候要显式指定传播目标:
public record OrderQuery(
@NotNull(groups = UpdateGroup.class) Long merchantId) {
// 注解目标是 METHOD/FIELD 时,需要写
// @NotNull(groups = UpdateGroup.class) private Long merchantId; // 这样写不允许
}
MyBatis:需要显式构造器映射
这是最难受的一块。MyBatis 默认用无参构造 + setter 或者字段反射填充,record 两者都没有。我试了三种写法,可行的是这种:
<resultMap id="orderMap" type="com.xxx.vo.OrderItemVo">
<constructor>
<idArg column="id" javaType="java.lang.Long"/>
<arg column="sku_name" javaType="java.lang.String"/>
<arg column="qty" javaType="java.lang.Integer"/>
</constructor>
</resultMap>
顺序必须和 record 的组件顺序完全一致,改字段顺序的时候极易出错。我后来干脆放弃了在 MyBatis 查询结果上用 record,DAO 层还是用普通类,只在 Controller 层的出入参用。
JPA 实体:不行
这个一定要记住。JPA 实体要求类非 final、字段非 final、有无参构造器,record 三条全违反。Hibernate 官方文档明确说不支持。
但 @Embeddable 值对象和 DTO 投影是可以的:
@Embeddable
public record Address(String province, String city, String detail) {}
// Spring Data JPA 的投影也能用
public interface OrderRepository extends JpaRepository<Order, Long> {
List<OrderSummary> findSummaryByMerchantId(Long merchantId);
}
public record OrderSummary(Long id, String status, Long totalAmount) {}
局部 record:临时组合数据很好用
Java 16 起可以在方法内部声明 record,配合 Stream 用起来很顺手:
var stat = orders.stream()
.collect(Collectors.groupingBy(Order::getSkuId, Collectors.summingInt(Order::getQty)))
.entrySet().stream()
.map(e -> new SkuStat(e.getKey(), e.getValue())) // SkuStat 是局部 record
.sorted(Comparator.comparing(SkuStat::qty).reversed())
.limit(10)
.toList();
record SkuStat(Long skuId, Integer qty) {} // 方法内声明
我们的落地约定
最后在团队里定了几条:
- Controller 的请求体、响应体:优先
record。不可变、代码短、Jackson 支持良好。 - 跨服务调用的 RPC 出入参:
record。Dubbo 3.0 和 Feign 都能正常序列化。 - MyBatis 的查询结果映射:不用
record,理由如上。 - JPA 实体:不用。值对象和投影可以用。
- 需要
@Builder或者字段要可变的:继续用 Lombok。
改造之后,order-api 模块的 DTO 代码行数从 1840 行降到 612 行,Lombok 的 @Data 从 47 处减少到 12 处。
小结
record编译后是final class Xxx extends java.lang.Record,自动生成私有 final 字段、全参构造器、equals/hashCode/toString和同名的访问器(amount()而非getAmount())。- 不可继承、不可加实例字段。这两条决定了它做不了 JPA 实体。
- 紧凑构造器不写参数列表,对参数赋值会在末尾自动写入字段,是做校验和归一化的地方。
- Jackson 需要 2.12.0+ 才支持 record 反序列化;低于这个版本会报
No serializer found。 - Bean Validation 正常,注解写在组件上;MyBatis 需要
<constructor>映射且顺序严格一致,我实际用下来觉得不值当;JPA 实体不行,@Embeddable和投影可以。 - 跟 Lombok 不是替代关系。
record胜在语言级、无工具链依赖;Lombok 胜在灵活(可变字段、Builder、无参构造)和支持 JDK 8。