Java 21+ 的 Pattern Matching:如何用密封类和记录类重构业务规则引擎

用订单状态机和风控规则匹配的真实案例,演示 Pattern Matching + Sealed Classes + Records 如何替代臃肿的 if-else 和 Visitor 模式,对比重构前后的代码复杂度和可维护性。

做过业务系统的人都知道,规则引擎的代码写到最后会变成什么样子。

几百行的 if-else 嵌套,十几个 instanceof 判断,加上散落各处的类型强转。每次加一条新规则,都得在多个文件里小心翼翼地改。漏改一处,线上就炸。

Java 21 正式定稿了 Pattern Matching for Switch(JEP 441),配合 Sealed Classes(JEP 409)和 Records(JEP 395),这三个特性组合起来,能从根本上改变业务规则的写法法。

不是语法糖。是范式转换。

先看清楚问题:传统规则引擎有多痛

拿一个电商订单系统举例。订单有不同的类型,每种类型有不同的处理逻辑。

普通订单、预售订单、拼团订单、虚拟商品订单。每种订单在支付成功后,走的流程完全不同。

传统写法大概是这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class OrderProcessor {
    public void process(Order order) {
        if (order instanceof NormalOrder) {
            NormalOrder no = (NormalOrder) order;
            // 普通订单处理逻辑
            handleNormalOrder(no);
        } else if (order instanceof PreSaleOrder) {
            PreSaleOrder po = (PreSaleOrder) order;
            // 预售订单处理逻辑
            handlePreSaleOrder(po);
        } else if (order instanceof GroupBuyOrder) {
            GroupBuyOrder go = (GroupBuyOrder) order;
            // 拼团订单处理逻辑
            handleGroupBuyOrder(go);
        } else if (order instanceof VirtualOrder) {
            VirtualOrder vo = (VirtualOrder) order;
            // 虚拟商品订单处理逻辑
            handleVirtualOrder(vo);
        } else {
            throw new IllegalArgumentException("未知订单类型: " + order.getClass());
        }
    }
}

这段代码的问题,写过的人都清楚。

第一,类型不安全。 instanceof 判断和类型强转是分开的两步。编译器没法帮你检查有没有漏掉某个子类型。

第二,扩展性差。 新增一种订单类型,你得找到所有涉及订单类型判断的地方,逐个添加分支。一个漏掉,就是运行时的 IllegalArgumentException

第三,代码膨胀。 如果订单类型有十几种,每个分支里的处理逻辑又有几十行,这个方法轻松突破五百行。读代码的人根本找不到重点。

有人会说,用 Visitor 模式可以解决。确实可以,但 Visitor 模式的代价也不小。

Visitor 模式的代价

Visitor 模式通过双重分派,把类型判断和具体处理逻辑分开。结构上确实更清晰。

但它要求每个被访问的类都实现 accept 方法,要求定义 Visitor 接口,要求在每个子类里调用 visitor.visit(this)

对于简单的业务规则匹配来说,这套机制太重了。

更关键的问题是——Visitor 模式本身也不能让编译器帮你检查穷尽性。你新增了一个订单子类,但忘了在 Visitor 里添加对应的 visit 方法,编译器不会报错。运行时才会抛异常。

这就引出了 Java 21 方案的核心优势:编译器级别的穷尽性检查

三件套:Records + Sealed Classes + Pattern Matching

先说这三个特性各自解决什么问题。

Records 解决的是数据建模。用一行代码定义一个不可变的数据载体,自动生成 equalshashCodetoString。比传统的 POJO 干净太多。

Sealed Classes 解决的是类型封闭。一个密封类明确声明哪些类可以继承它。编译器因此知道了这个类型的所有可能子类型。

Pattern Matching for Switch 解决的是分派逻辑。在 switch 里直接做类型匹配,同时绑定变量,不需要手动强转。

三者组合在一起的效果:你用 Records 定义业务数据的形状,用 Sealed Classes 限定类型的范围,用 Pattern Matching 做穷尽的类型分派。编译器会检查你覆盖了所有情况。漏一个就编译不过。

