Java 记录模式实战:一段解析代码从 300 行缩到 60 行的过程

2026-10-06 0 736

去年接了一个支付对账系统的活。核心任务是从五家支付网关拉回调数据,把它们统一成我们内部的流水格式入账。看起来是个体力活,但真正动起手来,发现事情没那么简单——五家网关的数据结构各不相同,字段名不一样、嵌套层级不一样,甚至同一家网关在不同版本里返回的结构都不一样。

当时的解析代码长这样:

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 是不是空”,是一连串的过程。现在写的时候想的是”数据长什么样,匹配到什么样的结构做什么”,是一种声明式的形状匹配。

这个思维方式的转换,比语法本身的收益更大。

Java 记录模式实战:一段解析代码从 300 行缩到 60 行的过程
收藏 (0) 打赏

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

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

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

淘吗网 java Java 记录模式实战:一段解析代码从 300 行缩到 60 行的过程 https://www.taomawang.com/server/java/2897.html

常见问题

相关文章

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

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