手头有一个对接三方物流平台的项目,里面有个模块专门解析不同快递公司返回的轨迹数据。因为历史原因,轨迹节点被建模成了一套庞大的继承体系:有表示揽收的节点、有运输中的节点、有派送中的节点、还有签收和异常节点,每种节点里嵌套着不同结构的位置信息和时间戳。最开始的代码为了从这些五花八门的子类里提取出前端需要的统一格式,写了大量的 `if (node instanceof XxxNode)` 分支,每个分支里再手动做强转,然后层层剥开内部结构。功能倒是稳定的,但任何一个新人打开那个文件,都会被几百行嵌套的条件判断劝退。
Java 21 把模式匹配推到了一个新的可用程度——`switch` 可以直接匹配对象类型,并且能把类型绑定与记录组件的解构写在同一行。结合记录类不可变且透明的特点,这套组合拳特别适合用来清理前面提到的那种遗产代码。花了两天时间把整个解析器翻新了一遍,结果让我很满意:代码行数减少了一半,分支逻辑变得像在阅读规格说明书,而且由于不再需要手动强转,一个潜藏了很久的类型转换 bug 也被顺带消灭了。这篇文章就把整个重构的思路和关键写法完整还原出来。
一、旧代码的样子:instanceof 链条为什么让人疲惫
先简单还原一下重构前的典型片段。轨迹节点的基类是 `TrackNode`,下面有 `PickupNode`、`TransitNode`、`DeliveryNode`、`SignedNode` 等几个子类。每个子类内部可能还包含一个 `Location` 对象,而 `Location` 自身又有 `GeoPoint` 和 `AddressText` 两种表示形式。旧代码要从中提取一个统一的前端展示文本,写出来大致是:
public String formatTrackNode(TrackNode node) {
if (node instanceof PickupNode) {
PickupNode pn = (PickupNode) node;
Location loc = pn.getLocation();
if (loc instanceof GeoPoint) {
GeoPoint gp = (GeoPoint) loc;
return "揽收地点: (" + gp.lat() + ", " + gp.lng() + ")";
} else if (loc instanceof AddressText) {
AddressText at = (AddressText) loc;
return "揽收地址: " + at.text();
}
} else if (node instanceof TransitNode) {
TransitNode tn = (TransitNode) node;
// 又是一层层的类型检查和强转...
}
// 后面还有 DeliveryNode、SignedNode、ExceptionNode...
return "未知状态";
}
如果每个子类都需要解析,`if-else` 链条会非常长。而且在每个分支里,先 `instanceof` 检查再手动强转是一个重复的模式,很容易在某次修改时忘记同步检查条件与强转类型,埋下 `ClassCastException` 的隐患。更麻烦的是,当内层 `Location` 也有子类时,嵌套的 `instanceof` 会让代码的视觉结构完全失去扁平感。
二、模式匹配 switch 的核心语法:把类型检查与解构合一
Java 21 将模式匹配从 `if` 语句扩展到了 `switch`,并且允许 `switch` 直接匹配对象类型。基本写法如下:
switch (obj) {
case String s -> System.out.println("字符串长度: " + s.length());
case Integer i -> System.out.println("整数值: " + i);
case null -> System.out.println("空值");
default -> System.out.println("其他类型");
}
这里 `case String s` 做了三件事:检查 `obj` 是不是 `String` 类型;如果是,则声明一个变量 `s` 并绑定到该对象;然后执行箭头右侧的代码块。不需要额外的强转,类型在编译期就已经是确定的。
如果匹配的类型本身是记录类,还可以在 `case` 里直接解构出它的组件。这就是记录模式(Record Pattern)。举个例子,假设有一个记录类 `GeoPoint(double lat, double lng)`,可以这样写:
case GeoPoint(double lat, double lng) -> "坐标(" + lat + ", " + lng + ")";
这条分支会匹配 `GeoPoint` 类型,同时把它的 `lat` 和 `lng` 组件直接提取出来,你甚至不需要调用 `lat()` 和 `lng()` 方法。
守卫条件(Guarded Pattern)可以进一步限制匹配范围。当某个 `case` 需要附加额外条件时,可以写成 `case PickupNode pn when pn.isUrgent()` 这种形式,只有 `when` 后面的表达式为真时才进入该分支。
三、用记录类重新建模数据,让模式匹配如鱼得水
在动手改写 `formatTrackNode` 之前,我先把旧的POJO全部换成了记录类。因为记录类的组件名称直接参与模式匹配的解构,语义非常清晰。新的轨迹节点定义如下:
sealed interface TrackNode permits PickupNode, TransitNode, DeliveryNode, SignedNode, ExceptionNode {}
sealed interface Location permits GeoPoint, AddressText {}
record GeoPoint(double lat, double lng) implements Location {}
record AddressText(String text) implements Location {}
record PickupNode(String trackingId, Location location, LocalDateTime time) implements TrackNode {}
record TransitNode(String trackingId, Location from, Location to, LocalDateTime time) implements TrackNode {}
record DeliveryNode(String trackingId, Location location, String courierName, LocalDateTime time) implements TrackNode {}
record SignedNode(String trackingId, Location location, String signer, LocalDateTime time) implements TrackNode {}
record ExceptionNode(String trackingId, String reason, LocalDateTime time) implements TrackNode {}
使用 `sealed interface` 限制子类型范围,这样 `switch` 在匹配时能启用穷尽检查——编译器会强制你覆盖所有许可的子类,减少遗忘分支的可能。记录类的不可变性也完全符合轨迹数据的一次写入、多次读取的使用方式。
四、重写后的解析器:扁平、穷尽、无强转
把模型改成记录类之后,`formatTrackNode` 方法用新模式匹配重写,代码一下子变得平坦了:
public String formatTrackNode(TrackNode node) {
return switch (node) {
case PickupNode(String id, Location loc, LocalDateTime time) ->
"揽收 [" + id + "] " + formatLocation(loc) + " 时间: " + time;
case TransitNode(String id, Location from, Location to, LocalDateTime time) ->
"运输中 [" + id + "] " + formatLocation(from) + " -> " + formatLocation(to) + " 更新: " + time;
case DeliveryNode(String id, Location loc, String courier, LocalDateTime time) ->
"派送中 [" + id + "] 快递员: " + courier + " " + formatLocation(loc) + " 时间: " + time;
case SignedNode(String id, Location loc, String signer, LocalDateTime time) ->
"已签收 [" + id + "] 签收人: " + signer + " " + formatLocation(loc) + " 时间: " + time;
case ExceptionNode(String id, String reason, LocalDateTime time) ->
"异常 [" + id + "] 原因: " + reason + " 时间: " + time;
case null -> "无效轨迹节点";
};
}
private String formatLocation(Location loc) {
return switch (loc) {
case GeoPoint(double lat, double lng) -> "坐标(" + lat + ", " + lng + ")";
case AddressText(String text) -> text;
case null -> "未知位置";
};
}
整个方法没有一次显式的类型强制转换,也没有嵌套的 `if-else`。每个分支的结构和记录类的定义一一对应,读代码的人可以像看接口文档一样快速定位到每种轨迹节点的格式化逻辑。`formatLocation` 同样利用 `switch` 对 `Location` 的密封子类做模式匹配,两个方法放在一起形成了一套自顶向下的解构。
如果某个分支还需要附加业务判断,可以用 `when` 来加守卫。比如只想高亮超时的运输节点,可以写:
case TransitNode(String id, Location from, Location to, LocalDateTime time)
when Duration.between(time, LocalDateTime.now()).toHours() > 48 ->
"⚠️ 运输超时 [" + id + "] " + formatLocation(from) + " -> " + formatLocation(to);
守卫条件的判断紧跟在模式之后,不会干扰解构的整洁性。
五、碰到的几个实际问题与解法
重构过程并不全是无缝的,有几个点值得单独拿出来说说。
第一个是空值处理。旧代码对 `null` 节点的处理分散在各个 `if` 开头,偶尔会有遗漏。新 `switch` 里我用了一个专门的 `case null ->` 分支来统一兜底,编译器的穷举检查也会在开启的情况下提示你是否覆盖了 `null`。不过默认情况下,`switch` 并不强制要求 `null` 分支,如果你不写,传入 `null` 会直接抛出 `NullPointerException`,这一点和之前 `switch` 的行为一致。保险起见,我习惯在所有模式匹配 switch 里显式加上 `case null`。
第二个是泛型与模式匹配的组合。项目中还有一小部分节点使用了泛型包装,比如 `ResultNode`。在 switch 里直接匹配泛型类型会导致未检查警告,因为记录模式目前还不能携带泛型参数做类型安全解构。遇到这种情况,我把泛型部分剥离到了外层,先做一次整体匹配 `case ResultNode rn`,然后在分支里用 `rn.value()` 单独处理,虽然多了一步但逻辑依然可控。
第三个是IDE支持。IntelliJ IDEA 2023.3 以上版本对 Java 21 模式匹配的提示和自动补全已经比较完善。输入 `case` 之后会根据密封接口自动列出所有可能的子类型,还能一键生成解构句式,整个过程和写普通代码没有太大区别。
六、编译器和穷举检查的实际体验
开启封闭接口后,`switch` 的穷举检查在工作中帮了不小的忙。当物流平台新增了一种轨迹节点类型(比如 “海关清关节点”),我只需要在 `sealed interface TrackNode` 的 `permits` 列表里加上它,然后重新编译。编译器立刻会在 `formatTrackNode` 方法上报警:`switch` 没有覆盖新的子类。这相当于一道强制性的提醒,告诉我这里必须处理新的业务逻辑。相比于以前新增一个类型后需要手动搜索所有 `instanceof` 链条去找哪里需要补充分支,这个机制可靠太多了。
七、适合立刻开始使用的场景
模式匹配 switch 和记录模式的组合最适合下面几种情况:
- 处理代数数据类型,比如用密封接口和记录类描述的命令、事件、AST节点等。
- 替换过长的 `instanceof` 链条,把分散在各处的类型分支集中到一个 `switch` 中,改善内聚性。
- 重构复杂的DTO转换逻辑,将层层判空和类型检查替换为声明式的模式匹配。
如果你的项目已经升级到Java 21,并且代码库里有上述特征的模块,花半天时间做一个类似的迁移,收益会比预期的大很多。
八、总结
Java 21 的模式匹配和记录类不是孤立存在的语法糖,它们放在一起形成了一套完整的数据导向编程范式。过去我们习惯了用一堆 `if-else` 和类型强转来拉扯对象结构,现在可以把类型判断、结构提取和业务逻辑写进同一个紧凑的 `case` 句式里。对那个轨迹解析器来说,这次重构删掉了一百多行重复的检查代码,并在编译器层面堵上了遗漏分支的漏洞。如果你还在观望要不要升级到 Java 21,这套模式匹配的改进绝对是一个分量很足的升级理由。