这才是真正的设计闭环。

实战一:用三件套重构订单状态机

先看订单类型怎么用 Records + Sealed 来建模。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public sealed interface Order
    permits NormalOrder, PreSaleOrder, GroupBuyOrder, VirtualOrder {}

public record NormalOrder(
    String orderId,
    BigDecimal amount,
    List<OrderItem> items
) implements Order {}

public record PreSaleOrder(
    String orderId,
    BigDecimal amount,
    LocalDate estimatedDelivery
) implements Order {}

public record GroupBuyOrder(
    String orderId,
    BigDecimal amount,
    String groupId,
    int requiredMembers
) implements Order {}

public record VirtualOrder(
    String orderId,
    BigDecimal amount,
    String redeemCode
) implements Order {}

每个订单类型都是一个 Record,数据结构一目了然。密封接口 Order 限定了只有这四种实现。

再来看处理逻辑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public String processOrder(Order order) {
    return switch (order) {
        case NormalOrder o ->
            "创建发货单,通知仓库拣货,订单号: " + o.orderId();
        case PreSaleOrder o ->
            "标记预售,等待到货后发货,预计: " + o.estimatedDelivery();
        case GroupBuyOrder o ->
            "加入拼团队列,当前团号: " + o.groupId()
            + ",还差 " + o.requiredMembers() + " 人";
        case VirtualOrder o ->
            "发送兑换码: " + o.redeemCode();
    };
}

注意这里没有 default 分支。因为 Order 是密封的,编译器知道只有四种子类型。switch 覆盖了全部四种,编译器确认穷尽,不需要 default

如果你将来加了第五种订单类型——比如 SubscriptionOrder——但忘了在 switch 里添加对应分支,编译器直接报错。不是运行时崩溃,是编译期拦截。

这个保证,在传统的 if-else 和 Visitor 模式里都是做不到的。

实战二:风控规则引擎

风控是另一个典型场景。一笔交易进来,需要经过多条规则的匹配和判断。

传统写法通常是这样的:一堆 if 条件嵌套,每个条件检查交易的不同属性。规则之间还有优先级和互斥关系。代码写到最后,连作者自己都看不懂。

用三件套来重构,思路很清晰。

首先,把风控事件建模为密封类型:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public sealed interface RiskEvent
    permits HighAmountEvent, FrequentTransactionEvent,
            NewDeviceEvent, CrossBorderEvent, BlacklistEvent {}

public record HighAmountEvent(
    String userId, BigDecimal amount, String merchantId
) implements RiskEvent {}

public record FrequentTransactionEvent(
    String userId, int countInLastHour, BigDecimal totalAmount
) implements RiskEvent {}

public record NewDeviceEvent(
    String userId, String deviceId, String ipAddress
) implements RiskEvent {}

public record CrossBorderEvent(
    String userId, String fromCountry, String toCountry,
    BigDecimal amount
) implements RiskEvent {}

public record BlacklistEvent(
    String userId, String matchedEntry, String listSource
) implements RiskEvent {}

然后,风控决策也用密封类型来表达:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public sealed interface RiskDecision
    permits Approve, ManualReview, Reject, ChallengeVerify {}

public record Approve(String reason) implements RiskDecision {}
public record ManualReview(String reason, int priority)
    implements RiskDecision {}
public record Reject(String reason, String ruleCode)
    implements RiskDecision {}
public record ChallengeVerify(String verifyMethod)
    implements RiskDecision {}

