☰
从if-else到状态模式:Java订单状态机重构实战
2026/9/26 7:47:50 网站建设 项目流程

你先回忆一下,有没有在线上环境改过这样的代码:一个订单状态流转的方法里,从上到下并排写着十几个 if-else,每个分支还要临时去查表"当前状态到底能不能走到这个动作"。我在一家电商公司刚接手订单模块的时候,光是想通"已取消的订单能不能再发货"这个问题,就翻了四五个方法,最后发现答案是:能,因为代码里漏写了一个状态判断。

后面我用状态模式把这块逻辑重写了一遍,改动的地方反而少了,新增一个状态也只是加一个新类。状态模式是 Java 设计模式中典型的行为型模式,它解决的核心问题可以概括成一句话:允许对象在内部状态改变时,改变自身的响应行为,看起来就像对象换了类。如果你正在准备 Java 面试、复习软考设计模式速记,或者手头正好有一段被 if-else 塞满的状态判断代码,这篇文章应该能帮上忙。我会从问题场景讲起,用一个订单状态机的完整重构过程,把状态模式从思想到代码实现整个过一遍。

1. 状态判断写进业务方法之后,代码是怎么一步步失控的

1.1 一个订单状态判断方法的"成长史"

业务系统里最常见的一种代码演进路径,我在很多项目里都见过。最开始状态只有两三个,需求也简单,直接在 Service 方法里写 if-else 就够了。比如订单有三个状态:待支付、待发货、已完成。显示状态描述的时候写一个 getStatusDesc(),判断几个 int 就完事;执行发货操作时,先判断是不是待支付,是就抛异常,不是就继续。一切看起来都挺清爽。

但电商订单的状态远不止这些。加了支付回调,就有了待发货;加了物流,就有了待收货;加了售后,就有了退款中、退款完成;加了平台介入,还会有投诉中、处理中。于是那个一开始很清爽的方法开始变味了。

我举个例子,假设现在订单有五种状态:UNPAID(待支付)、PAID(待发货)、SHIPPED(待收货)、COMPLETED(已完成)、CANCELLED(已取消)。操作也有五六个,包括 pay、ship、confirm、cancel、refund。按 if-else 的写法,每个操作对应一个方法,而每个方法都要把所有状态的情况判断一遍。一个 ship 方法会变成这样:

public void ship(Order order) { if ("UNPAID".equals(order.getStatus())) { throw new IllegalStateException("未支付不能发货"); } if ("CANCELLED".equals(order.getStatus())) { throw new IllegalStateException("已取消不能发货"); } if ("COMPLETED".equals(order.getStatus())) { throw new IllegalStateException("已完成不能发货"); } if ("SHIPPED".equals(order.getStatus())) { throw new IllegalStateException("不能重复发货"); } if ("PAID".equals(order.getStatus())) { deliveryService.deliver(order); order.setStatus("SHIPPED"); } }

每个操作的方法基本都长这样。操作一多,这种方法的数量就跟着多起来。你改一个地方,要把所有操作都过一遍;你加一个状态,要把所有操作都加一个判断。这就是大家常说的"状态判断散落各处"。

1.2 if-else 方案的三宗罪:可读性、开闭原则、非法流转

用 if-else 管状态,本质上是在做一件事:把"当前状态"和"当前事件"两个维度硬编码到一个业务方法里。这个做法有三个很难忽视的缺陷。

先说可读性。状态一多,每个方法都要把全局状态集合判断一遍,读代码的人要不断在方法之间跳转,才能拼出完整的流转链路。有时候一个动作的合法状态写在一个很长的条件里,后面加需求的人不知道这里埋着判断,顺手一改就可能放开一个不该开放的流转。

再说开闭原则。为了让一个模块对扩展开放、对修改关闭,我们希望加状态时尽量不去动已有代码。但 if-else 方案做不到:每加一个新状态,所有执行操作的业务方法都要加判断分支;每加一个新操作,所有状态的合法性检查也要重新过一遍。加需求变成了一件全屋装修式的事情。

