Java 21 模式匹配重构订单状态机:sealed 家族设计、switch 穷举与序列化坑

2026-10-18 0 652

我们后台订单模块里有这么一段代码,两百多行,处理各种订单状态的流转。它诞生的时间是 2019 年,那时候 JDK 8 是最新的 LTS,switch 只支持数字和字符串,别的手段有限。

这段代码,每次改状态的业务逻辑都是一场噩梦。哪个状态能转到哪个状态,全部藏在一层层的 if (order.getStatus() == 1) { ... } 里,没有一张图能画清楚。谁加逻辑,谁就怕踩错分支。

去年底项目升级到 JDK 21,我们终于把这段代码彻底重写了一遍。中间的过程有顺利的部分,也有好几个坑。下面把这些写下来。

先看看原来的代码什么样

订单状态原来是用一个枚举 OrderStatus 表示的:CREATED、PAID、SHIPPED、DELIVERED、COMPLETED、CANCELLED、REFUNDED。加上 Order 实体,以及一个 OrderStateMachine 用来处理流转。

流转的入口方法大概长这样:

public ActionResult transit(Order order, OrderAction action) {
    if (action == OrderAction.PAY) {
        if (order.getStatus() == OrderStatus.CREATED) {
            order.setStatus(OrderStatus.PAID);
            order.setPaidAt(now());
            notifyPaymentSuccess(order);
            return ActionResult.ok();
        }
        return ActionResult.fail("订单状态不允许支付");
    } else if (action == OrderAction.SHIP) {
        if (order.getStatus() == OrderStatus.PAID) {
            order.setStatus(OrderStatus.SHIPPED);
            order.setShippedAt(now());
            generateTrackNo(order);
            return ActionResult.ok();
        }
        return ActionResult.fail("订单状态不允许发货");
    } else if (action == OrderAction.DELIVER) {
        if (order.getStatus() == OrderStatus.SHIPPED) {
            order.setStatus(OrderStatus.DELIVERED);
            order.setDeliveredAt(now());
            return ActionResult.ok();
        }
        return ActionResult.fail("订单状态不允许确认收货");
    } else if (action == OrderAction.CONFIRM) {
        if (order.getStatus() == OrderStatus.DELIVERED) {
            order.setStatus(OrderStatus.COMPLETED);
            order.setCompletedAt(now());
            refundDeposit(order);
            return ActionResult.ok();
        }
        return ActionResult.fail("订单状态不允许完成");
    } else if (action == OrderAction.CANCEL) {
        if (order.getStatus() == OrderStatus.CREATED
            || order.getStatus() == OrderStatus.PAID) {
            order.setStatus(OrderStatus.CANCELLED);
            order.setCancelledAt(now());
            returnStock(order);
            refundIfPaid(order);
            return ActionResult.ok();
        }
        return ActionResult.fail("订单状态不允许取消");
    } else if (action == OrderAction.REFUND) {
        if (order.getStatus() == OrderStatus.COMPLETED
            || order.getStatus() == OrderStatus.DELIVERED) {
            order.setStatus(OrderStatus.REFUNDED);
            order.setRefundedAt(now());
            returnStock(order);
            refundMoney(order);
            return ActionResult.ok();
        }
        return ActionResult.fail("订单状态不允许退款");
    }
    return ActionResult.fail("未知操作");
}

这段代码的问题:一是”允许的操作”和”状态”分散在各处,不知道总共有多少条合法流转;二是新增一个状态的时候,必须把整个方法看一遍才知道要改几个地方;三是极容易漏分支——曾经有过 DELIVERED 状态漏了 REFUND,客服那边申请退款被系统拒绝,客诉。

为什么选择 sealed + record + switch 模式匹配

JDK 21 里 Pattern Matching for switch(JEP 441)正式转正,加上 JDK 17 就有的 sealed class 和 record。这套组合看着就是为状态机量身定做的。

我当时的想法很简单:把”状态”和”携带的数据”捆绑成一个 sealed 家族,让编译器帮我管住穷举性。这样新增一个状态,编译期就能直接报错,不会再有漏分支的可能。

