从一个真实的代码痛点开始
我上个月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的项目,不必急着全量重写。我的建议是:
- 先挑选一个状态数量有限、但逻辑比较复杂的领域对象(比如订单、工单、审批流),用密封接口和record重写它的状态体系。
- 保持原来的状态转换代码不动,在新的状态机类里用模式匹配重写转换逻辑,通过单元测试确保行为一致。
- 逐步将service里的旧代码替换为调用状态机的方法,每次替换后跑一遍测试。
- 全部替换完成后,删除旧的状态类,享受编译期检查带来的安全感。
这个过程很平滑,不需要停服或者大规模重构。而且因为新旧代码能共存一段时间,风险完全可控。
收尾:类型安全不是口号,是编译器给你的承诺
写完这个状态机之后,我在团队的分享会上演示了一个场面:把Cancelled状态从permits里删除,然后展示全项目十几个编译错误;再把它加回来,错误全部消失。有个同事当场说了一句话:“这不就是穷举检查吗?以前我们用枚举也能做到啊。”他说得没错,枚举确实也能穷举状态,但枚举不能携带状态数据,也不能为不同的状态定义不同的字段结构。你可以用枚举搭配一个巨大的上下文对象来模拟,但那又回到了把所有字段揉在一起的老路上。
密封类和模式匹配的组合,让你既能享受穷举检查的安全性,又能让每个状态拥有属于自己的数据结构。这正是类型系统应该发挥的价值——不是束缚你的手脚,而是帮你把那些容易疏漏的细节变成编译器强制执行的规则。
如果你的项目已经跑在Java 21上,不妨找一个你一直在忍受的if-else大段代码,试试用这套方式改一改。我猜你会像我当时一样,改完之后忍不住把其他模块也重构了一遍。