最后说非法流转的兜底。if-else 漏掉分支的表现通常是静默通过,然后执行了一个非法操作,把订单打成脏数据。等到下游对账发现问题,数据已经错了。我在实际项目里就处理过一次这样的线上事故:一个订单被判了"已取消",但代码里没有拦截重复支付的分支,支付回调一来,订单状态从已取消直接跳到了待发货,账都对不上。这种问题用 if-else 写,很难穷举所有组合,漏配是迟早的事。

1.3 状态模式的核心思想:把状态变成对象,把行为收回状态自己管

说到这里,状态模式的答案已经很自然了:把"状态"从 int、String 这种弱类型升级成对象。

每个状态对应一个类,这个类专门处理"当前处于这个状态时,各种事件应该怎么办"。合法事件执行对应的业务逻辑,然后切换到下一个状态;非法事件直接拒绝,抛出异常。Context 只做转发,不负责判断。

这种设计把一个全局的、集中的判断,拆成了多个状态类内部的局部判断。每个状态类的逻辑都很简单:我就是待支付状态,我看到 pay 事件就完成支付,看到 ship 事件就拒绝。简单,意味着好读、好测、好改。

在 23 种设计模式里,状态模式属于行为型模式,关注的是对象间职责的分配。软考设计模式中经常把这个意图和策略模式放一起考,很多同学就在这里开始混淆。这个问题我在第四章会专门展开,先记住一句话的核心:状态模式是"状态变了,行为跟着变,而且状态是自己换的"。

2. 状态模式的角色拆解:谁负责定义、谁负责执行、谁负责切换

2.1 State 与 ConcreteState:行为按状态划分

标准的状态模式有四个参与者:

  • State(抽象状态):定义一组和业务事件对应的方法,只声明接口,不关心具体实现。
  • ConcreteState(具体状态):每个状态一个类,实现 State 接口,把属于自己状态下的事件行为写清楚。
  • Context(上下文):持有当前 State 引用,提供 setState 方法,并把客户端请求转发给当前状态对象。
  • Client(客户端):通过 Context 的方法触发状态流转,不直接操作具体状态类。

这四者的关系可以这样理解:Context 是一台自动售货机,State 是售货机当前所处的模式,ConcreteState 是某个模式下的内部逻辑。售货机不关心自己处于哪个模式,用户投币后,它把动作交给当前模式去处理,处理完,模式自己决定是不是切到下一个模式。

需要注意一个容易忽略的点:State 接口的方法签名里,几乎都要带上 Context。因为具体状态在完成自己的业务动作后,要通知"这台售货机"切换到下一个状态,实现方式就是调用 context.setState(nextState)。如果方法参数里没有 Context,状态类就没有切换状态的通道,状态模式也就名存实亡了。

2.2 Context:状态持有者与门面

Context 是状态模式里唯一被客户端直接操作的类。它要做的事情有三件:

  1. 保存当前状态对象;
  2. 把对外方法转发给当前状态对象;
  3. 提供状态切换入口 setState。

一个最简单的 Context 骨架如下:

public class OrderContext { private OrderState currentState; public OrderContext(OrderState initialState) { this.currentState = initialState; } public void setState(OrderState state) { this.currentState = state; } public void pay() { currentState.pay(this); } public void ship() { currentState.ship(this); } public void confirm() { currentState.confirm(this); } public void cancel() { currentState.cancel(this); } }

看到没有,Context 里的每个业务方法只有一行:currentState.xxx(this)。真正的逻辑都在状态类里。这就保证了状态相关逻辑是内聚的,而不是散落在 Service 层各处。

有人可能觉得这样太绕:用户调用 pay(),Context 先转发给 currentState.pay(),currentState 是 UnpaidState,UnpaidState 内部再调用 context.setState(new PaidState()),三层跳转。但从维护角度看,这种绕是值得的:每个状态类都是一个完全独立的小单元,你改一个状态不会影响其他状态,测试的时候也可以只测单个状态类。

2.3 状态流转的两种驱动方式:谁来调用 setState

实现状态模式时,有一个细节会直接影响代码质量:状态流转的判断和 setState 调用,到底放在哪里。