规则匹配逻辑就可以用 Pattern Matching 来写了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public RiskDecision evaluate(RiskEvent event) {
    return switch (event) {
        case BlacklistEvent e ->
            new Reject("命中黑名单: " + e.matchedEntry(),
                       "RULE_BL_001");
        case HighAmountEvent e when e.amount().compareTo(
            new BigDecimal("50000")) > 0 ->
            new ManualReview("大额交易: " + e.amount(), 1);
        case HighAmountEvent e ->
            new Approve("金额在阈值内");
        case FrequentTransactionEvent e when e.countInLastHour() > 10 ->
            new ChallengeVerify("SMS");
        case FrequentTransactionEvent e when e.totalAmount().compareTo(
            new BigDecimal("20000")) > 0 ->
            new ManualReview("高频且累计金额大", 2);
        case FrequentTransactionEvent e ->
            new Approve("频率正常");
        case NewDeviceEvent e ->
            new ChallengeVerify("FACE");
        case CrossBorderEvent e when e.amount().compareTo(
            new BigDecimal("10000")) > 0 ->
            new ManualReview("跨境大额", 2);
        case CrossBorderEvent e ->
            new Approve("跨境小额,放行");
    };
}

注意 when 关键字。这是 Pattern Matching 里的 Guard(守卫条件),可以在模式匹配的基础上再加一层布尔判断。相当于把 if 条件内联到了 switchcase 里。

这段代码的可读性非常好。每一条规则都是一个 case,从上到下就是规则的优先级顺序。新人接手,读一遍就知道系统有哪些风控策略。

Guard 条件的威力

when 守卫是 Pattern Matching 里非常实用的特性。它让你在不牺牲类型安全的前提下,表达复杂的业务条件。

再看一个例子。假设订单系统里,对不同类型的订单有不同的折扣策略:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
public BigDecimal calculateDiscount(Order order, CustomerLevel level) {
    return switch (order) {
        case NormalOrder o when level == CustomerLevel.VIP ->
            o.amount().multiply(new BigDecimal("0.15"));
        case NormalOrder o when level == CustomerLevel.GOLD ->
            o.amount().multiply(new BigDecimal("0.10"));
        case NormalOrder o ->
            o.amount().multiply(new BigDecimal("0.05"));
        case PreSaleOrder o ->
            BigDecimal.ZERO; // 预售不参与折扣
        case GroupBuyOrder o when o.requiredMembers() >= 5 ->
            o.amount().multiply(new BigDecimal("0.20"));
        case GroupBuyOrder o ->
            o.amount().multiply(new BigDecimal("0.10"));
        case VirtualOrder o ->
            BigDecimal.ZERO; // 虚拟商品不参与折扣
    };
}

类型匹配和条件判断融合在一个表达式里。不需要先判断类型、再嵌套 if。代码的层次扁平了,意图也更清晰。

嵌套模式匹配:处理复杂事件

实际业务中,事件的结构往往不止一层。风控事件的背后可能还有嵌套的上下文信息。

Records 天然支持嵌套。Pattern Matching 也支持解构嵌套的 Record。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public record TransactionContext(
    RiskEvent event,
    CustomerProfile profile,
    DeviceInfo device
) {}

public record CustomerProfile(
    String userId, int accountAge, boolean verified
) {}

public record DeviceInfo(
    String deviceId, boolean trusted, String country
) {}

处理逻辑可以逐层解构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
public RiskDecision evaluateContext(TransactionContext ctx) {
    return switch (ctx) {
        case TransactionContext(
            BlacklistEvent e, var profile, var device) ->
            new Reject("黑名单命中: " + e.matchedEntry(),
                       "RULE_BL_001");
        case TransactionContext(
            HighAmountEvent e,
            CustomerProfile(var id, int age, boolean verified),
            DeviceInfo(var did, boolean trusted, var country))
            when age < 30 && !verified ->
            new ManualReview("新账户大额未实名", 1);
        case TransactionContext(
            var event,
            CustomerProfile(var id, int age, boolean verified),
            DeviceInfo(var did, boolean trusted, var country))
            when !trusted && "CN".equals(country) ->
            new ChallengeVerify("SMS");
        default ->
            new Approve("上下文检查通过");
    };
}

嵌套解构让多层数据结构的访问变得直接。不需要一层层 get 方法调用,模式本身就完成了数据的拆解和绑定。

