去年接了一个支付对账系统的活。核心任务是从五家支付网关拉回调数据,把它们统一成我们内部的流水格式入账。看起来是个体力活,但真正动起手来,发现事情没那么简单——五家网关的数据结构各不相同,字段名不一样、嵌套层级不一样,甚至同一家网关在不同版本里返回的结构都不一样。
当时的解析代码长这样:
public FlowRecord parse(WebhookPayload payload) {
if (payload instanceof WechatPayload) {
WechatPayload w = (WechatPayload) payload;
if (w.getResource() != null && w.getResource().getCiphertext() != null) {
String trade = w.getResource().getCiphertext().getTransactionId();
Long amount = w.getResource().getCiphertext().getAmount().getTotal();
// ... 一大堆 get 和判空
}
} else if (payload instanceof AlipayPayload) {
AlipayPayload a = (AlipayPayload) payload;
// ... 又是三十几行
}
// ... 剩下的三家
}
一个方法 300 行,全是 instanceof 加 getXxx() 的链式调用,每个分支里还有嵌套的判空。改一个网关格式要战战兢兢地找半天,测试用例更是噩梦——要构造五套不同结构的 mock 数据,还没法一眼看出哪个是哪个。
今年年初,借着项目升级 JDK 21 的机会,我把这段代码用记录模式和 switch 模式匹配重写了一遍。最终版不到 60 行,可读性和可测试性都上了好几个台阶。这篇文章就把这个过程完整记录一下。
先看记录模式是什么
一句话说明:记录模式让你能在 switch 或 instanceof 里直接解构 Record 的字段,而不是先强转再逐个 get。
假设有一个简单的 Record:
public record Point(int x, int y) {}
以前判断一个 Object 是不是 Point 并且要拿到坐标,得这么写:
if (obj instanceof Point) {
Point p = (Point) obj;
int x = p.x();
int y = p.y();
System.out.println("坐标:" + x + "," + y);
}
用记录模式一下子搞定:
if (obj instanceof Point(int x, int y)) {
System.out.println("坐标:" + x + "," + y);
}
类型判断和解构在一行完成,x 和 y 直接就是可用的局部变量。
如果有人觉得”这就是省了两行代码嘛”,那不妨看看嵌套的场景。假设有这样一个结构:
public record Address(String city, String street) {}
public record User(String name, Address address) {}
判断一个对象是不是住在北京的用户:
// 老写法
if (obj instanceof User) {
User u = (User) obj;
if (u.address() != null && "北京".equals(u.address().city())) {
System.out.println(u.name());
}
}
// 记录模式
if (obj instanceof User(String name, Address(String city, String street))
&& "北京".equals(city)) {
System.out.println(name);
}
这才开始体现出真正的好处——嵌套解构。一个模式可以匹配多层结构,不需要中间变量。
把它用到对账系统里
回来解决我的问题。先定义五家网关各自的数据结构,用 Record 表达:
// 微信
public record WechatPayload(WechatResource resource) {}
public record WechatResource(WechatCiphertext ciphertext) {}
public record WechatCiphertext(String transactionId, WechatAmount amount) {}
public record WechatAmount(long total) {}
// 支付宝
public record AlipayPayload(String tradeNo, String outTradeNo, String totalAmount) {}
// 银联
public record UnionpayPayload(UnionpayData data) {}
public record UnionpayData(String orderId, UnionpayTx tx, long fee) {}
public record UnionpayTx(String status, String transTime) {}
// Stripe(国际业务)
public record StripePayload(String id, StripeIntent intent) {}
public record StripeIntent(long amount, String currency) {}
// PayPal
public record PaypalPayload(PaypalResource purchase) {}
public record PaypalResource(String id, String status, PaypalAmount amount) {}
public record PaypalAmount(String value, String currency) {}
五家五套结构,从最底层的字段看没有共性。但如果把它们放进一个密封接口里,就可以用模式匹配一次处理:
public sealed interface WebhookPayload
permits WechatPayload, AlipayPayload, UnionpayPayload,
StripePayload, PaypalPayload {
}
每个 Record 加一个 implements WebhookPayload 就完事。sealed 关键字保证编译器知道所有的实现类,这样后面 switch 才能做穷举检查。
核心解析逻辑
重写后的解析器不到 60 行,我们一段段看:
public class WebhookParser {
public FlowRecord parse(WebhookPayload payload) {
return switch (payload) {
case WechatPayload(WechatResource(WechatCiphertext(
String txId, WechatAmount(long total))))
-> new FlowRecord(txId, total, "CNY", Channel.WECHAT);
case AlipayPayload(String tradeNo, String outTradeNo, String total)
-> new FlowRecord(
tradeNo,
new BigDecimal(total).movePointRight(2).longValueExact(),
"CNY",
Channel.ALIPAY);
case UnionpayPayload(UnionpayData(String orderId, UnionpayTx(_, String _), long fee))
-> new FlowRecord(orderId, fee, "CNY", Channel.UNIONPAY);
case StripePayload(String id, StripeIntent(long amount, String currency))
-> new FlowRecord(id, amount, currency, Channel.STRIPE);
case PaypalPayload(PaypalResource(String id, String status, PaypalAmount(String value, String currency)))
when "COMPLETED".equals(status)
-> new FlowRecord(
id,
new BigDecimal(value).movePointRight(2).longValueExact(),
currency,
Channel.PAYPAL);
case PaypalPayload(PaypalResource(_, String status, _))
-> throw new UnsupportedStatusException("PayPal 状态不支持:" + status);
};
}
}
几个关键点展开说。
第一:switch 的参数直接就是模式。编译器知道 WebhookPayload 是一个 sealed 接口,所有实现类是已知的,所以在 case 里列出所有可能的实现,就不需要 default 分支。这是 sealed 接口和模式匹配配合的精髓所在。
第二:嵌套解构可以多层。WechatPayload(WechatResource(WechatCiphertext(...))) 直接把三层结构拆到底。以前这里至少要写十行判空代码,现在一行。
第三:用下划线忽略不需要的字段。UnionpayTx(_, String _) 里的 _ 表示”我不关心这个位置的值”。这个特性是 JDK 21 引入的,之前你得给每个字段都起名。(注:下划线在 Java 里本来是保留字,JDK 21 起解禁为未命名变量)
第四:可以用 when 加守卫条件。PayPal 的分支里有一个 when "COMPLETED".equals(status)。这表示”只有 status 是 COMPLETED 才走这个分支”。因为 status 也是解构出来的变量,用起来很自然。
第五:多个 case 顺序。switch 从上往下匹配。PayPal 的两个 case 顺序不能换,否则第二个 case 会覆盖第一个。这一点跟 if-else 链条是类似的,但比 if-else 更清晰——case 直接列出了匹配的结构模式,一眼能看出来是哪种数据。
为什么这套写法比 instanceof 强
列几点直观感受。
1. 不用手动强转。以前每一层都要 (WechatPayload) obj 这样的强转,代码噪声巨大。模式匹配之后,编译器自动把变量声明成正确的类型,一步到位。
2. 不用手动判空。嵌套解构的过程里,如果中途遇到 null,模式匹配会直接失败——不会进分支,也不会抛 NPE。这一点非常宝贵。以前写 a.getB().getC().getD() 得层层判空,现在交给编译器处理。
3. 穷举检查。因为 WebhookPayload 是 sealed 的,如果将来新增一家网关的 Payload 类型,编译器会在 switch 处报错,提示”缺少对这个类型的处理”。这就把”添加新渠道时忘了改某个解析器”的 bug 从运行时提前到了编译时。
这一条用了我最久才体会到价值。团队里三个人同时在改这段代码,其中一个人加了新渠道,另外两个人在别的地方用了相同模式的 switch,编译直接不通过,强制每个人都去看一眼自己的处理逻辑对不对。
4. 可读性。每一行就是一个完整的”结构 → 语义”映射,读起来像在念业务规则:”微信的密文里,把 txId 和 total 拿出来,币种 CNY,渠道是 WECHAT”。不需要理解函数调用的中间过程。
性能数据
有人会担心模式匹配的运行时开销。我在本地做了个基准测试,用 JMH 跑,比较三种实现的解析性能。同样是解析 500 万个各种类型的 payload。
| 实现方式 | 吞吐量(ops/ms) | 平均耗时(ns/op) |
|---|---|---|
| instanceof + 手动 get | 2,150 | 465 |
| instanceof 模式变量(无解构) | 2,380 | 420 |
| switch 记录模式 | 2,620 | 381 |
结果有点意外——记录模式比手动 get 还要快。分析下来有两个原因:一是模式匹配在字节码层面做了一些优化,避免了不必要的类型转换指令;二是手动 get 的代码里经常有重复的字段访问,编译器很难消除,模式解构一次性提取所有需要的字段,效率更高。
这个数据不代表所有场景都一样,但至少告诉我们:用记录模式不用担心性能,它不是靠开销换优雅的。
几个容易踩的坑
坑一:Record 的字段不能重名。嵌套模式里如果不同类型有同名字段,会冲突。比如:
public record A(String id) {}
public record B(String id, int value) {}
// 编译错误:变量 id 重复定义
if (obj instanceof B(String id, int value)) { ... }
if (obj2 instanceof A(String id)) { ... }
// 但如果同一个 switch 里都用 id,就没事,因为是不同分支作用域
同一个 case 模式里如果解构出重名字段,编译会报错。解决办法是给其中一个起别名,或者用下划线忽略掉不用的。这个坑在写复杂嵌套的时候会遇到。
坑二:不能用 record 模式匹配普通类。记录模式只对 record 类型生效——普通类没有”组件(component)”的概念。想匹配普通类只能用基础的类型模式:
// 能这么写
if (obj instanceof Point(int x, int y)) { ... } // Point 是 record
// 不能这么写
if (obj instanceof Foo(String a, int b)) { ... } // Foo 是普通类
想在普通类上享受类似能力,得自己改成 record。改造的时候注意 record 自动生成的 equals/hashCode/toString 是否满足需求,尤其是不能被继承这一点。
坑三:switch 的模式匹配对 null 有特殊处理。以前的 switch 遇到 null 会抛 NPE,现在如果有一个 case null 分支,可以接住 null:
switch (payload) {
case null -> throw new IllegalArgumentException("payload 不能为 null");
case WechatPayload(...) -> ...
// ...
}
如果没写 case null,也没写 default,遇到 null 仍然会抛 NPE。想做防御性编程的话,把 case null 放在第一位。
坑四:泛型擦除会影响模式匹配。因为泛型在运行时擦除,List<String> 和 List<Integer> 在运行时都是 List。所以下面这种模式匹配是不可行的:
// 编译不通过:无法在运行时判断泛型参数
if (obj instanceof List<String> list) { ... }
// 只能匹配原始类型
if (obj instanceof List<?> list) { ... }
这个限制来自 Java 泛型的设计,跟记录模式无关,但写代码的时候容易忘记。
坑五:record 里的数组字段有坑。record 自动生成的 equals 对数组字段用的是引用比较,不是内容比较。如果 record 里有数组字段,用模式匹配解构拿到之后改内容,可能引起不可思议的行为。record 的字段最好都是不可变类型,数组这种尽量换成 List。
怎么渐进式地在项目里用起来
模式匹配是 Java 21 的正式特性,不需要加 --enable-preview。所以升级到 21 之后就可以直接用了。但要迁移老代码,建议分三步走。
第一步:把所有需要”精确处理”的取值类改成 record。像是 DTO、Response、Config、Message 这类”数据载体”,天然适合 record。改的时候注意字段命名要和 JSON 序列化框架兼容(Jackson 从 2.12 开始原生支持 record)。
第二步:把顶层接口换成 sealed interface。找出那些”只有几种固定实现”的接口,改成 sealed 的。这样后面用模式匹配的时候,编译器才能做穷举检查。注意这一步可能会暴露一些隐藏的实现类——有时候你会发现有个接口的实现在意料之外的模块里。这是好事,趁着有 net 的时候把架构理清。
第三步:写解析逻辑的时候用 switch 模式匹配。一开始不用追求一次性把所有 if-else 都改掉,从最常见的、复杂度最高的那部分开始。先挑一个分支试水,熟悉之后自然就越写越顺。
我们项目里从决定迁移到全部替换完,用了一周左右的时间。不是技术上难,而是因为要一个个文件确认,确保行为一致。
写在最后
Java 这几年的更新节奏,让我这个写了十几年的老兵有点陌生。特别是从 JDK 17 到 21,语言层的变化太多,而且都是实实在在能改善代码质量的东西——Records、Sealed Classes、Pattern Matching、Virtual Threads,每一个拎出来都值得团队做一次技术升级讨论。
说回记录模式,它给我最大的收获其实不是代码短了,而是让我在写代码的时候,开始真正地”按结构去思考问题”。以前写解析逻辑,脑子里想的是”先拿到 A,再从 A 里拿 B,判断 B 是不是空”,是一连串的过程。现在写的时候想的是”数据长什么样,匹配到什么样的结构做什么”,是一种声明式的形状匹配。
这个思维方式的转换,比语法本身的收益更大。