但真正写起来,”捆绑”这件事没那么简单。它牵扯到 Record 的可变性问题、序列化方案、持久化字段怎么对齐。这些我在动手之前没想清楚,所以走得比较曲折。

第一步:定义 sealed 家族

最初的设计长这样:

public sealed interface OrderState
        permits Created, Paid, Shipped, Delivered,
                Completed, Cancelled, Refunded {
    Long orderId();
    Instant enteredAt();
}

public record Created(Long orderId, Instant enteredAt, Instant expiredAt)
        implements OrderState {}

public record Paid(Long orderId, Instant enteredAt, String paymentNo, BigDecimal amount)
        implements OrderState {}

public record Shipped(Long orderId, Instant enteredAt, String trackNo, String carrier)
        implements OrderState {}

public record Delivered(Long orderId, Instant enteredAt)
        implements OrderState {}

public record Completed(Long orderId, Instant enteredAt)
        implements OrderState {}

public record Cancelled(Long orderId, Instant enteredAt, String reason)
        implements OrderState {}

public record Refunded(Long orderId, Instant enteredAt, String refundNo, BigDecimal amount)
        implements OrderState {}

写到这里的时候,我停顿了一下。因为我在想——Created 记录里为什么要带 expiredAt?这不是订单实体的字段吗?而且”状态”作为一个值对象,它应该是不可变的,但订单的实际状态每时每刻在变。

我后来意识到自己走偏了。**sealed 家族定义的是”状态迁移的动作”,不是”状态的当前值”。** 这是两个完全不同的概念。

重新梳理之后,我改成了围绕”操作”来设计。因为状态机真正要做的事情不是”描述状态”,而是”处理操作”。每一次操作都是一次状态迁移,迁移动作不同,需要的数据也不同。

重新设计:围绕 Action 定义 sealed 家族

改成这样:

public sealed interface OrderAction
        permits Pay, Ship, Deliver, Confirm, Cancel, Refund {}

public record Pay(String paymentNo, BigDecimal amount, Instant paidAt)
        implements OrderAction {}

public record Ship(String trackNo, String carrier, Instant shippedAt)
        implements OrderAction {}

public record Deliver(Instant deliveredAt)
        implements OrderAction {}

public record Confirm(Instant confirmedAt)
        implements OrderAction {}

public record Cancel(String reason, Instant cancelledAt)
        implements OrderAction {}

public record Refund(String refundNo, BigDecimal amount, Instant refundedAt)
        implements OrderAction {}

然后状态机就变成了一个方法,接收”当前状态”和”操作”,返回”下一个状态”或者错误:

public Result<OrderStatus, String> transit(OrderStatus current, OrderAction action) {
    return switch (current) {
        case CREATED -> switch (action) {
            case Pay p    -> Result.ok(OrderStatus.PAID);
            case Cancel c -> Result.ok(OrderStatus.CANCELLED);
            default       -> Result.err("当前状态不支持该操作");
        };
        case PAID -> switch (action) {
            case Ship s   -> Result.ok(OrderStatus.SHIPPED);
            case Cancel c -> Result.ok(OrderStatus.CANCELLED);
            case Refund r -> Result.ok(OrderStatus.REFUNDED);
            default       -> Result.err("当前状态不支持该操作");
        };
        case SHIPPED -> switch (action) {
            case Deliver d -> Result.ok(OrderStatus.DELIVERED);
            case Refund r  -> Result.ok(OrderStatus.REFUNDED);
            default        -> Result.err("当前状态不支持该操作");
        };
        case DELIVERED -> switch (action) {
            case Confirm c -> Result.ok(OrderStatus.COMPLETED);
            case Refund r  -> Result.ok(OrderStatus.REFUNDED);
            default        -> Result.err("当前状态不支持该操作");
        };
        case COMPLETED -> switch (action) {
            case Refund r -> Result.ok(OrderStatus.REFUNDED);
            default       -> Result.err("已完成订单不支持该操作");
        };
        case CANCELLED -> Result.err("已取消订单不能变更");
        case REFUNDED  -> Result.err("已退款订单不能变更");
    };
}

这次设计比第一版好很多。但同时我发现了几个问题。

