sealed interface + record 重构支付回调:Java 模式匹配实战与穷尽性检查

2026-09-23 0 108

接手一个结算服务的时候,看到这么一个类:六百多行,全是 if (event instanceof XxxEvent) 的链式判断。每一次判断都要先强转,再挨个调 getter,中间夹杂着业务逻辑,嵌套四层。作者已经在类注释里写了「改这个类请小心」。

这种代码最难受的地方不是它丑,而是它不会报错。三个月后运营提了个需求:支付回调要支持「部分退款」。开发加了一个新的 PartiallyRefundedEvent 类,然后在处理链的开头补了一段 if。上线之后发现,统计报表里少了一种状态,对账任务把它们全漏了。原因是有另外三个地方也在遍历这个事件体系,那三处的 if 链没改。编译器一句话都没说。

这篇就讲讲怎么用 Java 21 的 sealed interface、record 和 switch 模式匹配,把这类代码改造成「加了新类型,编译器主动告诉你哪些地方没处理」。这才是这套特性真正的价值所在,少写几个 getter 只是附带的。

一、先把改造前的代码摆出来

简化一下,核心结构大概是这样:

public abstract class PayEvent {
    private String orderNo;
    private long amountFen;
    private Instant occurredAt;
    // getter / setter 一大堆
}

public class PaySucceeded extends PayEvent { ... }
public class PayFailed extends PayEvent { ... }
public class PayRefunded extends PayEvent { ... }

处理逻辑:

public void handle(PayEvent event) {
    if (event instanceof PaySucceeded) {
        PaySucceeded e = (PaySucceeded) event;
        orderService.markPaid(e.getOrderNo(), e.getAmountFen());
        notifyService.push(e.getOrderNo(), "支付成功");

    } else if (event instanceof PayFailed) {
        PayFailed e = (PayFailed) event;
        orderService.markFailed(e.getOrderNo(), e.getReason());

    } else if (event instanceof PayRefunded) {
        PayRefunded e = (PayRefunded) event;
        orderService.markRefunded(e.getOrderNo(), e.getAmountFen());
        notifyService.push(e.getOrderNo(), "已退款");

    } else {
        log.warn("未知事件类型: {}", event.getClass().getName());
    }
}

这段代码本身没什么错,问题是它有三个隐患同时存在。

第一,PayEventabstract 类,理论上任何人都可以用匿名子类或者继承出新的类型。类型体系是开放的,编译器对「一共可能有哪些事件」这件事一无所知。

第二,那个 else 分支只是打了一条日志。新类型进来会被静默吞掉,除非有人翻日志。

第三,事件类写成了带 setter 的普通类,六十多个字段分布在四个子类里,每个 getter 名字都得记得住,强转之后写错一个 getter 也不会编译报错。

二、sealed interface:把「有哪些实现」写进类型系统

第一步是把抽象类换成密封接口。

public sealed interface PayEvent
        permits PaySucceeded, PayFailed, PayRefunded, PayPending {
    String orderNo();
    long amountFen();
    Instant occurredAt();
}

sealed 的含义是:这个类型的直接子类型被写死在 permits 子句里,除此之外任何人都不能在别处继承或者实现它。如果你试图在另一个源文件里写 class Foo implements PayEvent,编译直接失败。

有个细节值得留意:如果所有子类型都写在这同一个源文件里(比如作为嵌套接口或嵌套 record),permits 子句可以省略,编译器会自动推导。实际项目里为了可读性,我一般还是会显式写出来,因为它本身就是一份最好的文档。

接口里定义了三个公共方法,但注意用的是接口方法而不是 getter。原因有两个:一是 record 会自动生成同名的访问器,不需要再写 getXxx();二是接口方法强制所有实现提供这些字段,不会出现「某个子类忘了传 orderNo」这种只有运行时才发现的问题。

另外,sealed 类型的子类必须显式声明自己的封闭性,只能是三种之一:final(不能再被继承)、sealed(继续限制)、non-sealed(重新开放继承)。record 本身就是 final 的,所以直接写 record 就行。如果哪天你确实需要一个开放的实现,可以标记 non-sealed,但那也意味着你放弃了后面要说的穷尽性检查——这应该是一个有意识的决定,而不是随手写的。

三、record:把数据类写成一行

四个事件用 record 重写:

public record PaySucceeded(
        String orderNo,
        long amountFen,
        String channel,
        String transactionId,
        Instant occurredAt
) implements PayEvent {}

public record PayFailed(
        String orderNo,
        long amountFen,
        String reason,
        String rawCode,
        Instant occurredAt
) implements PayEvent {}

