Java Records深度实战:用不可变数据类重构订单系统与序列化集成全指南

2026-07-11 0 725

不久前翻看一个老项目的代码仓库,发现 `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` 和重复代码的旧世界了。

Java Records深度实战:用不可变数据类重构订单系统与序列化集成全指南
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请联系本站我们会及时删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 java Java Records深度实战:用不可变数据类重构订单系统与序列化集成全指南 https://www.taomawang.com/server/java/2350.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务