不过要注意,嵌套层数太深会降低可读性。超过三层嵌套的时候,考虑拆分方法会更好。

对比:重构前后的量化差异

说了这么多,来看实际的对比数据。

以一个包含 8 种订单类型、15 条处理规则的系统为例:

维度 if-else 写法 Visitor 模式 Pattern Matching
核心代码行数 ~380 行 ~320 行 ~180 行
新增类型需修改的文件数 3-5 个 2-3 个 1 个
编译器穷尽性检查
类型强转次数 8 次 0 次 0 次
新人理解成本

代码行数的减少只是一方面。更重要的是「新增类型需修改的文件数」这个指标。

传统写法里,新增一个订单类型,你得改 Order 类本身、改 Processor、改 Validator、改 Converter、可能还要改 DTO 映射。漏改一个就是线上事故。

用三件套重构之后,新增一个类型只需要:在 sealed interface 的 permits 列表里加上它,然后在涉及该类型的 switch 里补一个 case。编译器会告诉你哪些 switch 还没覆盖新类型。改完就完事了。

状态机的 Pattern Matching 写法

订单状态机是另一个高频场景。订单从创建到完成,会经历多个状态流转。每个状态下能执行的动作不同,状态之间的转换也有严格的规则。

用密封类型来建模状态:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
public sealed interface OrderState
    permits Created, Paid, Shipped, Delivered,
            Refunding, Completed, Cancelled {}

public record Created(LocalDateTime createdAt)
    implements OrderState {}
public record Paid(LocalDateTime paidAt, String payChannel)
    implements OrderState {}
public record Shipped(LocalDateTime shippedAt, String trackingNo)
    implements OrderState {}
public record Delivered(LocalDateTime deliveredAt)
    implements OrderState {}
public record Refunding(LocalDateTime requestedAt, String reason)
    implements OrderState {}
public record Completed(LocalDateTime completedAt)
    implements OrderState {}
public record Cancelled(LocalDateTime cancelledAt, String reason)
    implements OrderState {}

状态转换规则:

1
2
3
4
5
6
7
public sealed interface OrderAction
    permits PayAction, ShipAction, DeliverAction,
            RefundAction, CancelAction, CompleteAction {}

public record PayAction(String channel) implements OrderAction {}
public record ShipAction(String trackingNo) implements OrderAction {}
// ... 其他 Action

转换逻辑用 Pattern Matching 来表达,非常自然:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public OrderState transition(OrderState current, OrderAction action) {
    return switch (current) {
        case Created c -> switch (action) {
            case PayAction p ->
                new Paid(LocalDateTime.now(), p.channel());
            case CancelAction ca ->
                new Cancelled(LocalDateTime.now(), ca.reason());
            default -> throw new IllegalStateException(
                "状态 Created 不支持动作: " + action);
        };
        case Paid p -> switch (action) {
            case ShipAction s ->
                new Shipped(LocalDateTime.now(), s.trackingNo());
            case RefundAction r ->
                new Refunding(LocalDateTime.now(), r.reason());
            default -> throw new IllegalStateException(
                "状态 Paid 不支持动作: " + action);
        };
        case Shipped s -> switch (action) {
            case DeliverAction d ->
                new Delivered(LocalDateTime.now());
            case RefundAction r ->
                new Refunding(LocalDateTime.now(), r.reason());
            default -> throw new IllegalStateException(
                "状态 Shipped 不支持动作: " + action);
        };
        case Delivered d -> switch (action) {
            case CompleteAction ca ->
                new Completed(LocalDateTime.now());
            case RefundAction r ->
                new Refunding(LocalDateTime.now(), r.reason());
            default -> throw new IllegalStateException(
                "状态 Delivered 不支持动作: " + action);
        };
        case Refunding r -> switch (action) {
            case CancelAction ca ->
                new Cancelled(LocalDateTime.now(), ca.reason());
            default -> throw new IllegalStateException(
                "状态 Refunding 不支持动作: " + action);
        };
        case Completed c, Cancelled c -> throw new IllegalStateException(
            "终态不允许任何动作: " + current);
    };
}

