Java 21模式匹配与密封类实战:构建类型安全的订单状态机,告别instanceof地狱

2026-07-05 0 748

从一堆if-else到编译器帮你检查所有状态——模式匹配密封类带来的不止是语法糖

从一个真实的代码痛点开始

我上个月review了一个电商项目的订单模块,看到一段让我血压升高的大段代码。大概逻辑是:订单在不同状态下需要执行不同的操作——待支付时可以取消、可以支付;已支付后可以退款、可以发货;已发货后可以确认收货、可以申请售后;已完成就不能再动了。代码是用经典的“状态模式”写的,定义了一个抽象的OrderState接口,然后每个状态写一个实现类,最后在OrderService里用一大串instanceof来判断当前状态类型,再调用具体的方法。

那段代码大概长这样:

if (order.getState() instanceof PendingPayment) {
    // 处理待支付逻辑
} else if (order.getState() instanceof Paid) {
    // 处理已支付逻辑
} else if (order.getState() instanceof Shipped) {
    // 处理已发货逻辑
} else if (order.getState() instanceof Completed) {
    // 已完成,不允许操作
} else {
    throw new IllegalStateException("未知状态");
}
            

这种写法有几个明显的问题。第一,如果哪天新增了一个状态,编译器不会给你任何提示,只能在运行时发现那个else分支被触发然后抛异常。第二,每个分支里都需要手动进行类型转换,哪怕你刚用instanceof判断过,也得再强转一次才能访问子类特有的方法。第三,状态转换的逻辑散落在各个service和controller里,完全没有统一的管理。

那次code review之后,恰好项目要升级到Java 21,我就顺手用模式匹配和密封类把整个状态机重写了一遍。效果出乎意料地好:代码量减少了大约40%,所有状态都被编译器严格检查,新增一个状态如果忘记处理,编译就直接报错。这篇文章就完整记录下这个重构过程,希望能给你一个可以直接参考的实践模板。

密封类和模式匹配分别解决什么问题

在动手写代码之前,有必要用最简单的语言把这两个特性讲清楚。它们是搭档,配合使用时威力最大。

密封类(Sealed Classes)允许你明确指定一个类或接口只能被哪些类实现。比如你定义一个OrderState接口,然后声明permits PendingPayment, Paid, Shipped, Completed,那其他人就无法再创建第五个实现类了。这个信息会保存在字节码里,编译器可以利用它做很多事情。

模式匹配(Pattern Matching)在switch语句里把instanceof、类型转换和变量绑定合并成一步。你不再需要写if (x instanceof A) { A a = (A) x; ... },直接写case A a -> ...就行。更重要的是,当switch作用在一个密封类上时,编译器会检查你的case分支是否覆盖了所有可能的类型。如果漏了某一个,编译时就报“switch语句未覆盖所有可能值”。

一句话总结:密封类限定了“可能出现的类型有哪些”,模式匹配让编译器强制你“处理每一个可能的类型”。两者叠加,等于给状态机上了一道编译期的安全锁。

定义密封的订单状态体系

我们从一个干净的接口开始。创建一个OrderState接口,用sealed关键字标记它,并用permits列出所有允许的实现类:

public sealed interface OrderState
    permits PendingPayment, Paid, Shipped, Completed, Cancelled, Refunding {

    String displayName();
}
            

这里我多加了两个状态——Cancelled(已取消)和Refunding(退款中),让案例更贴近真实业务。每个实现类都用record来写,因为状态对象本身就是不可变的数据载体,用record简洁又安全。

public record PendingPayment() implements OrderState {
    @Override
    public String displayName() { return "待支付"; }
}

public record Paid(String paymentNo) implements OrderState {
    @Override
    public String displayName() { return "已支付"; }
}

public record Shipped(String trackingNumber, LocalDateTime shippedAt) implements OrderState {
    @Override
    public String displayName() { return "已发货"; }
}

public record Completed(LocalDateTime completedAt) implements OrderState {
    @Override
    public String displayName() { return "已完成"; }
}

public record Cancelled(String reason) implements OrderState {
    @Override
    public String displayName() { return "已取消"; }
}

public record Refunding(String refundId, BigDecimal amount) implements OrderState {
    @Override
    public String displayName() { return "退款中"; }
}
            

看到每个状态携带的字段了吗?待支付状态下不携带任何额外信息,而已支付状态带了一个支付单号,已发货状态带了快递单号和发货时间。这种“状态自带数据”的设计,让状态对象自己就能完整描述当前订单所处阶段的所有上下文信息。以前用传统状态模式时,这些数据往往要存在订单主表里,或者用通用的Map来存,类型安全完全靠自觉。

用模式匹配处理状态转换:编译器帮你兜底

现在我们有了一组密封的状态类,接下来写订单服务中处理“用户点击某个按钮后订单状态如何变化”的核心逻辑。每个操作对不同的当前状态有不同的响应:

  • “支付”操作:对“待支付”状态有效,执行后状态变为“已支付”。
  • “发货”操作:对“已支付”状态有效,执行后状态变为“已发货”。
  • “确认收货”操作:对“已发货”状态有效,执行后状态变为“已完成”。
  • “取消”操作:只对“待支付”有效。
  • “申请退款”操作:对“已支付”或“已发货”状态有效。

