引言
在 Java 应用中,处理 JSON 数据是常见的需求。假设我们正在开发一个订单系统,需要解析来自上游系统的 JSON 消息。消息体包含一个 type 字段,用于区分订单类型:普通订单、预售订单或虚拟商品订单。不同类型的订单携带不同的字段,例如预售订单有预计发货日期,虚拟商品订单有激活码。
一种常见的做法是定义一个通用的 Order 类,包含所有可能的字段,并使用 type 字段在运行时判断具体类型:
class Order {
String type;
String productId;
int quantity;
String estimatedShipDate; // 仅预售订单使用
String activationCode; // 仅虚拟商品订单使用
}
这种设计存在明显缺陷:类型安全性差,字段访问依赖隐式约定,容易误用;穷举性无法保证,添加新订单类型时编译器不会提示遗漏;相等性比较和对象拷贝需要手动实现,容易出错。
Java 14 引入的记录类(Records)和 Java 17 最终确定的密封类(Sealed Classes)为解决这类问题提供了新的工具。记录类简化了不可变数据载体的定义,密封类限制了继承层次,两者结合可以构建代数数据类型(Algebraic Data Types, ADT),使数据模型既类型安全又易于维护。
记录类:不可变数据载体的简洁定义
记录类是一种特殊的类,用于透明地承载不可变数据。它的声明由名称、类型参数(可选)和头部组成,头部列出了组成状态的组件。例如,定义一个 Point 记录类:
record Point(int x, int y) { }
编译器会自动生成以下成员:
- 每个组件对应的
private final字段和公共访问器方法(方法名与组件名相同)。 - 规范构造器(canonical constructor),其签名与头部一致,用于初始化所有字段。
equals、hashCode和toString方法,基于所有组件实现。
记录类放弃了 API 与内部表示的解耦,换取了极大的简洁性。这种设计非常适合作为数据载体,例如 JSON 反序列化的目标对象。
记录类的构造器与约束
记录类可以显式声明规范构造器,用于验证或规范化数据。例如,确保 x 和 y 非负:
record Point(int x, int y) {
Point {
if (x < 0 || y < 0) throw new IllegalArgumentException("Coordinates must be non-negative");
}
}
这种紧凑构造器(compact constructor)省略了参数列表和字段赋值,编译器会自动将形参与组件绑定,并在构造器末尾插入字段赋值。在紧凑构造器中,不能对实例字段赋值,这强化了不可变性。
记录类还可以声明额外的构造器,但必须以规范构造器为终点。例如,提供无参构造器创建原点:
record Point(int x, int y) {
Point() {
this(0, 0);
}
}
记录类在 JSON 反序列化中的应用
现代 JSON 库(如 Jackson)对记录类有良好支持。例如,使用 Jackson 将 JSON 反序列化为 Point:
ObjectMapper mapper = new ObjectMapper();
Point point = mapper.readValue("{\"x\":1,\"y\":2}", Point.class);
记录类的不可变性避免了反序列化后意外修改的风险,自动生成的 equals 和 hashCode 使对象比较和集合操作更可靠。
密封类:限制继承以支持穷举性
密封类允许作者控制哪些类或接口可以扩展或实现它。通过 sealed 修饰符和 permits 子句,明确列出允许的子类。例如,定义一个形状层次:
public abstract sealed class Shape permits Circle, Rectangle, Square {
// ...
}
允许的子类必须满足以下条件之一:
- 声明为
final,禁止进一步继承。 - 声明为
sealed,进一步限制子类。 - 声明为
non-sealed,恢复为开放继承。
密封类的主要动机之一是支持模式匹配中的穷举性分析。当 switch 表达式或语句处理密封类的所有允许子类时,编译器可以检查是否覆盖了所有情况,无需 default 分支。
密封类与代数数据类型
代数数据类型是函数式编程中的概念,通过组合已有的类型来构造新类型。在 Java 中,密封类可以模拟和类型(sum type),记录类可以模拟积类型(product type)。密封类充当和类型的根,其允许的子类就是和类型的各个变体;每个变体通常是一个记录类,携带该变体所需的数据。
构建类型安全的订单模型
回到订单系统的例子,我们可以用密封类和记录类重新建模。首先,定义一个密封接口 Order,并允许三种订单类型:
public sealed interface Order permits NormalOrder, PresaleOrder, VirtualOrder {
String productId();
int quantity();
}
每个具体订单类型用记录类实现:
record NormalOrder(String productId, int quantity) implements Order { }
record PresaleOrder(String productId, int quantity, LocalDate estimatedShipDate) implements Order { }
record VirtualOrder(String productId, int quantity, String activationCode) implements Order { }
这种设计带来了以下好处:
- 类型安全:访问
estimatedShipDate必须先将Order转换为PresaleOrder,编译器强制检查。 - 穷举性:处理
Order时,使用模式匹配可以覆盖所有情况,编译器会提示遗漏。 - 不可变性:所有字段都是
final,无需担心数据被意外修改。 - 自动生成的方法:
equals、hashCode和toString由编译器生成,减少出错。
反序列化与类型判断
JSON 反序列化时,需要根据 type 字段决定实例化哪个子类。Jackson 可以通过自定义反序列化器实现:
public class OrderDeserializer extends StdDeserializer<Order> {
public OrderDeserializer() {
super(Order.class);
}
@Override
public Order deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
JsonNode node = p.getCodec().readTree(p);
String type = node.get("type").asText();
return switch (type) {
case "normal" -> ctxt.readTreeAsValue(node, NormalOrder.class);
case "presale" -> ctxt.readTreeAsValue(node, PresaleOrder.class);
case "virtual" -> ctxt.readTreeAsValue(node, VirtualOrder.class);
default -> throw new IllegalArgumentException("Unknown order type: " + type);
};
}
}
在 switch 表达式中,编译器无法检查 type 字符串是否覆盖所有情况,需要 default 分支。但当我们处理 Order 对象时,可以使用模式匹配获得编译期穷举检查。
模式匹配与穷举性检查
Java 17 引入了模式匹配的预览特性,后续版本逐步增强。使用 switch 表达式处理 Order:
String describe(Order order) {
return switch (order) {
case NormalOrder o -> "Normal order for product: " + o.productId();
case PresaleOrder o -> "Presale order, ships on: " + o.estimatedShipDate();
case VirtualOrder o -> "Virtual order, activation code: " + o.activationCode();
};
}
如果遗漏某个子类,编译器会报错,要求覆盖所有可能。这避免了运行时因遗漏处理而导致的错误。
密封类的层次设计
密封类的允许子类必须与密封类位于同一个模块(如果密封类在具名模块中)或同一个包(如果密封类在未命名模块中)。这种限制保证了封闭性,但有时需要在不同包中声明子类。例如:
package com.example.geometry;
public abstract sealed class Shape
permits com.example.polar.Circle,
com.example.quad.Rectangle,
com.example.quad.simple.Square {
// ...
}
当允许的子类较少且紧密相关时,可以将它们声明在同一个源文件中,此时可以省略 permits 子句,编译器会推断。
对象拷贝与相等性比较
在传统 JavaBean 中,拷贝对象通常需要手动编写 clone 或复制构造器,容易遗漏字段。记录类的不可变性使得拷贝变得简单:直接使用构造器创建新对象,传入相同的字段值。如果需要修改某个字段,可以创建新记录:
NormalOrder order = new NormalOrder("P123", 10);
NormalOrder updated = new NormalOrder(order.productId(), 20); // 修改数量
相等性比较方面,记录类的 equals 方法基于所有组件,避免了手动实现可能出现的错误。例如,两个 NormalOrder 对象只要 productId 和 quantity 相等,就认为相等。
与 Lombok 等工具的对比
Lombok 的 @Value 注解也能生成不可变类,但记录类是语言级特性,具有以下优势:
| 特性 | 记录类 | Lombok @Value |
|---|---|---|
| 语言支持 | 原生支持,无需额外依赖 | 需要注解处理器 |
| 不可变性 | 强制不可变,字段为 final | 字段为 final,但可绕过 |
| 构造器 | 紧凑构造器,支持验证 | 需手动编写或使用 Builder |
| 继承 | 隐式为 final,不能继承其他类 | 可继承其他类 |
| 模式匹配 | 支持解构模式(未来) | 不支持 |
| 与密封类协作 | 天然适合作为密封类的允许子类 | 无特殊支持 |
记录类更适合作为纯数据载体,而 Lombok 在需要继承或更灵活控制时仍有其用途。
生产环境中的考量
序列化兼容性
记录类的序列化基于其组件,而非内部字段。即使记录类添加了额外的方法或静态字段,只要组件不变,序列化形式保持兼容。但修改组件类型或顺序会破坏兼容性。
性能影响
记录类的 equals 和 hashCode 由编译器生成,通常比手动实现更高效,因为编译器可以生成直接访问字段的字节码,避免反射或自动装箱。但记录类的不可变性意味着修改数据需要创建新对象,在频繁修改的场景下可能带来分配压力。对于订单模型,订单一旦创建很少修改,因此影响不大。
反射与框架集成
记录类的组件名和类型在运行时可通过 Class.getRecordComponents() 获取,框架可以利用这些信息进行序列化或数据绑定。但记录类的字段是 private final,通过反射修改会抛出 IllegalAccessException,这强化了不可变性,但也可能影响某些依赖反射修改字段的框架。
常见失败模式
- 密封类遗漏子类:当添加新订单类型时,如果忘记在
permits子句中声明,编译会失败。但更隐蔽的是,处理Order的switch表达式会提示错误,迫使开发者处理新类型,这正是穷举性的价值。 - 记录类构造器中的副作用:紧凑构造器应只进行验证,不应执行有副作用的操作,因为构造器可能被多次调用(例如反序列化时)。
- JSON 反序列化中的类型映射错误:如果
type字段与类映射不一致,会导致反序列化失败或创建错误类型。应确保映射逻辑与密封类层次同步。
贯穿场景:订单处理流程
下面通过一个完整的订单处理流程,展示记录类与密封类的协作。假设收到一个 JSON 消息:
{"type":"presale","productId":"P123","quantity":5,"estimatedShipDate":"2025-12-01"}
反序列化后得到 PresaleOrder 对象。处理逻辑根据订单类型执行不同操作:
void process(Order order) {
switch (order) {
case NormalOrder o -> fulfillNormal(o);
case PresaleOrder o -> reserveInventory(o);
case VirtualOrder o -> issueActivationCode(o);
}
}
如果未来增加新的订单类型,例如 SubscriptionOrder,只需将其添加到 Order 的 permits 列表,并实现相应处理逻辑,编译器会强制所有 switch 覆盖新类型。
下面的流程图展示了从 JSON 反序列化到类型安全处理的过程:
flowchart TD
A[接收 JSON 消息] --> B{解析 type 字段}
B -->|normal| C[反序列化为 NormalOrder]
B -->|presale| D[反序列化为 PresaleOrder]
B -->|virtual| E[反序列化为 VirtualOrder]
C --> F[处理普通订单]
D --> G[处理预售订单]
E --> H[处理虚拟商品订单]
F --> I[完成]
G --> I
H --> I
在这个流程中,密封类确保了 type 到子类的映射是受控的,记录类保证了数据载体的简洁和不可变。
总结
记录类与密封类的组合为 Java 带来了代数数据类型的能力,使得建模固定集合的类型变体变得简单且安全。在 JSON 反序列化场景中,这种模式消除了基于字符串的类型判断,提供了编译期穷举检查,并降低了对象拷贝和相等性比较的出错率。虽然记录类的不可变性可能带来额外的对象分配,但在许多业务场景中,这种代价远小于其带来的安全性和可维护性提升。
当你的数据模型需要表达“有限种可能”时,密封类和记录类应成为首选工具。