这段代码把整个状态机的转换规则表达得非常清楚。哪个状态下支持哪些动作,一目了然。

注意到 case Completed c, Cancelled c 这种写法——多个模式可以合并到一个分支里,前提是它们不需要提取不同的字段。

性能:别担心,JVM 比你聪明

有人会问,switch 里的 Pattern Matching 性能怎么样?会不会比直接 if-else 慢?

答案是:不会。甚至可能更快。

JVM 对 Pattern Matching 的 switch 做了专门的优化。对于密封类型的 switch,编译器会生成 tableswitchlookupswitch 字节码指令,时间复杂度是 O(1) 或 O(log n)。比一连串的 instanceof 判断(O(n))要快。

Record 类的模式匹配还有额外的优化。因为 Record 的字段是 final 的、结构是确定的,JVM 可以直接对字段做比较,不需要走虚方法分派。

在 HotSpot C2 编译器的优化下,Pattern Matching 的性能和手写的 if-else 基本持平,甚至在分支较多的情况下更优。

所以性能不是你需要担心的问题。放心用。

迁移策略:从旧代码平滑过渡

实际项目里不可能一次性全部重构。需要渐进式迁移。

第一步,引入 Records。 这个最简单,对现有代码没有侵入性。新建的数据模型直接用 Record,旧的 POJO 保持不变。

第二步,引入 Sealed Classes。 给现有的类型层次结构加上 sealedpermits。这一步可能会涉及接口和抽象类的修改,但不影响业务逻辑。

第三步,逐步替换 if-else 为 switch pattern。 从最核心、分支最多的方法开始改。每次只改一个方法,确保测试通过后再改下一个。

第四步,去掉 Visitor。 如果你的项目里有 Visitor 模式的实现,可以在 Pattern Matching 覆盖完毕之后,逐步移除 Visitor 接口和相关的 accept 方法。

整个迁移过程可以在几个迭代内完成。每一步都是独立的、可验证的。不需要大爆炸式的重构。

和 Spring 生态的配合

Spring Boot 3.x 已经全面支持 Java 21。在实际项目中,Pattern Matching 可以和 Spring 的各种组件无缝配合。

在 Controller 层,可以直接用 Pattern Matching 处理不同类型的请求体:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@PostMapping("/orders/process")
public ResponseEntity<?> processOrder(@RequestBody Order order) {
    var result = switch (order) {
        case NormalOrder o -> orderService.processNormal(o);
        case PreSaleOrder o -> orderService.processPreSale(o);
        case GroupBuyOrder o -> orderService.processGroupBuy(o);
        case VirtualOrder o -> orderService.processVirtual(o);
    };
    return ResponseEntity.ok(result);
}

在事件监听器里,可以用 Pattern Matching 处理不同类型的领域事件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@EventListener
public void handleOrderEvent(OrderEvent event) {
    switch (event) {
        case OrderCreatedEvent e ->
            notificationService.sendConfirmation(e.orderId());
        case OrderPaidEvent e ->
            inventoryService.reserveStock(e.orderId(), e.items());
        case OrderShippedEvent e ->
            notificationService.sendTrackingInfo(
                e.orderId(), e.trackingNo());
        case OrderCancelledEvent e ->
            inventoryService.releaseStock(e.orderId());
    }
}

Spring 的 JSON 反序列化也支持密封类型。配合 Jackson 的多态反序列化注解,可以直接把 JSON 请求体反序列化为密封接口的具体实现。

注意事项和坑

有几个实际使用中容易踩的坑,提前说一下。

密封类必须在同一个模块内。 permits 的子类必须和密封类在同一个模块(module)里。如果你的项目是多模块的 Maven/Gradle 项目,需要注意模块边界。跨模块的子类不能参与密封。

switch 表达式必须有返回值。 如果你用 switch 作为表达式(赋值给变量或作为返回值),所有分支必须覆盖或者提供 default。编译器会严格检查。

