Java Record 与 Sealed Class 实战——Java 17+ 新语法的工程价值
Java Record 与 Sealed Class 实战——Java 17+ 新语法的工程价值
适读人群:还没用上 Java 17+ 新特性的工程师 | 阅读时长:约13分钟 | 核心价值:Record 和 Sealed Class 的实际应用场景,不讲虚的
前阵子帮一个朋友看代码,他们的项目还在 Java 11,但有计划升到 Java 17。他问我:"Java 17 有什么特性是真的有价值的,不是花架子?"
我不假思索地说了两个:Record 和 Sealed Class。
这两个特性我在实际项目里用了差不多一年,感受是:它们单独看起来都是"小特性",但对代码的结构性影响挺显著的,特别是在领域对象和接口设计上。
一、Record:不只是少写 getter/setter
很多人对 Record 的第一印象是:"哦,就是 Lombok 的 @Data,少写点代码。"
这个理解不完整。
Record 和 @Data 的本质区别:Record 是不可变的,所有字段只能在构造时赋值,没有 setter。
// 定义一个 Record
public record UserVO(Long id, String username, String email) {}
// 使用
UserVO user = new UserVO(1L, "zhangsan", "zhang@example.com");
user.username(); // 访问字段,注意是 username(),不是 getUserName()
// user.username = "newname"; // 编译错误!不可变不可变有什么意义?
- 线程安全:不可变对象天然是线程安全的,不需要同步
- 防止意外修改:VO 对象在 Service 和 Controller 之间传递时,不会被某个地方意外修改
- 值语义:Record 自动生成
equals()和hashCode(),基于所有字段的值,而不是引用
UserVO a = new UserVO(1L, "zhang", "zhang@example.com");
UserVO b = new UserVO(1L, "zhang", "zhang@example.com");
System.out.println(a.equals(b)); // true!值相等就相等,不像普通类
System.out.println(a == b); // false(不同对象)二、Record 的实际应用场景
场景1:VO/DTO 对象
// 查询结果的 VO
public record UserDetailVO(
Long id,
String username,
String email,
String departmentName,
LocalDateTime createdAt
) {
// Record 可以有方法
public String getDisplayName() {
return username + " (" + departmentName + ")";
}
// 可以有自定义构造器(compact constructor)做参数校验
public UserDetailVO {
Objects.requireNonNull(id, "id cannot be null");
if (username == null || username.isBlank()) {
throw new IllegalArgumentException("username cannot be blank");
}
// 注意:compact constructor 不需要显式赋值,编译器自动处理
}
}场景2:函数的多返回值
Java 没有元组,以前想返回多个值要么创建一个专门的类,要么返回 Map/Object[](都不好)。Record 解决了这个问题:
// 查询操作的结果:数据 + 总数(分页用)
public record PageResult<T>(List<T> data, long total, int page, int size) {
public int totalPages() {
return (int) Math.ceil((double) total / size);
}
public boolean hasMore() {
return (long) page * size < total;
}
}
// 使用
public PageResult<UserVO> findUsers(int page, int size) {
List<UserVO> data = userRepository.findPage(page, size);
long total = userRepository.count();
return new PageResult<>(data, total, page, size);
}场景3:事件对象
// 领域事件,通常是不可变的
public record UserRegisteredEvent(
Long userId,
String username,
String email,
LocalDateTime occurredAt
) {
public UserRegisteredEvent(Long userId, String username, String email) {
this(userId, username, email, LocalDateTime.now());
}
}场景4:配置类
// 读取配置的值对象
public record DatabaseConfig(
String url,
String username,
int maxPoolSize,
Duration connectionTimeout
) {}三、Record 的局限性
Record 不是万能的:
- 不能继承其他类(可以实现接口)
- 所有字段必须在头部声明,不能有额外的实例字段
- Jackson 默认不支持(需要加
jackson-databind2.12+ 或配置@JsonProperty)
// 和 Jackson 搭配,需要加 @JsonProperty 或用 record-style 注解
@JsonDeserialize
public record CreateUserRequest(
@JsonProperty("username") String username,
@JsonProperty("email") String email
) {}
// 或者用构造器注解(Jackson 2.12+)
public record CreateUserRequest(String username, String email) {
@JsonCreator
public CreateUserRequest {}
}Spring Boot 2.7+ 的 Jackson 默认支持 Record 反序列化,不用额外配置。
四、Sealed Class:封闭的类型层次
Sealed Class 允许你明确限定一个类/接口的子类范围:
// 这个接口只能被这三个类实现,别的类不能实现
public sealed interface Shape
permits Circle, Rectangle, Triangle {}
public record Circle(double radius) implements Shape {}
public record Rectangle(double width, double height) implements Shape {}
public record Triangle(double a, double b, double c) implements Shape {}这有什么用?
五、Sealed Class + 模式匹配:强大的组合
Sealed Class 真正发挥威力是和 Java 21 的 switch 模式匹配结合使用:
// 计算面积:switch 匹配所有子类
public double calculateArea(Shape shape) {
return switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
case Triangle t -> {
// 海伦公式
double s = (t.a() + t.b() + t.c()) / 2;
yield Math.sqrt(s * (s - t.a()) * (s - t.b()) * (s - t.c()));
}
};
// 编译器知道所有子类,如果漏了某个子类,编译错误!
// 不需要 default 分支
}这里有一个关键点:因为 Shape 是 sealed 的,编译器知道所有可能的实现类,switch 可以做穷举检查——如果你新加了一个 Shape 的实现,但 calculateArea 里没处理,编译直接报错。
这在维护长期代码时非常有价值。我在一个项目里用这个模式定义支付方式:
public sealed interface PaymentMethod
permits AlipayPayment, WechatPayPayment, BankCardPayment {}
public record AlipayPayment(String alipayUserId) implements PaymentMethod {}
public record WechatPayPayment(String openId) implements PaymentMethod {}
public record BankCardPayment(String cardNo, String bankCode) implements PaymentMethod {}
// 处理支付
public PaymentResult process(PaymentMethod method, BigDecimal amount) {
return switch (method) {
case AlipayPayment a -> alipayService.charge(a.alipayUserId(), amount);
case WechatPayPayment w -> wechatService.charge(w.openId(), amount);
case BankCardPayment b -> bankService.charge(b.cardNo(), b.bankCode(), amount);
};
}后来业务要加一种新支付方式(数字人民币),我只需要:
- 定义
record DigitalCNYPayment(...) implements PaymentMethod {} - 编译,所有
switch (method)的地方都会报错,提醒我去加处理逻辑
这个"编译期的穷举保证"是非常有价值的工程工具,能有效防止"加了新类型但忘了处理"的 bug。
六、Sealed Class 的工程场景
场景1:领域事件(Event Sourcing)
public sealed interface OrderEvent
permits OrderCreatedEvent, OrderPaidEvent, OrderShippedEvent, OrderCancelledEvent {}
// 事件溯源里处理所有事件
public OrderState apply(OrderState state, OrderEvent event) {
return switch (event) {
case OrderCreatedEvent e -> state.withStatus("CREATED").withItems(e.items());
case OrderPaidEvent e -> state.withStatus("PAID").withPayTime(e.payTime());
case OrderShippedEvent e -> state.withStatus("SHIPPED").withTrackingNo(e.trackingNo());
case OrderCancelledEvent e -> state.withStatus("CANCELLED").withReason(e.reason());
};
}场景2:API 响应的统一封装
public sealed interface ApiResult<T>
permits ApiSuccess, ApiError {}
public record ApiSuccess<T>(T data) implements ApiResult<T> {}
public record ApiError<T>(String code, String message) implements ApiResult<T> {}
// 调用方必须处理两种情况
ApiResult<User> result = userService.findUser(id);
String display = switch (result) {
case ApiSuccess<User> s -> s.data().getUsername();
case ApiError<User> e -> "查询失败: " + e.message();
};Record 和 Sealed Class 都是"让代码更诚实"的工具:Record 让你诚实地承认"这是个值对象,不应该被修改";Sealed Class 让你诚实地承认"这个类型只有这几种情况,没有其他"。这种诚实带来的是更少的防御性代码和更早的 bug 发现。
下一篇写函数式接口,Predicate、Function、Consumer 这些,以及它们的组合方式,这块有一些很实用的技巧。
