Administrator
发布于 2022-01-28 / 5873 阅读
104

Record 类型与不可变数据建模实践

「为什么这个 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 的对比

先说结论:它们解决的是不同层面的问题,不存在谁取代谁。

对比项recordLombok @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。

参考