传统写法是一个方法里堆满if-else,现在我们用增强型switch来处理。以“发货”操作为例:

public OrderState shipOrder(OrderState currentState, String trackingNo) {
    return switch (currentState) {
        case Paid paid -> new Shipped(trackingNo, LocalDateTime.now());
        case PendingPayment pending -> {
            log.warn("待支付订单无法发货");
            throw new IllegalStateException("订单尚未支付");
        }
        case Shipped shipped -> {
            log.warn("订单已发货,不能重复发货");
            throw new IllegalStateException("订单已发货");
        }
        case Completed completed -> throw new IllegalStateException("已完成订单无法发货");
        case Cancelled cancelled -> throw new IllegalStateException("已取消订单无法发货");
        case Refunding refunding -> throw new IllegalStateException("退款中订单无法发货");
    };
}
            

这段代码里最值得关注的有三点:

第一,每个case后面直接跟着模式变量,比如case Paid paid,paid就是那个分支里的变量,不需要再手动转换类型。你可以直接访问paid.paymentNo(),编译器完全知道它的类型。

第二,因为OrderState是密封接口,编译器知道它只有这六个实现类。如果你漏写了任何一个case,编译就会报错。假设产品经理后来新增了一个PartialRefund状态,你把这个状态加到permits列表里,所有用到模式匹配的地方都会标红,提醒你“这里需要处理新状态”。这种编译期的保障,以前靠default分支抛异常是做不到的——default只会默默吞掉所有未知情况,运行时才暴露问题。

第三,返回值直接就是新的状态对象,整个状态转换是函数式的、无副作用的。你不需要修改一个全局的订单对象,只需要用新状态替换旧状态。这种写法让单元测试变得极其简单:输入一个状态对象,断言返回的状态对象是否符合预期。

带上守卫条件:同一个状态的不同分支

真实业务里,“申请退款”这个操作对“已支付”和“已发货”两个状态都有效,但在“已发货”状态下,还需要判断是否在7天无理由退货期内。模式匹配的case支持用when关键字加上守卫条件,处理这种更细粒度的逻辑:

public OrderState applyRefund(OrderState currentState) {
    return switch (currentState) {
        case Paid paid -> new Refunding(UUID.randomUUID().toString(), new BigDecimal("0"));
        case Shipped shipped when shipped.shippedAt()
                .isAfter(LocalDateTime.now().minusDays(7)) -> {
            // 7天内发货的订单可以退款
            yield new Refunding(UUID.randomUUID().toString(), new BigDecimal("0"));
        }
        case Shipped shipped -> throw new IllegalStateException("已超过7天无理由退款期限");
        case PendingPayment p -> throw new IllegalStateException("待支付订单无需退款");
        case Completed c -> throw new IllegalStateException("已完成订单无法退款");
        case Cancelled c -> throw new IllegalStateException("已取消订单无法退款");
        case Refunding r -> throw new IllegalStateException("退款已在处理中");
    };
}
            

注意这里Shipped出现了两次,但第二个case带了一个when守卫。模式匹配按从上到下的顺序执行,第一个匹配到的case会命中。这种写法比在一个case块里写if-else清晰得多,每个条件分支都是独立的、可读的。

把多个操作整合到一个状态机类中

现在我们有了几个零散的方法:支付、发货、退款等等。可以创建一个专门的OrderStateMachine类,把这些操作集中管理,并对外提供统一的接口:

public class OrderStateMachine {

    public OrderState pay(OrderState current, String paymentNo) {
        return switch (current) {
            case PendingPayment p -> new Paid(paymentNo);
            case Paid paid -> throw new IllegalStateException("订单已支付,不能重复支付");
            case Shipped s -> throw new IllegalStateException("已发货,不能支付");
            case Completed c -> throw new IllegalStateException("已完成,不能支付");
            case Cancelled c -> throw new IllegalStateException("已取消,不能支付");
            case Refunding r -> throw new IllegalStateException("退款中,不能支付");
        };
    }

    public OrderState ship(OrderState current, String trackingNo) {
        return switch (current) {
            case Paid paid -> new Shipped(trackingNo, LocalDateTime.now());
            case PendingPayment p -> throw new IllegalStateException("未支付,不能发货");
            case Shipped s -> throw new IllegalStateException("已发货,不能重复发货");
            case Completed c -> throw new IllegalStateException("已完成,不能发货");
            case Cancelled c -> throw new IllegalStateException("已取消,不能发货");
            case Refunding r -> throw new IllegalStateException("退款中,不能发货");
        };
    }

    // 其他操作方法类似,省略
}
            

这样所有状态转换规则全部集中在一个类里,业务service只需要调用stateMachine.pay(order.getState(), paymentNo),然后用返回的新状态覆盖订单的旧状态即可。状态机内部的复杂判断对外部完全透明。

编译器成了你最严格的代码审查员