public record PayRefunded(
        String orderNo,
        long amountFen,
        RefundDetail detail,
        Instant occurredAt
) implements PayEvent {
    public record RefundDetail(String refundNo, String reason) {}
}

public record PayPending(
        String orderNo,
        long amountFen,
        Instant expireAt,
        Instant occurredAt
) implements PayEvent {}

record 自动生成规范构造器、访问器、equalshashCodetoString。这四个类原来加起来有小两百行,现在不到三十行。

不过 record 用在这里的真正好处不是省代码,而是字段顺序和字段集合被固定下来了。这为下一步的「解构」提供了前提——只有当编译器确切知道一个类型有哪些字段、顺序如何,才可能在模式匹配里按位置把它们一次性取出来。

顺带说一句,record 里的列表字段要注意。record 的 equals 对 List 是比较内容,但如果字段本身是可变对象(比如传进来的 ArrayList),列表被外部修改后,这个 record 的哈希值就变了,放进 HashSet 会出乱子。稳妥做法是在规范构造器里做一次防御性拷贝:

public record PayBatch(List<PayEvent> events) {
    public PayBatch(List<PayEvent> events) {
        this.events = List.copyOf(events);
    }
}

四、switch 模式匹配:一次拿到类型和数据

改造后的处理逻辑:

public void handle(PayEvent event) {
    switch (event) {
        case PaySucceeded(String orderNo, long amountFen, String channel,
                          String transactionId, Instant occurredAt) -> {
            orderService.markPaid(orderNo, amountFen);
            notifyService.push(orderNo, "支付成功");
        }

        case PayFailed(String orderNo, long amountFen, String reason,
                       String rawCode, Instant occurredAt) -> {
            orderService.markFailed(orderNo, reason);
            metrics.counter("pay.failed." + rawCode).increment();
        }

        case PayRefunded(String orderNo, long amountFen,
                         RefundDetail(String refundNo, String reason),
                         Instant occurredAt) -> {
            orderService.markRefunded(orderNo, amountFen);
            auditLog.write(refundNo, reason);
        }

        case PayPending(String orderNo, long amountFen,
                        Instant expireAt, Instant occurredAt) -> {
            orderService.markPending(orderNo, expireAt);
        }
    }
}

几件事同时发生了。

类型判断和强转合二为一。不用再写 instanceof 然后再 (PaySucceeded) event。模式匹配的语义是「如果它是这个类型,就把它绑定到这个名字上」。编译器在字节码层面只做一次类型检查,不会多一次强转。

变量从对象里「拆」了出来。case PaySucceeded(String orderNo, long amountFen, ...) 这个写法叫 record pattern。它按记录组件的声明顺序,把每个字段绑定到一个新变量上。之后在箭头右边直接用 orderNo 就行,不用再写 e.orderNo()。字段多的时候,这个写法省下来的视觉噪音相当可观。

嵌套结构可以一层拆到底。PayRefunded 里的 RefundDetail 本身也是个 record,所以在模式里直接嵌套一层就能拿到 refundNoreason。原来这套结构需要用 e.getDetail().getRefundNo() 一路点下去,还要担心 getDetail() 是不是会返回 null。现在模式匹配会一路校验:如果 detail 是 null,这个分支就不匹配,不会抛 NPE。

没有 default 分支。这是故意的,也是整篇文章的重点——留到下一节讲。

守卫条件

有时候分支的判断不只看类型,还要看数据。比如大额支付需要走额外的人工复核:

switch (event) {
    case PaySucceeded s when s.amountFen() >= 500_000L -> {
        riskService.requireManualReview(s.orderNo(), s.amountFen());
    }
    case PaySucceeded s -> {
        orderService.markPaid(s.orderNo(), s.amountFen());
    }
    // 其余分支...
}

when 的分支必须写在同类的不带守卫的分支前面,否则编译器会报错说后面的分支不可达。这个规则检查得挺严,写反了编译就过不去,不会留到运行时才发现。

null 的处理

Java 21 的 switch 模式匹配对 null 有个变化:如果 switch 的选择器是 null,又没有 case null 分支,会直接抛 NullPointerException。这个行为比以前的 switch 语句更明确,但用法上有个小陷阱——如果你确实要处理 null,得显式写出来:

switch (event) {
    case null -> log.warn("收到空的支付事件");
    case PaySucceeded s -> ...
    // ...
}

更省事的写法是 case null, default ->,把空值和兜底放在一起。但我们这个场景不该出现 null,所以我更愿意让它自然地抛 NPE,在调用入口做一次参数校验就行,不要在分支里吞掉。

五、穷尽性检查:这套东西真正的价值

回到最开始说的那个事故。现在假如有人要加一个「部分退款」事件:

public sealed interface PayEvent
        permits PaySucceeded, PayFailed, PayRefunded, PayPending, PartiallyRefunded {
    // ...
}

public record PartiallyRefunded(
        String orderNo, long amountFen, long refundedFen,
        Instant occurredAt
) implements PayEvent {}

保存这个文件之后,所有 switch 这个 PayEvent 的地方,编译器会一个个报错:

error: the switch expression does not cover all possible input values

它会精确地告诉你:PartiallyRefunded 这个分支没有处理。你有多少个地方在处理支付事件,就有多少个地方会亮红。改完之后再编译,红灯全灭,说明覆盖完整了。

这就是 sealed 加模式匹配组合起来的杀手锏。类型体系从「开放的、运行时才可能出问题的」变成了「封闭的、编译期就被检查的」。原来靠人力去搜索「还有哪些地方在处理这个事件」,现在搜索这一步被编译器接管了。

但要拿到这个收益,有个前提必须守住:不要写 default 分支。

只要写了 default ->,编译器就认为你表态了「剩下的情况我都归到这里处理」,穷尽性检查立刻失效。新加的类型会悄悄流进 default,你刚才看到的那一圈红灯一个都不会亮。

那什么时候该写 default?我的经验是两种。一种是处理外部不可信输入,确实需要兜底防止脏数据把服务打挂,这时候可以在 default 里打一条 error 级别的日志加报警。另一种是类型体系本来就不在你控制范围内——比如 switch 的是个枚举但你无法保证未来不扩展,而且这个 switch 是别人写完就不管的库代码。除了这两种,业务内部处理自己定义的 sealed 类型,都应该让它敞着,让编译器兜底。

六、和 Jackson、Spring 的配合

这块是实际落地时最容易卡住的地方,因为 sealed 和 record 是纯 Java 语言特性,而 JSON 序列化是 Jackson 的领域,两者需要手动接上。

多态反序列化:@JsonSubTypes 一个都少不了

反序列化 PayEvent 时,Jackson 需要知道 JSON 里的 type 字段对应哪个具体类:

@JsonTypeInfo(
    use = JsonTypeInfo.Id.NAME,
    include = JsonTypeInfo.As.PROPERTY,
    property = "type"
)
@JsonSubTypes({
    @JsonSubTypes.Type(value = PaySucceeded.class, name = "pay_succeeded"),
    @JsonSubTypes.Type(value = PayFailed.class,    name = "pay_failed"),
    @JsonSubTypes.Type(value = PayRefunded.class,  name = "pay_refunded"),
    @JsonSubTypes.Type(value = PayPending.class,   name = "pay_pending")
})
public sealed interface PayEvent
        permits PaySucceeded, PayFailed, PayRefunded, PayPending {
    // ...
}

注意这里有个遗憾:@JsonSubTypes 是一份和 permits 子句平行的清单。加了新的 record 之后,permits 忘了改会编译报错,但 @JsonSubTypes 忘了改不会——会一直等到线上收到那个类型的报文,抛一句 Could not resolve type id 才发现。

如果用的是 Spring Boot 3,可以在配置里开启密封类型的自动推断,让 Jackson 从 sealed 层次结构里推导子类型,不用手写 @JsonSubTypes。开启方式是往 ObjectMapper 里注册一个模块,或者直接在配置里打开对应开关。开启之后 @JsonTypeInfo 还是需要保留(它规定了 JSON 里用什么字段区分类型),但 @JsonSubTypes 那份手工清单可以删掉,唯一的真相源回到 permits 上。

我强烈建议做这一步。两个平行的清单迟早会漂移,而这类漂移只会在生产环境的某次回调里暴露。

那个 -parameters 编译参数

这是个几乎每个人都会撞一次的坑。Jackson 从 2.12 开始原生支持 record,但它的原理是拿构造器的参数名去和 JSON 的字段名做匹配。而 Java 默认编译时不会把参数名保留到 class 文件里——你写的参数名在字节码里变成了 arg0arg1

结果就是序列化正常(因为 record 的访问器名是确定的),反序列化报错:

InvalidDefinitionException: Cannot construct instance of `PaySucceeded`,
no Creators, like default constructor, exist

解决的方案是在编译时加上:

javac -parameters ...

// Maven
<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-compiler-plugin</artifactId>
  <configuration>
    <parameters>true</parameters>
  </configuration>
</plugin>

// Gradle
tasks.withType(JavaCompile).configureEach {
    options.compilerArgs << '-parameters'
}

顺带留意一点:Spring Boot 3.x 的父 POM 默认已经打开了这个参数,所以新建项目一般不会遇到。但从 Spring Boot 2.x 升上来的老项目,或者自己手写 build 配置的模块,很可能没开。升级 JDK 时一起检查一遍,能省一次深夜排查。