方式一,放在 Context 或者外部 Service 里。事件发生时,Context 先调用 currentState 的方法,然后用一个大的 if-else 或 switch 判断当前状态和事件组合,决定下一个状态。这种做法的本质还是"全局集中判断",只是把 if-else 从业务方法挪到了 Context 里,状态类仍然是纯粹的执行器。状态多了之后,Context 里的判断并不会比原来少多少,只是换了个位置。

方式二,放在具体状态类内部。每个状态方法在业务动作完成后,自己决定并调用 context.setState(nextState)。我推荐这种方式,它才是状态模式的精髓。这样每个状态类都完整描述了自己能做什么、不能做什么、做完之后去哪里;新增状态时,只需要新增一个类,并在相关的前置状态里把流转补上,改动的范围是局部的。

用订单的例子对比:UnpaidState 的 pay 方法内部会在支付成功后调用 context.setState(new PaidState())。这段流转逻辑写在 UnpaidState 里,读代码的人打开 UnpaidState 就能看到"待支付 -> 待发货"这条链路,不需要去 Context 里搜 case,也不需要去 Service 层拼状态机。

提示:状态之间的转移逻辑本身也是一种判断,只是从集中的 if-else 换成了分布在各个状态类内部的局部判断。这是一种有意识的换位:用"分布式的局部判断"替代"集中式的全局判断",换的是可维护性。

3. 从 if-else 到状态模式:订单状态机重构全程

3.1 需求定义与状态矩阵

为了演示,我设计一个简化但完整的电商订单状态机。订单有 5 个状态,4 个核心事件(pay、ship、confirm、cancel)。先把状态矩阵画出来,这一步非常关键,它可以避免"漏掉某个非法分支"的情况:

状态事件目标状态说明
UNPAID(待支付)payPAID用户支付成功
UNPAID(待支付)cancelCANCELLED未支付直接取消
PAID(待发货)shipSHIPPED商家发货
PAID(待发货)cancelCANCELLED发货前退款取消
SHIPPED(待收货)confirmCOMPLETED用户确认收货
COMPLETED(已完成)任何事件-非法操作
CANCELLED(已取消)任何事件-非法操作

其余未列出的组合,比如 UNPAID 状态下 ship、PAID 状态下 pay、SHIPPED 状态下 cancel,都属于非法操作。在 if-else 版本里,非法操作很容易漏写;在状态模式版本里,它们会成为每个状态类中"统一抛异常"的方法。

3.2 定义 State 接口与 OrderContext

先定义 State 接口。方法名直接对应订单的业务事件,加上一个 getStateName 方便记录和调试:

public interface OrderState { void pay(OrderContext context); void ship(OrderContext context); void confirm(OrderContext context); void cancel(OrderContext context); String getStateName(); }

再看 OrderContext。和上面的骨架相比,我在 setState 里加了一行日志,方便在生产环境追踪状态流转路径。如果以后要接审计系统,也可以在这里统一埋点:

public class OrderContext { private OrderState currentState; public OrderContext() { // 订单初始状态:待支付 this.currentState = new UnpaidState(); } public void setState(OrderState state) { System.out.println("状态变更: " + currentState.getStateName() + " -> " + state.getStateName()); this.currentState = state; } public void pay() { currentState.pay(this); } public void ship() { currentState.ship(this); } public void confirm() { currentState.confirm(this); } public void cancel() { currentState.cancel(this); } public OrderState getCurrentState() { return currentState; } }

有一点要提醒:这里构造器直接 new UnpaidState(),相当于把初始状态耦合在了 Context 里。如果你的订单可能有多种初始状态(比如预订单、直购单),建议把初始状态作为构造参数传入,或者用工厂方法创建 Context。

3.3 实现具体状态类:UNPAID 与 PAID

接下来是核心部分:具体状态类的实现。先看 UnpaidState:

public class UnpaidState implements OrderState { @Override public void pay(OrderContext context) { // 这里写真正的支付回调逻辑:校验库存、锁定优惠、调用支付网关等 System.out.println("支付成功,订单待发货"); context.setState(new PaidState()); } @Override public void ship(OrderContext context) { throw new IllegalStateException("未支付订单不能发货"); } @Override public void confirm(OrderContext context) { throw new IllegalStateException("未支付订单不能确认收货"); } @Override public void cancel(OrderContext context) { System.out.println("取消订单成功"); context.setState(new CancelledState()); } @Override public String getStateName() { return "UNPAID"; } }

这个类把"待支付状态下所有事件该怎么处理"完整表达了出来。合法事件只有 pay 和 cancel,非法事件统一抛异常,代码里不需要任何 if。UnpaidState 的 pay 方法内部,真正支付成功的逻辑执行完后,调一下 context.setState(new PaidState()),流转完成。

再看 PaidState,逻辑类似,换了一组合法操作:

public class PaidState implements OrderState { @Override public void pay(OrderContext context) { throw new IllegalStateException("订单已支付,不能重复支付"); } @Override public void ship(OrderContext context) { // 调用物流系统,写入运单号,发通知等 System.out.println("发货成功,订单待收货"); context.setState(new ShippedState()); } @Override public void confirm(OrderContext context) { throw new IllegalStateException("订单尚未发货,不能确认收货"); } @Override public void cancel(OrderContext context) { // 触发退款流程 System.out.println("发货前取消,进入退款流程"); context.setState(new CancelledState()); } @Override public String getStateName() { return "PAID"; } }

这两个类的写法已经能看出状态模式的收益:每个类只关注自己的状态域,合法/非法行为一目了然,不会出现"改一个操作把所有状态都牵连一遍"的情况。

3.4 实现具体状态类:SHIPPED、COMPLETED 与 CANCELLED

ShippedState 的合法操作只有 confirm,其余操作全部拒绝。注意 cancel 这一点,实际业务中已发货订单不是不能取消,而是要走售后流程,所以我在这里直接抛异常,把流程分流到售后模块去处理,更符合真实业务:

public class ShippedState implements OrderState { @Override public void pay(OrderContext context) { throw new IllegalStateException("订单已支付,不能重复支付"); } @Override public void ship(OrderContext context) { throw new IllegalStateException("订单已发货,不能重复发货"); } @Override public void confirm(OrderContext context) { // 更新订单为完成,发放积分等 System.out.println("确认收货,订单完成"); context.setState(new CompletedState()); } @Override public void cancel(OrderContext context) { // 已发货订单不能直接取消,走售后流程 throw new IllegalStateException("已发货订单不能直接取消,请走售后流程"); } @Override public String getStateName() { return "SHIPPED"; } }

CompletedState 和 CancelledState 是终态,所有事件都非法。在真实项目里,终态的每个方法大概率只会抛异常,代码重复度会比较高。有同事问过我,能不能在抽象父类里把"默认抛异常"实现掉,子类只覆盖合法方法。当然可以,这也是常见的优化手段,不过要小心:一旦默认实现是"抛异常",子类漏写某个合法事件时不会编译报错,而是运行期才发现。我倾向于在状态数量不多时保留显式实现,宁可重复几行,也不要在状态流转上埋"静默漏配"的雷。

3.5 客户端调用与重构前后对比

客户端调用变得非常干净:

OrderContext order = new OrderContext(); order.pay(); // 支付成功,订单待发货;状态 UNPAID -> PAID order.ship(); // 发货成功,订单待收货;状态 PAID -> SHIPPED order.confirm(); // 确认收货,订单完成;状态 SHIPPED -> COMPLETED order.pay(); // 抛 IllegalStateException: 订单已完成,不能重复支付

我专门跑了一下,输入的日志序列是:状态变更 UNPAID -> PAID、状态变更 PAID -> SHIPPED、状态变更 SHIPPED -> COMPLETED,第四次调用直接抛异常。整个链路完全符合状态矩阵的定义。

和 if-else 版本对比,差异在下面这张表里能看得很清楚:

对比维度if-else 方案状态模式方案
可读性状态判断散落各方法,需全局拼链路每个状态的行为内聚在一个类里
扩展性加状态要改所有操作新增状态类 + 局部流转补充
非法操作容易漏写分支,静默通过每个状态类显式拒绝,不会漏
测试每个操作方法要覆盖所有状态每个状态类单独测,边界清晰
类数量少,但代码臃肿多,但每个类小而简单

如果你正在准备 Java 面试,这个对比基本能回答"状态模式到底好在哪"的追问。面试官更在意的往往不是你背了多少定义,而是你有没有见过它真正落地后的样子。

4. 状态模式和策略模式:结构像双胞胎,意图完全不同

4.1 为什么这两个模式容易被混在一起

状态模式和策略模式在 UML 类图上长得几乎一样:都有一个 Context、一个抽象接口、多个实现类。我见过不少人在软考复习时把这两个模式的类图背混了,面试里说不清区别的也大有人在。

但它们的动机完全相反。策略模式解决的是"同一件事可以有多种做法",比如支付时选支付宝还是微信、排序时用快排还是归并,这些算法彼此独立、可以互换,而且通常没有流转关系。状态模式解决的是"同一个对象在不同生命周期阶段,行为不同",状态之间有顺序、有边界,而且状态会自己去切换。

我总结过一个容易记住的口诀,面试和软考都能用:策略是换算法,状态是换状态;策略选给别人看,状态流转自己干。

4.2 三个判据:谁能选、会不会变、谁在换

判断一个场景到底该用策略还是状态,我一般从三个角度入手:

第一,看行为是谁选的。策略模式里,是客户端创建 Context 时主动传入一个策略对象;状态模式里,客户端通常只看到 Context,根本不知道内部现在是哪个状态,状态是内部自己维护的。

第二,看状态之间会不会自己流转。策略模式中,策略对象不会把自己换成另一个策略;状态模式中,状态对象会在事件触发后调用 context.setState,这是状态模式最典型的特征。

第三,看行为是否随"历史"变化。策略模式里,同一策略执行结果相对稳定;状态模式里,同一个事件在不同状态下结果完全不同,甚至合法/非法都会改变,这和对象当前所处生命周期息息相关。

下面这个表格适合做复习卡片:

对比维度策略模式状态模式
核心意图封装一族可替换的算法根据状态改变对象行为
切换主体客户端主动选择状态对象内部自流转
状态意识无状态记录有明确状态与流转
客户端感知通常要感知具体策略不需要感知具体状态
类之间关系互相独立有流转依赖

4.3 误用状态模式写策略的后果

我在 Code Review 里见过一个反面例子,正好能加深理解。有个同事要做订单金额计算,按用户等级用不同的折扣规则,他把每种折扣规则写成了状态类,并且在"状态类"里互相 setState,结果发现计算一次金额,折扣对象还要根据金额大小切换"状态",越写越乱。这就是典型的用状态模式硬套策略场景。

正确做法应该是:定义一个 PriceStrategy 接口,客户端把具体的折扣策略注入 Context。策略之间不需要知道彼此存在,也不需要任何流转;状态模式里那套"状态自己切换"的机制,在这里反而是负担。

如果你在面试中被问到"实际项目中怎么快速分辨",我的回答通常是:先看这个对象会不会在运行期自动改变自己的分类。订单会从待支付变成待发货,这是自动流转;但支付方式不会自己从支付宝变成微信,它是外部换的。自动流转的那部分,才需要状态模式。

5. 实践避坑:状态对象复用、流转归属和多线程安全

5.1 状态实例:每次 new 还是缓存复用

代码照上面写能跑,但有个小问题:每发生一次状态流转,就在 setState 里 new 一个状态对象。状态类的实例通常不持有业务数据,它是典型的无状态对象,每次 new 会产生不必要的垃圾对象。项目规模小无所谓,但订单这种高并发场景,频繁 new 对象会带来明显的分配压力。

更常见的做法是让每个状态类变成单例,Context 里直接复用。我在一个中大型项目里把状态类从"每次 new"改成了"静态实例引用",用 profiler 看,对象分配数量肉眼可见地降了一截。改造很简单,把构造器私有化,暴露一个静态常量即可:

public class UnpaidState implements OrderState { public static final UnpaidState INSTANCE = new UnpaidState(); private UnpaidState() { } // 其余方法不变 }

不过要记住一个前提:状态类内部不能有可变成员变量。如果状态类里存了订单号、用户 ID 这类业务数据,共享单例就会出线程安全问题。状态模式里,业务数据应该放在 Context 侧,状态类只负责行为逻辑。那种"状态类里存数据"的设计,通常是思路还没转过来。

更彻底的做法是直接用枚举实现状态类。枚举天然是单例,还可以带方法,代码会更紧凑,很多开源状态机就是这么设计的。枚举不适合的是状态行为特别复杂、一个状态要实现几十行逻辑的场景,那种情况下拆成独立类更好阅读。

5.2 状态流转的幂等与非法操作兜底

非法操作是抛异常还是静默忽略,要结合业务来判断。订单类业务我建议抛异常,宁可让上层捕获处理,也不能让一个已取消的订单被重复支付成功。但某些异步场景,比如消息队列重复投递,事件本身可能重复触发,这时候状态机的入口就要做幂等处理:如果收到的事件和当前状态不匹配,直接返回成功,而不是抛异常。

我在实际项目中踩过一次这样的坑:支付回调接口被消息队列重放了两次,第一条消息把订单从 UNPAID 推进到 PAID,第二条消息进来时状态已经是 PAID,代码里直接抛了"重复支付"异常。看起来没错,但支付平台那边把异常当成处理失败,又继续重试,导致告警刷屏。后来我在入口做了幂等判断:如果订单已经是 PAID,再次收到支付成功消息直接返回成功,不落状态机。这个经验说明,状态模式只负责组织代码,幂等和重试策略还是要结合业务单独设计。

5.3 多线程下的 Context 状态切换

同一个订单对象如果可能被并发操作,Context 里的 currentState 字段建议至少用 volatile 修饰,避免一个线程看到另一个线程切换了一半的引用:

public class OrderContext { private volatile OrderState currentState; // ... }

但 volatile 只能保证可见性,保证不了"支付"和"取消"两个操作在同一时刻只有一个能推进状态。真实场景下,两个请求同时操作同一个订单的情况虽然少,一旦发生就比较麻烦。服务端一般会用数据库乐观锁收口:更新订单状态时带上版本号,只有版本号匹配才更新成功。状态模式解决的是应用层的代码组织问题,事务和并发一致性仍然要靠锁、版本号、事务这些手段。

顺便说一句,状态模式本身并不适合承担"分布式状态机"的职责。如果订单状态要跨服务流转,或者状态判断要基于数据库里的历史记录,更合适的做法是接一个独立的状态机组件,或者把状态存储在持久层里,应用层的状态对象只做"当前进程内的缓存映照"。这个问题我早期没想明白,把状态对象放 Redis 里硬存,结果序列化和反序列化非常折腾,后来改成持久化状态枚举 + 应用层状态模式,两边都清爽了。

5.4 别用状态模式硬套简单场景

状态模式最大的坑,可能是"为了用而用"。状态少于三个、流转路径固定不变、以后也不太可能新增状态时,老老实实用 if-else 或枚举加 switch 就行。状态模式的价值体现在状态数量多、流转复杂、变化频繁的场景里。我见过为了展示设计模式,把两个状态也拆成四个类的代码,阅读成本比收益还大。

我现在的习惯是:拿到一个状态相关的需求,先画状态矩阵,再决定要不要用状态模式。如果矩阵只是两行三列,直接用 if;如果矩阵到了五行四列,而且还有各种非法分支,果断状态模式。这张状态矩阵本身就是最好的设计文档,画完矩阵再写代码,基本不会漏流转。

踩过几次坑之后,我对状态模式的态度是这样的:它是一种组织代码的方式,不是用来炫技的道具。状态模式的真正价值,是让状态相关的逻辑收敛到局部、让未来加需求的人不用翻遍整个 Service 层、让非法流转在编译期和测试期就被显式暴露。能做到这一点,它作为行为型设计模式的使命就完成了;做不到,或者场景根本不复杂,那别硬上,简单代码也是一种设计。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询