ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

状态模式实战:订单状态流转、if-else重构与并发持久化

状态模式实战:订单状态流转、if-else重构与并发持久化 第一次接手一个订单模块的改造需求时我在 Service 层看到过这样一段代码if (order.getStatus() 1) { ... } else if (order.getStatus() 2) { ... }一路嵌套到第七层最后还有个else里写着throw new RuntimeException(状态异常)。改一个已发货能否取消的规则我得同时打开三个类找到十几处魔法数字。那之后我把整套逻辑用状态模式重构了一遍代码量从 800 行降到 300 行左右新增一个部分退款状态只动了两个文件。这篇文章就把状态模式从头到尾捋清楚它到底把什么搬走了、三个角色各自该干什么、状态流转的控制权放哪、完整的 Java 代码怎么写以及我在真实项目里踩过的并发和持久化的坑。1. 从一段被 if-else 撑爆的订单代码说起1.1 坏味道现场状态判断散落在每个方法里先看一段典型的状态没抽象的代码这是我们重构前的真实样子做了脱敏和简化public class OrderService { public void pay(Order order) { if (order.getStatus() Order.STATUS_PENDING_PAYMENT) { order.setStatus(Order.STATUS_PAID); // 扣库存、记流水 } else if (order.getStatus() Order.STATUS_CANCELLED) { throw new BizException(订单已取消不能支付); } else if (order.getStatus() Order.STATUS_PAID) { throw new BizException(订单已支付请勿重复支付); } else { throw new BizException(当前状态不支持支付); } } public void ship(Order order) { if (order.getStatus() Order.STATUS_PAID) { order.setStatus(Order.STATUS_SHIPPED); } else if (order.getStatus() Order.STATUS_PENDING_PAYMENT) { throw new BizException(未支付不能发货); } else if (order.getStatus() Order.STATUS_SHIPPED) { throw new BizException(已发货请勿重复操作); } else { throw new BizException(当前状态不支持发货); } } // cancel、confirm、refund... 每个方法都是同样的结构 }这段代码有几个很要命的问题而且它们会随着业务演进不断放大同一份状态知识被复制了 N 遍。哪些状态能支付这个规则在pay里写了一遍在cancel里可能又写了一遍。哪天产品说待发货状态下已申请退款的订单不能取消你得把所有方法翻一遍靠人肉保证不漏。状态流转的合法性判断和行为执行混在一起。校验和业务逻辑搅在同一个 if 分支里测试的时候很难单独验证流转规则这一层。新增状态是场灾难。加一个部分发货意味着上面每一个方法都要多一个else if还要重新梳理所有组合。1.2 状态模式把判断搬到了哪里状态模式的核心动作只有一个把状态从一个 int 常量升级成一个对象把在不同状态下对同一个请求的不同响应从调用方搬到状态对象自己身上。重构之后调用方变成了这样order.pay(); order.ship(); order.confirm();没有 if没有 switch。那么未支付不能发货这个规则去哪了它变成了PendingPaymentState类里的一句话——因为PendingPaymentState根本没有覆写ship方法父类的默认实现直接抛异常。规则从对当前状态做判断变成了由当前状态自己表态这就是状态模式的全部魔法。用一个生活化的类比以前的写法像是门口保安拿着张表逐个核对你是几号状态这个时间点能不能进状态模式则是把门禁权限写进了每个人的工牌里刷卡的时候由工牌自己决定开不开门。保安只负责把卡递给读卡器不负责判断。这个转变带来的最大收益是开闭原则的落地新增一种状态是新增一个类而不是修改已有类的代码。对于订单、审批流、工单、设备这类状态会长期演进的业务这点差异在半年后就是改两个文件和改二十个地方的区别。2. 状态模式的三个角色以及各自不该越的界2.1 Context只做转发和存档不做任何判断Context上下文是外部世界的唯一入口。它持有一个 State 引用把所有请求原样转给当前状态对象自己绝不判断这个状态下能不能干这个。很多人写状态模式时最容易犯的错就是在 Context 里加判断// 错误示范Context 又变成了 if-else 集散地 public void pay() { if (state.getStatus() ! OrderStatus.PENDING_PAYMENT) { throw new BizException(不能支付); } state.pay(this); }这样做等于把刚搬走的规则又搬回来了一半状态类的覆写就形同虚设。Context 该干的三件事是持有并暴露当前状态对外通常只暴露一个 status 枚举而不是 State 对象本身负责切换状态也就是提供setState这类方法并在切换时做统一记录日志、埋点、状态变更历史转发请求一个方法一行纯粹的委托。2.2 State 抽象接口还是抽象类这里有个真实的取舍状态抽象有两种主流写法选错了会很难受。写法一接口。所有行为都声明在接口里每个状态类必须实现全部方法。public interface OrderState { void pay(OrderContext ctx); void ship(OrderContext ctx); void confirm(OrderContext ctx); void cancel(OrderContext ctx); }它的痛点是CompletedState这种终态也得把四个方法全写上方法体里全是throw new IllegalStateException(已完成订单不支持该操作)五个终态加起来能写几十行重复代码。更麻烦的是接口一旦新增一个refund()方法所有状态类都会编译报错哪怕大部分状态根本不支持退款。写法二抽象类 默认拒绝。这是我更推荐的写法。public abstract class OrderState { protected final OrderStatus status; protected OrderState(OrderStatus status) { this.status status; } public OrderStatus getStatus() { return status; } // 默认语义当前状态不支持该操作 public void pay(OrderContext ctx) { reject(支付); } public void ship(OrderContext ctx) { reject(发货); } public void confirm(OrderContext ctx) { reject(确认收货); } public void cancel(OrderContext ctx) { reject(取消); } protected void reject(String action) { throw new IllegalStateException( 订单当前状态[ status.getDesc() ]不允许执行[ action ]); } }这样每个具体状态只写自己支持的动作比如ShippedState里只有confirm一个方法其余全部继承默认的拒绝逻辑。新增行为时也不会引发全量编译报错只需要在真正支持该行为的状态里覆写即可。提示默认拒绝有两种实现风格。抛异常适合非法操作必须被拦截并告警的场景返回 boolean 适合前端按钮置灰、后端静默忽略的场景。不要在一个项目里两种混用调用方会疯掉。2.3 ConcreteState状态知识唯一的归属地具体状态类是全部业务规则的落点。它该放什么、不该放什么值得单独说清楚。该放的这个状态下允许什么操作、操作后流转到哪个状态、这个状态下特有的业务校验比如待发货状态取消订单时如果已经出库则需要走拦截流程。不该放的跨状态的通用业务逻辑。比如扣减库存这个方法如果待支付取消和待发货取消都要用就不要在两个状态类里各写一遍应该抽到一个独立的领域服务里状态类调用它。状态类是指挥官不是苦力。Override public void cancel(OrderContext ctx) { if (ctx.isOutboundDone()) { // 已出库走物流拦截分支 inventoryService.intercept(ctx.getOrderNo()); } ctx.setState(CancelledState.INSTANCE); }2.4 一张表看清三个角色的职责边界角色持有数据是否包含业务判断是否决定下一个状态对外可见性Context订单号、金额、状态引用、变更历史否通常不决定由状态调用 setState完全可见State抽象状态枚举标识否只提供默认拒绝否不可见ConcreteState自身状态标识必要时带临时数据是核心规则所在地是不可见调用方无否否只调 Context 的公开方法这张表是我在做代码评审时的 Checklist。只要发现 Context 里出现if (status ...)或者状态类里出现了大段和本状态无关的通用逻辑基本就可以判定这次抽象没做到位。3. 状态流转的控制权到底该放在谁手里这是状态模式实践中分歧最大的一点我见过三种做法各有适用面。3.1 状态内部自流转最贴合面向对象直觉也就是上面代码里的写法——PendingPaymentState.pay()里直接调用ctx.setState(PaidState.INSTANCE)。下一个状态是谁由当前状态自己决定。优点是内聚性最强看一个状态类的代码就知道它的全部门禁规则不用跳来跳去。缺点是状态类和具体状态类之间产生了直接引用10 个状态如果两两都可能转移就形成了一张耦合网。状态数量在 8 个以内这种方式完全够用超过 15 个代码会开始变得难以追踪。3.2 Context 集中裁决流转规则一目了然另一种做法是状态类只表达我允许这个操作至于跳到哪个状态由 Context 查一张转移表决定public class OrderContext { private static final MapString, OrderState TRANSITIONS new HashMap(); static { TRANSITIONS.put(key(OrderStatus.PENDING_PAYMENT, pay), PaidState.INSTANCE); TRANSITIONS.put(key(OrderStatus.PENDING_PAYMENT, cancel), CancelledState.INSTANCE); TRANSITIONS.put(key(OrderStatus.PAID, ship), ShippedState.INSTANCE); TRANSITIONS.put(key(OrderStatus.PAID, cancel), CancelledState.INSTANCE); TRANSITIONS.put(key(OrderStatus.SHIPPED, confirm), CompletedState.INSTANCE); } private static String key(OrderStatus status, String action) { return status.name() : action; } }这种方式的好处是所有合法流转集中在一处做流程评审、画状态转移图、甚至把这张表配置化下发都非常方便。代价是状态类里那种我读到哪儿去的直观感没了。3.3 表驱动状态机状态多到一定程度就该换工具了当状态超过 20 个、流转规则需要动态配置、还要支持超时自动流转时硬写状态模式就不划算了。这时候可以考虑引入状态机框架Spring Statemachine 是 Java 圈比较常见的方案或者干脆自己写一个表驱动的轻量状态机。判断依据其实很简单如果团队里非开发岗产品、运营也需要参与流转规则的维护那就该把它做成配置表而不是写死在类里。3.4 三种控制方式的选择对照方式状态数量级规则集中度新增状态成本适合场景状态内部自流转3~8分散在各状态类新增一个类 改相邻状态订单、工单等中小型流程Context 集中裁决8~20集中在转移表改表 新增一个类规则需要评审和画图的流程表驱动状态机20完全外置为配置改配置可能不动代码审批流、工作流引擎4. 完整代码示例订单从待支付到已完成前面是拆解这里给一份可以直接跑起来的完整实现。我用 Java 写选它的原因是状态模式在 Java 里最典型换成 C 只需要把INSTANCE换成静态成员并在.cpp里定义思路完全一样。4.1 状态枚举与抽象基类public enum OrderStatus { PENDING_PAYMENT(待支付), PAID(待发货), SHIPPED(已发货), COMPLETED(已完成), CANCELLED(已取消); private final String desc; OrderStatus(String desc) { this.desc desc; } public String getDesc() { return desc; } }抽象基类沿用前面 2.2 的写法这里补一个细节status字段用protected final构造时注入保证状态对象一旦创建它代表的状态就不会被篡改。4.2 五个具体状态的实现public final class PendingPaymentState extends OrderState { public static final PendingPaymentState INSTANCE new PendingPaymentState(); private PendingPaymentState() { super(OrderStatus.PENDING_PAYMENT); } Override public void pay(OrderContext ctx) { // 实际项目里校验金额、冻结库存、生成支付流水 ctx.setState(PaidState.INSTANCE); } Override public void cancel(OrderContext ctx) { ctx.setState(CancelledState.INSTANCE); } }public final class PaidState extends OrderState { public static final PaidState INSTANCE new PaidState(); private PaidState() { super(OrderStatus.PAID); } Override public void ship(OrderContext ctx) { // 实际项目里生成运单、推送物流、扣减真实库存 ctx.setState(ShippedState.INSTANCE); } Override public void cancel(OrderContext ctx) { // 已付款未发货需要走退款 refundService.refund(ctx.getOrderNo()); ctx.setState(CancelledState.INSTANCE); } }public final class ShippedState extends OrderState { public static final ShippedState INSTANCE new ShippedState(); private ShippedState() { super(OrderStatus.SHIPPED); } Override public void confirm(OrderContext ctx) { ctx.setState(CompletedState.INSTANCE); } }终态直接留空即可所有动作都会命中父类的rejectpublic final class CompletedState extends OrderState { public static final CompletedState INSTANCE new CompletedState(); private CompletedState() { super(OrderStatus.COMPLETED); } } public final class CancelledState extends OrderState { public static final CancelledState INSTANCE new CancelledState(); private CancelledState() { super(OrderStatus.CANCELLED); } }注意这里我把构造方法都设成了private并把实例暴露为public static final INSTANCE。理由是这些状态对象不携带任何实例数据全局一份就够做成单例能省掉大量无意义的对象分配。这个选择不是可选项后面 5.1 会讲清楚什么时候它反而是错的。4.3 Context 与调用方public class OrderContext { private final String orderNo; private final ListString history new ArrayList(); private OrderState state; public OrderContext(String orderNo) { this.orderNo orderNo; this.state PendingPaymentState.INSTANCE; this.history.add(state.getStatus().getDesc()); } void setState(OrderState next) { OrderStatus from this.state.getStatus(); OrderStatus to next.getStatus(); System.out.printf([%s] 状态流转: %s - %s%n, orderNo, from.getDesc(), to.getDesc()); this.state next; this.history.add(to.getDesc()); } public void pay() { state.pay(this); } public void ship() { state.ship(this); } public void confirm() { state.confirm(this); } public void cancel() { state.cancel(this); } public OrderStatus getStatus() { return state.getStatus(); } public String getOrderNo() { return orderNo; } public ListString getHistory() { return Collections.unmodifiableList(history); } }setState用的是包级私有无修饰符这样只有同包内的状态类能调用它外部业务代码无法绕过状态规则直接改状态。这是个很小的设计细节但能堵住有人图省事直接ctx.setState(...)这个最常见的破坏点。4.4 跑一遍看输出public class Main { public static void main(String[] args) { OrderContext order new OrderContext(SO202405210001); order.pay(); order.ship(); order.confirm(); System.out.println(最终状态: order.getStatus().getDesc()); System.out.println(流转轨迹: String.join( - , order.getHistory())); OrderContext illegal new OrderContext(SO202405210002); try { illegal.ship(); } catch (IllegalStateException e) { System.out.println(拦截非法操作: e.getMessage()); } } }输出结果[SO202405210001] 状态流转: 待支付 - 待发货 [SO202405210001] 状态流转: 待发货 - 已发货 [SO202405210001] 状态流转: 已发货 - 已完成 最终状态: 已完成 流转轨迹: 待支付 - 待发货 - 已发货 - 已完成 拦截非法操作: 订单当前状态[待支付]不允许执行[发货]从这里能看出状态模式一个很实用的副产品history列表天然就是一条完整的流转链路出问题时直接把它打到日志里排查线上事故的效率能提升一大截。我们后来还把它和变更时间戳一起落到数据库做了个简单的状态流转审计表。5. 状态对象谁来创建单例、新建还是交给工厂这个问题看起来琐碎实际上是我在 code review 里发现 Bug 最多的地方。5.1 无状态的状态对象放心用单例判断标准只有一条这个状态类有没有自己的实例字段。如果像ShippedState那样只有一个继承来的status标识那它就是无状态的全局共享一个实例完全安全还省内存。这也是我在 4.2 里全部写成INSTANCE的原因。5.2 有状态的状态对象做成单例就会串号反过来只要状态类里出现了属于某一次流转过程的字段就绝对不能共享。举个真实例子我们做过一个带重试的中间状态// 危险写法这个 retry 会被所有订单共享 public class PayingState extends OrderState { public static final PayingState INSTANCE new PayingState(); private int retry 0; // 属于单个订单的运行时数据 Override public void pay(OrderContext ctx) { if (retry 3) { ctx.setState(FailedState.INSTANCE); } } }这段代码在单线程压测下毫无问题一上生产就会出现订单 A 的支付重试把订单 B 的次数带到了 3。因为retry是挂在单例上的而单例是全应用共享的。正确做法有三种把这个字段挪到 Context 里、每次调用时new PayingState()、或者干脆用一个Map订单号, Integer显式记录。我更倾向于第一种因为 Context 本来就是承载订单运行时数据的地方。提示判断能不能做单例不要看这个字段是不是很少变而要看这个字段属于上下文还是属于状态本身。属于上下文的一律放 Context。5.3 用枚举实现轻量版状态模式如果状态只有三四个、行为也很简单用完整的类体系会显得很重。Java 的枚举可以带抽象方法天生适合这种场景public enum SimpleOrderStatus { PENDING_PAYMENT { Override public SimpleOrderStatus pay() { return PAID; } Override public SimpleOrderStatus cancel() { return CANCELLED; } }, PAID { Override public SimpleOrderStatus ship() { return SHIPPED; } Override public SimpleOrderStatus cancel() { return CANCELLED; } }, SHIPPED { Override public SimpleOrderStatus confirm() { return COMPLETED; } }, COMPLETED, CANCELLED; public SimpleOrderStatus pay() { throw new IllegalStateException(当前状态不支持支付); } public SimpleOrderStatus ship() { throw new IllegalStateException(当前状态不支持发货); } public SimpleOrderStatus confirm() { throw new IllegalStateException(当前状态不支持确认); } public SimpleOrderStatus cancel() { throw new IllegalStateException(当前状态不支持取消); } }调用方变成status status.pay();一行搞定还自带持久化能力存字符串就行。它的天花板在于状态里没法方便地注入 Spring 的 Bean做不了复杂的数据库操作。所以我的经验是——纯规则流转用枚举涉及外部资源操作的用类体系。6. 状态模式、策略模式、责任链别再用混了面试被问状态模式和策略模式有什么区别很多人答不上来。我一开始也答不上后来用一个类比想通了策略模式是选一次就一直用状态模式是用着用着会自己变。维度状态模式策略模式责任链模式表驱动状态机核心意图对象行为随内部状态改变运行时选择一种算法请求沿链传递直到被处理用数据描述流转规则状态/分支之间是否互相知晓是状态自己决定下一个否策略之间互不感知部分知晓持有 next否全部由表描述流转是否自动发生是否由调用方指定是沿链前进是典型落地订单、工单、设备状态支付渠道、折扣算法审批、过滤器链复杂审批流新增一个分支的成本新增类 改相邻状态新增类 改选择逻辑新增类 调整链顺序改配置表还有一个容易混淆的点状态模式和有限状态机FSM不是一回事。状态模式是用面向对象的方式实现 FSM 的一种手段FSM 是概念模型状态模式是实现手法。理解这一层就不会纠结到底该用状态模式还是状态机了——它们是不同层级的词。顺带说一句课程作业的场景。不少同学做设计模式大作业时会把状态模式和简单工厂模式硬凑在一起状态对象的创建交给一个工厂。这本身没错但如果工厂里又是一长串if-else判断该 new 哪个状态那就是把刚消灭掉的条件分支又请回来了。要真想结合用枚举 values()做映射比if-else干净得多。7. 优缺点摆在桌面上以及不该用它的三种情况7.1 它真正解决的问题消除庞大的条件分支。原本散落在各个方法里的状态判断被压缩成每个状态类里的几行覆写。我们那次重构OrderService从 800 行降到 300 行出头减少的主要就是重复的 if 分支。状态知识单点收敛。哪些状态能取消这个问题答案只存在于各个状态类中不会存在第二份副本。新增状态符合开闭原则。加部分退款状态只需新增一个类并修改触发它的那个相邻状态其余代码零改动。流转过程天然可观测。所有状态变更都经过 Context 的一个setState出口想加日志、埋点、审计、发消息都只需要改这一个地方。7.2 它的成本也要说清楚类数量膨胀。5 个状态就是 5 个类20 个状态就是 20 个类。IDE 里一屏都放不下新人接手时容易迷路。状态爆炸难以避免。当状态和行为都很多时理论上可能出现状态 × 行为的组合膨胀这时候往往需要重新审视状态划分是否合理而不是继续硬加类。调试链路变长。以前打断点在一个方法里就能看完现在要跟着setState一路跳。Context 层的统一日志能很大程度上缓解这个问题。7.3 三种不该用状态模式的场景状态只有两三个且短期不会增长。比如一个只有启用/禁用两态的对象写个if就够了硬套状态模式属于杀鸡用牛刀。状态之间完全没有行为差异。如果所有状态对所有操作的响应都一样那状态模式带来的只是纯粹的类数量负担。状态流转规则高度动态且必须由运营配置。这种情况直接用配置驱动的状态机比手写状态类更合适。8. 踩坑实录并发、持久化与初始化顺序下面这三个坑都是我在生产环境真真切切踩过的写出来给后来人省点时间。8.1 并发下的状态覆盖单例没问题Context 有问题前面说了状态对象可以做单例但这不代表状态模式天然线程安全。问题出在 Context 上——state字段是被多方共享的可变状态。试想订单 A 在两个线程里同时收到支付和取消请求用户手速快 网络重试这在真实场景里完全可能两个线程都读到了PendingPaymentState然后一个 set 成PaidState一个 set 成CancelledState最终状态取决于谁后写而支付流水可能已经产生了。这就是典型的检查后执行竞态。我的处理方案分两层业务层用乐观锁兜底。数据库加version字段UPDATE ... WHERE order_no ? AND status ? AND version ?状态变更以数据库的原子更新为准应用层状态只是一个缓存视图。Context 层加同步。如果业务量不大直接给setState和状态方法加synchronized如果既要并发又要性能可以用AtomicReferenceOrderState配合 CAS只有状态没被别人抢先改过才允许流转。public boolean compareAndSetState(OrderState expect, OrderState update) { return stateRef.compareAndSet(expect, update); }8.2 持久化状态对象该存什么、不该存什么状态类里那些INSTANCE是内存里的对象数据库里是存不进去的。我的做法是数据库只存OrderStatus这个枚举的字符串值重建对象时按枚举反查状态实例。public OrderContext restore(String orderNo, OrderStatus status) { OrderContext ctx new OrderContext(orderNo); ctx.setState(OrderStateRegistry.of(status)); // 枚举 - 状态实例 return ctx; }OrderStateRegistry就是一个MapOrderStatus, OrderState初始化时把五个单例塞进去。这一步千万别用switch写反查逻辑不然状态一多反查会变成全项目最难维护的一段代码。这里还有个隐藏陷阱如果某个状态类带了运行时字段像前面那个retry那它根本无法从数据库恢复因为重启后这个值就丢了。所以我在 5.2 里才强调要把它放到 Context 里而 Context 的数据本来就是要持久化的。8.3 循环引用与静态初始化的隐秘顺序问题如果两个状态互相在对方类里引用单例比如PendingPaymentState和CancelledState互相import并访问INSTANCE一般情况下没问题但一旦涉及复杂的静态初始化块就可能出现读到 null的诡异现象——因为 JVM 是按需初始化类的A 初始化到一半去访问 BB 又回头访问还没初始化完成的 A。避坑办法很朴素状态类里不要写静态初始化块所有状态实例的注册统一放到一个独立的注册表类里。这样初始化顺序就完全由你自己控制问题从根上消失了。提示如果你的状态之间有互相引用的感觉先停下来想一想大概率是状态划分粒度太细了应该合并。我见过一个项目把订单状态拆到 23 个其中一半是可以合并的中间态。8.4 别忘了在切状态时打日志最后这个算不上坑但收益极高。所有状态变更都收敛在setState一个方法里在这里打一行结构化日志成本几乎为零void setState(OrderState next) { OrderStatus from this.state.getStatus(); OrderStatus to next.getStatus(); log.info(order_state_transition orderNo{} from{} to{} traceId{}, orderNo, from.name(), to.name(), TraceContext.getTraceId()); this.state next; this.history.add(to.getDesc()); }线上遇到状态怎么不对的投诉时这行日志能直接告诉你状态在哪一步被谁改的。我们上线这行日志之后状态相关的排查时间从平均半小时压到了几分钟。我在实际项目里用状态模式的体会是它真正的价值不在于省了多少行代码而在于它把状态流转这件事变成了一个可以单独做单元测试的模块。以前要验证已发货订单不能取消得构造一个订单、走完支付和发货流程、再调取消断言抛异常现在直接new OrderContext(...)、setState(ShippedState.INSTANCE)、调cancel()五行测试代码搞定。如果你团队里也有那种改一处状态规则要跑半小时回归测试的模块状态模式值得试一次。
返回列表