坑一:switch 里对 Optional 或 Result 解包时,case null 容易漏

第一个坑出现在真实代码接入的时候。我们的订单实体里 getStatus() 返回的是 OrderStatus 枚举,理论上不会为 null。但线上确实出现过一次历史数据问题,某些老订单的状态字段是空字符串,走 DAO 层转换的时候变成了 null,一路传到状态机。

JDK 21 的 switch 支持 case null 分支了(JEP 441 里的一部分),这是之前很多版本没有的:

return switch (current) {
    case null -> Result.err("订单状态缺失");
    case CREATED -> ...
    // ...
};

如果不写 case null,而 current 是 null,会抛 NullPointerException。以前写 if (x == CONSTANT) 的时候 null 会走 else 分支,相对安全。现在 switch 加 null 检查,反而变成了一个陷阱:用 switch 之前必须确认参数不可能为 null,或者显式处理 case null。

我们的做法是两件事:一是在入参处加了 Objects.requireNonNull(current),二是状态机入口处加了 case null 兜底。两层防护看起来冗余,但线上数据问题从来不讲道理,宁可多做一层。

坑二:Record 的 equals/hashCode 跟你想的不一样

Record 会自动生成 equals 和 hashCode,基于所有字段。这听着很合理,但在订单场景下有个隐藏问题。

我们有一处代码把 Pay 类型的操作放进了 HashSet 去重,目的是防止同一笔支付因为网络重试被处理两次。我当时的假设是:”两个 Pay 对象,只要 paymentNo 相同就应该被去重。”

但 Record 的 equals 是全部字段比较,paidAt 不一样,amount 一样,paymentNo 一样,两个对象就不 equal。重试的请求带着略微不同的时间戳,每次都没被去重,重复支付了两次。

这个问题后来通过重写 equals 解决:

public record Pay(String paymentNo, BigDecimal amount, Instant paidAt)
        implements OrderAction {

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Pay other)) return false;
        return Objects.equals(paymentNo, other.paymentNo);
    }

    @Override
    public int hashCode() {
        return Objects.hash(paymentNo);
    }
}

这段代码我犹豫了一会儿才写。因为重写 Record 的 equals 是反直觉的操作——你用 Record 就是因为它自带值语义,结果你又要自己覆盖。写完之后感觉有点”用 Record 却不信任 Record”的味道。

最后的结论是:**去重不该用 Record 的 equals 来做。** 后来我们改成用 paymentNo 单独维护一个 Set,操作对象还是让 Record 保持天然语义。这样职责更清晰。

坑三:Jackson 反序列化 Record 的空构造函数问题

我们有一个 HTTP 接口接收操作参数,请求体是 JSON,需要反序列化成对应的 OrderAction 子类型。一开始写的是:

@PostMapping("/order/{id}/action")
public Result action(@PathVariable Long id, @RequestBody OrderAction action) {
    return service.handle(id, action);
}

结果直接 400,反序列化报错:

Cannot construct instance of `OrderAction` (no Creators, like default constructor, exist)

原因很直接——OrderAction 是个接口,Jackson 不知道要实例化哪个子类。

处理方式有两种。

方案一:在接口上标注 @JsonTypeInfo 和 @JsonSubTypes。让 Jackson 根据 JSON 里的一个类型字段来选子类:

@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
    @JsonSubTypes.Type(value = Pay.class, name = "PAY"),
    @JsonSubTypes.Type(value = Ship.class, name = "SHIP"),
    @JsonSubTypes.Type(value = Deliver.class, name = "DELIVER"),
    @JsonSubTypes.Type(value = Confirm.class, name = "CONFIRM"),
    @JsonSubTypes.Type(value = Cancel.class, name = "CANCEL"),
    @JsonSubTypes.Type(value = Refund.class, name = "REFUND")
})
public sealed interface OrderAction permits Pay, Ship, Deliver, Confirm, Cancel, Refund {
}

对应的 JSON 请求体变成:

{
  "type": "PAY",
  "paymentNo": "P202501080001",
  "amount": 199.00,
  "paidAt": "2025-01-08T10:00:00Z"
}