Spring MVC 返回值

如果 Controller 直接返回一个 sealed 类型,Jackson 序列化时会因为找不到 @JsonTypeInfo 而在 JSON 里丢掉类型信息,前端拿到的是一个没有 type 字段的对象,没法判断是哪种事件。要么在返回的 DTO 上加注解,要么干脆在 Controller 层转成明确的响应对象。我倾向后者——领域事件是内部模型,直接当 API 契约往外扔,耦合会越来越深。

七、迁移这类代码的实际顺序

让人一次把六百行 if 链改成 switch,风险太高,review 也 review 不动。我实际是分四步做的,每一步都能单独上线。

第一步:只做类型收口。abstract class PayEvent 改成 sealed interface,子类暂时不动,还是普通 class。这一步编译就能过,只是多了限制。跑一遍全量测试,确认没有哪里的反射或者继承被打破——有些老框架会动态生成子类,一旦遇到就会在这一步报错,早发现比晚发现好。

第二步:逐个类改 record。一次改一个,改完立刻跑单测。record 的访问器名和原来的 getter 名不同(orderNo() 对比 getOrderNo()),所有调用点都要改,编译器会逐个指出来,不会漏。这一步工作量最大但最机械。

第三步:逐个方法替换 if 链。还是保留 default 或者 else 分支,先把 instanceof 链换成 switch 模式匹配,行为完全不变,纯粹是写法变化。这一步验证的是模式匹配的解构顺序写对了没有——把字段顺序写错不会编译报错(只要类型兼容),会导致业务逻辑串位。所以这一步一定要有覆盖到每个字段的单元测试。

第四步:删掉 default 分支。这一步做完,穷尽性检查才真正生效。删之前先全局搜一遍,确认没有哪个 switch 是靠 default 兜着实际逻辑的。

整个过程大概花了两周,中间穿插了三次灰度发布,没有出过线上问题。回过头看,第二步(改 record)是最耗时的,但它也是收益最持久的——现在新加一个事件类型的成本,从「写八十行样板加四处搜索」变成了「写五行 record 加跟着编译器改三个地方」。

八、有几个地方我劝你别用

这套东西很好用,但不是所有类型体系都该密封。

会被第三方扩展的 SPI 接口不要密封。如果你的接口设计出来就是给外部团队实现的,那 permits 会把所有人挡在门外。这时候该用普通的 interface,穷尽性检查本来就不是你要的东西。

类型会频繁新增且外部有消费者的场景要谨慎。加一个子类型会让所有 switch 报错,这在业务内部是优点,但如果这个类型是发布出去的 SDK 契约,下游的每一次升级都会被你的新增打乱。这种情况下,可以在接口外层包一层「未知类型」的兜底实现,或者干脆用枚举加属性包的方式建模。

字段经常要改的类别急着改成 record。record 的组件是固定的,改一个字段就要改所有解构它的模式。如果一个类型的字段还在频繁变动,先用普通类,等结构稳定了再改。

还有一点,别为了用而用。如果一个 switch 只有两个分支,而且未来也不会增加,那用不用 sealed 差别不大。真正值得改的是那种「分支多、逻辑分散在多个文件、新人来了不知道有几个实现」的场景。判断标准就是:你上一次搜索「还有哪里在处理这个事件」是什么时候?如果搜过,就值得改。

写在最后

刚看到模式匹配这些特性的时候,我的第一反应是「语法糖」。用了大半年之后,想法变了。真正有价值的不是少写几个字符,而是它让「所有可能的情况都在这里处理了」这件事,从一句口头承诺变成了一个编译器能验证的事实

写业务代码时间长了会发现,很多线上事故的根因不是逻辑写错了,而是「某处遗漏了」。加了一个状态枚举、加了一个事件类型、加了一种支付渠道,某个角落的处理逻辑没跟上。这类 Bug 测试不容易覆盖,代码审查容易滑过去,因为问题在于「没写的东西」而不是「写错的东西」。sealed 加模式匹配,恰好就是对着这个问题来的。

如果你手上正好有一个还在用 instanceof 链的老服务,不用大动。挑一个分支最多、最近半年改过两次以上的方法,按第一节那四步先改一个小角落。改完之后加一个假的子类型进去编译一次,看看编译器会不会告诉你哪里没改。看到了那一片红灯,你就明白这套东西好在哪了。

sealed interface + record 重构支付回调:Java 模式匹配实战与穷尽性检查
收藏 (0) 打赏

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

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

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

淘吗网 java sealed interface + record 重构支付回调:Java 模式匹配实战与穷尽性检查 https://www.taomawang.com/server/java/2807.html

常见问题

相关文章

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

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