这个重构做完后,最让我感到安心的一次经历是这样的:产品经理在迭代中加入了一个“部分退款”状态PartialRefund。我做的第一件事是在OrderState接口的permits列表里加上PartialRefund,然后新建一个record。接下来,IDE里整整齐齐地亮起了十几个编译错误——所有用到模式匹配的switch语句都报“没有覆盖所有可能值”。我挨个打开这些文件,判断每个操作在“部分退款”状态下应该返回什么。整个过程像拿着清单逐一核对,不可能遗漏任何一处。

这种安全感是以前写if-else和instanceof时无法获得的。以前加一个新状态,只能在代码库里全局搜索instanceof,祈祷自己没有漏掉任何一个分支。很多线上bug就是这么埋下的——有一个角落的代码没有处理新状态,默默走了default分支,抛了一个通用的异常,用户看到的就是“系统繁忙请稍后再试”。

和传统的状态模式相比,到底好在哪里

你可能会说:“以前的状态模式也可以在状态接口里定义操作方法,让每个状态类自己去实现,不也一样不会漏吗?”没错,经典的状态模式确实避免了instanceof。但它有一个代价:状态类之间会产生循环依赖。比如PendingPayment类里的pay方法需要返回一个新的Paid实例,这本身没问题;但Paid类里的refund方法要返回Refunding实例,Refunding类里的completeRefund方法要返回Completed实例。每个状态类都引用了其他状态类,形成了复杂的依赖网。

而我们的模式匹配方案里,状态类本身没有任何行为逻辑,只是单纯的数据载体。所有的转换逻辑都集中在状态机类里,状态类之间完全解耦。你想改变“已支付”到“已发货”的转换规则,不需要动Paid类,只需要改状态机里的ship方法。

另外,记录类(record)的简洁性也是传统POJO无法比拟的。一个Shipped(String trackingNumber, LocalDateTime shippedAt)一行就搞定了,自动生成构造器、getter、equals和hashCode。状态对象的不可变性保证了并发安全,多个线程同时读取同一个订单状态不会有任何问题。

模式匹配的更多玩法:嵌套模式和解构

Java 21的模式匹配已经支持了嵌套模式,虽然在这个状态机案例里没有体现,但值得提一下。假设你有一个Order记录类里面包含OrderState字段,你可以直接在switch里解构:

record Order(String id, OrderState state, BigDecimal amount) {}

// 嵌套匹配(Java 21支持)
String action = switch (order) {
    case Order(var id, PendingPayment p, var amount) -> "订单" + id + "待支付";
    case Order(var id, Paid(var paymentNo), var amount) -> "订单" + id + "已支付,流水号:" + paymentNo;
    case Order(var id, Shipped(var tracking, var time), var amount) -> 
        "订单" + id + "已发货,快递单号:" + tracking;
    // ...
};
            

这种能力在处理深层嵌套的数据结构时特别有用,比如解析复杂的JSON树或者处理AST节点。虽然日常业务中不一定频繁用到,但一旦你理解了它,就会发现自己以前写的那些逐层判空的代码有多啰嗦。

实战建议:如何把现有项目迁移到这套模式

如果你正在维护一个使用了传统状态模式或者一堆if-else的项目,不必急着全量重写。我的建议是:

  1. 先挑选一个状态数量有限、但逻辑比较复杂的领域对象(比如订单、工单、审批流),用密封接口和record重写它的状态体系。
  2. 保持原来的状态转换代码不动,在新的状态机类里用模式匹配重写转换逻辑,通过单元测试确保行为一致。
  3. 逐步将service里的旧代码替换为调用状态机的方法,每次替换后跑一遍测试。
  4. 全部替换完成后,删除旧的状态类,享受编译期检查带来的安全感。

这个过程很平滑,不需要停服或者大规模重构。而且因为新旧代码能共存一段时间,风险完全可控。

收尾:类型安全不是口号,是编译器给你的承诺

写完这个状态机之后,我在团队的分享会上演示了一个场面:把Cancelled状态从permits里删除,然后展示全项目十几个编译错误;再把它加回来,错误全部消失。有个同事当场说了一句话:“这不就是穷举检查吗?以前我们用枚举也能做到啊。”他说得没错,枚举确实也能穷举状态,但枚举不能携带状态数据,也不能为不同的状态定义不同的字段结构。你可以用枚举搭配一个巨大的上下文对象来模拟,但那又回到了把所有字段揉在一起的老路上。

密封类和模式匹配的组合,让你既能享受穷举检查的安全性,又能让每个状态拥有属于自己的数据结构。这正是类型系统应该发挥的价值——不是束缚你的手脚,而是帮你把那些容易疏漏的细节变成编译器强制执行的规则。

如果你的项目已经跑在Java 21上,不妨找一个你一直在忍受的if-else大段代码,试试用这套方式改一改。我猜你会像我当时一样,改完之后忍不住把其他模块也重构了一遍。

Java 21模式匹配与密封类实战:构建类型安全的订单状态机,告别instanceof地狱
收藏 (0) 打赏

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

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

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

淘吗网 java Java 21模式匹配与密封类实战:构建类型安全的订单状态机,告别instanceof地狱 https://www.taomawang.com/server/java/2323.html

常见问题

相关文章

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

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