方案二:Controller 接收一个宽泛的 DTO,在服务层再做转换。不直接暴露 sealed 类型给外部,这样外部协议和内部模型解耦。

我们最终选的是方案一。原因是操作结构本身比较稳定,没有必要额外一层转换。如果将来对接更多外部系统,可能会改用方案二。

但方案一里还有个小细节:Record 的字段是 final 的,Jackson 需要知道如何构造。原生 Record 支持在 Jackson 2.12 之后已经做得很好,但前提是不能配置 FAIL_ON_NULL_FOR_PRIMITIVES 这类严格的检查。我们的项目里恰好开着这个开关,导致 Deliver 这种只有一个字段的记录,如果 deliveredAt 缺失就直接报错。

处理方式是在时间字段上加 @JsonFormat 或者给 Deliver 加一个空的静态工厂。最后选了更简单的方式——把时间参数从必传改成 Optional:

public record Deliver(Instant deliveredAt) implements OrderAction {
    public Deliver {
        if (deliveredAt == null) deliveredAt = Instant.now();
    }
}

Record 的紧凑构造器写得好看,但要注意——它就是在这个位置注入默认值的最简洁方式。

坑四:sealed 子类必须”紧凑声明”

这个坑编译的时候就会报错,但报错信息让人摸不着头脑。

sealed interface 的子类必须在同一个模块里,如果接口在 A 模块,子类在 B 模块,除非 B 模块有 requires A 而且 A 模块打开了对应的包,否则会编译报错。当时我们的项目是个多模块的,OrderAction 放在 order-api 里,子类散落在 order-service 和 order-admin 里,编译直接失败。

处理方式是在 module-info.java 里显式 exports 对应的包,或者干脆把整个 sealed 家族都放在同一个包里。

我们选的是后者——把七个子类全部塞进 order-api/domain/action 这个包下,一个文件一个 Record,但都在同包。这样模块化异常就没那么烦人,而且读代码的时候这个包里的东西高度相关,找东西反而更快了。

顺便说一句,如果你不用 JPMS,用的是普通的 Maven 多模块(没有 module-info.java),sealed 的子类约束会按类路径来,基本上就没什么限制了。我们当时是配置了 JPMS 的,所以踩到了这个坑。如果你的项目不用模块化,可以忽略这段。

坑五:switch 模式匹配不是无限强大

用之前我对 “switch 模式匹配” 有误解,以为它可以匹配任意复杂的条件。实际上它的能力是有明确边界:

它只能匹配类型、常量、以及类型+卫语句(when)。不能做”匹配所有金额大于 100 的 Pay”这种基于字段值的模式。要做的话得写成:

case Pay p when p.amount().compareTo(new BigDecimal("100")) > 0 -> ...
case Pay p -> ...

when 这个卫语句是 JDK 21 正式版里新增的,用来做额外的条件判断。这个设计非常好,但它也有个规则:**一旦用了 when,两个分支之间不能有歧义,顺序必须注意。** 上面这段代码里,一行是”匹配大于 100 的”,一行是”匹配所有 Pay”,第二行的 when 分支必须放在前面,否则永远不会命中。

编译器不会帮你检查这种情况,你写反了它就静默地按第一个匹配走。当时我们有人把顺序写反了,导致一笔 200 元的订单进入了错误的处理逻辑,是 QA 回归的时候发现的。

坑六:过度设计,谁也没告诉我这才是陷阱

写到这里,我想说最深的那个坑,也是最不容易被 AI 或教程告诉你的坑——**用了模式匹配之后,你很容易把整个领域模型都往 sealed 家族上靠**。

我在这个重构里也走过一段。一开始我把”状态”也设计成了 sealed 家族,每个状态对应一个 Record,还把”迁移的动作”也设计成了 sealed 家族。两层都套上 sealed,一共 14 个类。然后为了把它持久化到数据库,又得写两套映射——Record 转枚举,枚举转 Record。

后来想明白了,这事根本不需要这么复杂。**状态是枚举就够用了。sealed 家族更适合用来表达”带数据的多种操作”,因为不同的操作需要传递不同的数据。而”状态”本身没有数据,它就是几个常量,枚举才是它最好的表达方式。**