Guard 条件里不要有副作用。 when 后面的守卫表达式应该是纯函数。如果守卫里有副作用(比如修改了外部状态),可能导致难以预测的行为。因为 JVM 可能对模式匹配做优化重排。

Record 的解构有层级限制。 虽然语法上支持深层嵌套解构,但超过三层嵌套的 Record 解构会严重影响可读性。超过三层建议拆分成多个方法。

和 Lombok 的关系。 如果你用了 Records,就不需要 Lombok 的 @Data@Value 了。Record 自带这些功能。但如果你用 Lombok 生成 Builder,Record 不支持(Record 是不可变的,没有 setter)。需要 Builder 模式的场景,还是得用传统 class。

什么时候不该用

Pattern Matching 不是万能的。有几个场景不太适合。

规则动态可配的场景。 如果你的业务规则需要从数据库或配置中心动态加载,Pattern Matching 帮不了你。它是编译期确定的静态分派。动态规则还是需要传统的规则引擎框架(如 Drools、Easy Rules)。

类型层次非常深的场景。 如果你的类型体系有三四层继承,每个层级都有多个子类型,Pattern Matching 的 switch 会变得很长。这时候考虑在层级间做分层处理,每个层级一个 switch。

需要运行时反射的场景。 Pattern Matching 是编译期特性。如果你需要在运行时动态判断类型(比如从 JSON 反序列化后做分派),还是得用 instanceof 或 Class 对象来判断。

向前看:Java 未来的方向

Pattern Matching 还在演进中。Java 后续的预览特性里,还有几个值得关注的方向。

Record Patterns 的增强。 目前 Record 解构已经可用,但未来可能会支持更灵活的模式,比如部分字段匹配、通配符模式等。

Value Classes。 Project Valhalla 带来的值类型,会让 Record 在内存布局和性能上有进一步提升。未来的 Record 可能是 inline class,没有对象头的开销。

Deconstruction Patterns。 目前只有 Record 支持解构。未来普通 class 也可能支持自定义的解构模式,让 Pattern Matching 的适用范围更广。

Java 语言在类型安全和表达力方面,正在快速追赶 Kotlin 和 Scala。Pattern Matching 三件套是这个方向上的重要一步。

写在代码里的建议

如果你打算在项目中引入这套方案,这里有几条实操建议。

从新项目开始用。老项目的迁移成本高,新项目可以直接用 Records 建模、Sealed Classes 约束类型、Pattern Matching 做分派。

建立团队的建模规范。哪些领域对象用 Record,哪些用 Sealed Interface,需要一个团队共识。不是所有数据都适合 Record,也不是所有类型层次都需要密封。

单元测试覆盖每个 switch 分支。Pattern Matching 保证了编译期的穷尽性,但业务逻辑的正确性还是需要测试来保证。每个 case 至少一个测试用例。

利用编译器的错误提示。新增类型后,编译器的报错信息会精确告诉你哪些 switch 需要更新。把这些报错当作 checklist,逐个修复。

这套组合拳的威力,在于把业务规则的「完整性」从人的责任心转移到了编译器。人会忘记改代码,编译器不会。这才是真正的生产力提升。

本博客文章采用 CC BY-NC-SA 4.0 许可协议
服务器推荐

腾讯云 · 新用户专属优惠

本博客部署在腾讯云服务器,稳定运行一年多。如果你是新用户或想搭建个人项目,推荐试试腾讯云的优惠活动。

查看优惠详情 →
阅读
上一篇
零信任架构怎么落地:从 mTLS 到 SPIFFE 身份体系的工程化路径
下一篇
大模型应用的"最后一公里":Evaluation-Driven Development 实践方法论
广告

📚 关注公众号,免费获取技术材料

扫码关注公众号,回复「资料」领取:

  • 📘 企业架构设计模板
  • 📗 数据治理实施指南
  • 📙 工业软件技术白皮书
公众号二维码

长按或扫描二维码