最终方案就是上面那个缩小版的:状态还是枚举,操作才是 sealed 家族。整个重构落地之后,新增代码行数其实比原来少了三分之一(原来的代码 200 多行,现在的 switch 结构加七个 Record 大概 150 行),可读性提高了,但也没有”脱胎换骨”的变化。

这次经历告诉我:**模式匹配是用来”解决 if-else + instanceof 脏代码”的,不是用来重写领域模型的。** 如果你正在用 sealed 家族去表达一个本来就很自然地用枚举、Map、或者策略模式能解决的东西,那大概率是过度设计了。

收益和代价

重构上线跑了三个月,简单说一下收益和代价。

收益方面,新增一个状态变更的操作时,只需要做三件事:在 OrderAction 的 permits 列表里加上新的 Record、在 Record 类里实现接口、在各处 switch 里补上 case。前两件编译器都会提醒你,第三件会报编译错误——因为 switch 是穷举的,只要你漏了任何一个 case 分支,编译器直接告诉你缺失哪个类型。这比之前”漏了运行时才发现”强太多。

代码可读性也上来了。现在任何一个人看这个状态机,两张表:状态枚举 × 操作类型,switch 里一行一个 case,合法迁移一目了然。产品经理拿着这个代码都能看懂。

代价方面,主要在两处。一是反序列化配置比之前复杂,@JsonTypeInfo 这些注解需要团队里每个人都理解。二是有的老代码还在用原来的 OrderStatus 假设,改造之后有些边界场景(比如 status=null)需要单独处理,跟原来的代码产生了一些重复的逻辑。

还有一点,switch 表达式返回 Result<OrderStatus, String> 这种包装类,业务层每次都要 .orElseThrow() 或者手动判空,比原来直接返回枚举多一点心智负担。这个我们接受了,因为 Result 携带的错误信息比 “return null” 友好很多。

什么场景适合用模式匹配重构,什么场景不该

总结几条经验。

适合的场景:多种操作、每种操作携带不同参数、需要根据当前状态做不同处理、希望编译期帮忙检查穷举性。典型就是订单状态机、支付流程、审批流程、物流状态跟踪这类东西。

不适合的场景:状态本身没有数据(用枚举)、操作参数非常固定(用策略模式+接口)、状态机一直在频繁变动(sealed 家族的 permits 列表每次都要改,太烦)、遗留系统不能升级 JDK(直接排除)。

另外还有一类场景,用模式匹配解决看起来很美,但实际上引入了新的复杂度:就是”需要在运行时决定处理逻辑”的场景。比如我们的运营后台允许运营人员配置”当订单金额大于 500 且用户等级大于 3 时,自动打标签”。这种规则是数据驱动的,用 switch 模式匹配写死了就失去灵活性,这种情况应该用规则引擎或者 Drools,而不是靠 switch。

最后

这套东西其实 JDK 21 一出来就该做了,但项目上有各种优先级,一直往后拖。真正动手之后,两天时间改完,一周测试,第十天上线。后续处理过三个小 bug:一个是没有处理 null 状态、一个是反序列化漏了一个字段、一个是 when 分支顺序写反。都不是大问题。

回头看,我觉得最值得分享的不是”模式匹配怎么用”,因为教程太多。真正值得说的是那些”事情不顺”的地方——Record 的 equals 不等于业务语义、sealed 家族受模块化限制、when 写反了编译器不管。这些才是真正用起来才知道的东西。

如果你也在做类似的重构,我的建议是:先花一天时间想清楚哪些东西进 sealed 家族,哪些留在枚举/普通类里。这一步做错了,后面所有工作都要返工。我们当时就是在这上面犹豫了一下午,才把”状态”和”操作”分开,否则整个重构会非常痛苦。

Java 21 模式匹配重构订单状态机:sealed 家族设计、switch 穷举与序列化坑
收藏 (0) 打赏

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

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

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

淘吗网 java Java 21 模式匹配重构订单状态机:sealed 家族设计、switch 穷举与序列化坑 https://www.taomawang.com/server/java/2957.html

常见问题

相关文章

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

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