☰
状态模式实战:从if-else地狱到订单状态机的优雅重构
2026/10/21 9:19:11 网站建设 项目流程

很多人写了两三年Java,第一次接触状态模式(State Pattern)时,都会觉得它不过是“把if-else拆成几个类”而已。我以前也这么想,直到某项目里接了一个不断膨胀的订单发货逻辑,才真正意识到状态模式解决的问题根本不是“少写几个if”,而是让状态规则变得可以维护、可以扩展、不出错。

这篇内容不打算讲课本上的UML定义,而是从一段真实业务代码出发,先看没用好状态模式是什么样子,再手写一个完整的订单状态机,最后聊聊单例、并发、数据库恢复这些教科书里很少提的坑。适合刚学完Java基础、想搞懂设计模式到底怎么用的同学,也适合准备重构状态相关代码的同行参考。

1. 一段膨胀到失控的业务代码:状态模式要解决的真实痛点

1.1 先用一段典型的if-else地狱热身

假设现在要处理一个订单的支付、发货、签收和取消,最常见的初版写法长这样:

public void handleOrder(Order order, String action) { int status = order.getStatus(); // 0:待支付, 1:已支付, 2:已发货, 3:已完成, 4:已取消 if (status == 0) { if ("pay".equals(action)) { // 调用支付接口,扣款... order.setStatus(1); } else if ("cancel".equals(action)) { // 给用户退款... order.setStatus(4); } else { throw new IllegalStateException("当前状态不支持该操作"); } } else if (status == 1) { if ("ship".equals(action)) { // 调用物流系统,生成运单... order.setStatus(2); } else if ("cancel".equals(action)) { // 直接取消,返回退款... order.setStatus(4); } else { throw new IllegalStateException("当前状态不支持该操作"); } } else if (status == 2) { if ("deliver".equals(action)) { // 用户确认收货... order.setStatus(3); } else { throw new IllegalStateException("当前状态不支持该操作"); } } }

这段代码看起来结构清晰,但两个月后需求一变就出问题:订单要支持“申请退款”“拒收”“修改收货地址”,每个状态下的行为都不一样,这个函数会越来越长。

我在那个项目里见过最夸张的版本,一个方法里堆了三百多行if-else,状态从4个涨到9个。改一个状态,得把所有分支都看一遍,生怕漏掉某个组合。这还只是表象。

1.2 状态流转规则:谁在负责“不允许做的事”

真正让人头疼的是状态流转规则的约束。先看这张表,这是订单模块最基本的状态规则:

当前状态可执行操作后续状态
待支付支付已支付
待支付取消已取消
已支付发货已发货
已支付取消已取消
已发货确认收货已完成
已完成无无
已取消无无

这看起来不复杂,但用if-else实现时,“规则”散落在业务代码里,没有任何地方集中声明。更麻烦的是特殊情况:如果订单已经发货,用户点击取消,系统到底让不让?

我见到的处理方式五花八门,有的人在Service层最前面加一句if (status != 0) { throw ... },有的人在Controller校验,还有的人干脆让数据库状态字段接受一切值,靠Bug反馈来补漏。

状态模式解决的核心问题,就是把“当前状态下能不能做这个操作”和“做了之后会跳转到哪个状态”这两件事,收拢到状态类自己身上。这样业务层不用再关心“我是谁、我在哪、这个操作是否合法”,只需要执行,不合法的操作自然会被拦下来。

2. 状态模式的核心骨架:把“判断”和“行为”绑进同一个类

2.1 三个角色的分工:Context、State与ConcreteState

状态模式有三个参与方:

  • Context(上下文):也就是业务主体,比如订单。它保存一个当前状态对象的引用,对外提供业务方法。业务方法本身不做判断,而是把执行权委托给当前状态对象。
  • State(状态接口):定义所有业务操作的接口。每个操作代表一种行为,具体能不能做、做完怎么流转,由实现类决定。
  • ConcreteState(具体状态类):比如待支付状态、已支付状态。每个类只负责自己这个状态下的规则。

用一个生活化的类比帮助理解:你早上起床后的状态是“清醒”,晚上下班后的状态是“疲惫”。同样是朋友喊你去跑步,清醒状态下你会答应并换鞋出门,疲惫状态下你会拒绝。这里“你的状态”就好比State对象,“你这个人”是Context,而“听到跑步邀请后怎么反应”就是业务方法。

代码结构上的对应关系是:

角色订单场景中的实体职责
ContextOrder 类持有当前状态,对外暴露 pay/ship/deliver/cancel 等入口
StateOrderState 接口声明所有业务方法
ConcreteStateNewState / PaidState / ShippedState 等实现各自状态下的规则与流转

2.2 为什么“状态自己决定行为”是设计的关键

传统写法里,判断逻辑在调用方。调用方拿到状态值,再决定执行哪些操作,于是就有了流向调用方的大量if-else。如果换成状态自己决定行为,调用方就不需要知道“当前状态是什么”,它只知道自己想做“支付”这个动作。

这和面向对象的多态正好对上:order.state.pay(order)会执行当前状态类里的pay方法。待支付订单调用pay,执行的是NewState.pay,结果是状态变成已支付;已取消订单调用pay,执行的是CancelledState.pay,直接抛异常。

这样做还有个隐藏收益:新增状态时,改动被限制在新增类内部,原有状态类不用动。比如要加一个“已退款”状态,只需要写一个RefundedState类,再修改可以从某个状态流转到它的那个状态类,而不用去主业务类里增加上百行if-else。这符合开闭原则:对扩展开放,对修改关闭。

不过要注意,状态模式不是银弹。状态非常多、流转非常复杂时,状态类之间的跳转关系会变得难以追踪,这时候通常需要引入状态机框架来集中管理规则。但在常规的业务状态流转场景里,手写状态类完全够用,而且可读性远好于if-else。

3. 从零手写一个订单状态机:完整代码与实现细节

3.1 定义状态与State接口:先定规则再写逻辑

先定义状态接口。接口里的方法就是业务操作,我按订单场景设计了四个核心操作:支付、发货、确认收货、取消。

public interface OrderState { void pay(Order order); void ship(Order order); void deliver(Order order); void cancel(Order order); }

接口定完后,定义每个具体状态类。这些类对应业务里的状态:待支付、已支付、已发货、已完成、已取消。

订单上下文Order类是这门状态机的“大脑”。它保存当前状态对象,并把外部调用转发给状态对象:

public class Order { private Long id; private BigDecimal amount; private OrderState state; public Order(Long id, BigDecimal amount) { this.id = id; this.amount = amount; this.state = NewState.getInstance(); } public void pay() { state.pay(this); } public void ship() { state.ship(this); } public void deliver() { state.deliver(this); } public void cancel() { state.cancel(this); } public void setState(OrderState state) { this.state = state; } // getter... }

对于状态类,我建议使用单例。因为状态类里不能有成员变量(否则多个订单共用会有并发问题),单例既能减少对象创建开销,也让代码更简洁。下面动手写具体状态类。

3.2 具体状态类:每个状态只关心自己的允许操作

先看“待支付”状态:

public class NewState implements OrderState { private NewState() {} private static final NewState INSTANCE = new NewState(); public static NewState getInstance() { return INSTANCE; } @Override public void pay(Order order) { // 校验金额非空,调用支付平台扣款,这里省略 order.setState(PaidState.getInstance()); } @Override public void ship(Order order) { throw new IllegalStateException("待支付订单不能发货"); } @Override public void deliver(Order order) { throw new IllegalStateException("待支付订单不能确认收货"); } @Override public void cancel(Order order) { // 取消待支付订单,给用户原路退回,这里省略 order.setState(CancelledState.getInstance()); } }

已支付状态:

public class PaidState implements OrderState { private PaidState() {} private static final PaidState INSTANCE = new PaidState(); public static PaidState getInstance() { return INSTANCE; } @Override public void pay(Order order) { throw new IllegalStateException("订单已支付,请勿重复支付"); } @Override public void ship(Order order) { // 调用物流系统生成运单,这里省略 order.setState(ShippedState.getInstance()); } @Override public void deliver(Order order) { throw new IllegalStateException("已支付订单还未发货,不能确认收货"); } @Override public void cancel(Order order) { // 已支付订单取消,走退款流程,这里省略 order.setState(CancelledState.getInstance()); } }

已发货和已完成、已取消状态同理,这里不重复粘贴。核心就一句话:哪个状态允许哪个操作,都在对应类里写死,非法操作直接抛IllegalStateException。

3.3 业务主体Order:统一入口与防御式校验

Order的设计要注意一点:setState方法不要设置成public对外随意调用,否则任何调用方都能直接改状态,绕过合法性检查。我一般把它设为包级私有,或者只让状态类调用:

public class Order { // 其他字段省略 // 仅同包内的状态类可以调用 void setState(OrderState state) { this.state = state; } }

这样从外部看,订单只能通过pay()、ship()这四个业务方法操作。调用方根本不知道底层状态类是什么,也改不了状态。规则全部由状态类决定,外部拿不到改写机会。这是状态模式在工程落地时很容易忽略的细节:很多人会把order.setState(...)声明为public,结果Service层绕过状态类直接改状态,模式就名存实亡了。

3.4 状态切换与非法操作的报错链路

走一遍完整链路。用户点击“支付”按钮,Controller调用orderService.pay(order),Service执行order.pay()。此时Order内部托付给状态对象:

Order.pay() -> NewState.pay(this) -> 校验业务条件 -> 调用内部 setState(PaidState.getInstance())

如果用户对一个已取消的订单调用支付:

Order.pay() -> CancelledState.pay(this) -> throw new IllegalStateException("已取消订单不能支付")

不用在Service层写任何状态判断,异常会从状态类里冒出来,由全局异常处理器转成400或409响应。这个链路的好处是异常信息和规则定义在同一个类里,找问题的时候跳到对应状态类一看就懂。

我刚开始写的时候,总想在每个状态方法里先if (order.getStatus() != 0)判断一下,这是多余的。状态类自己就是状态,不需要再判断自己是谁。

4. 状态流转的两种驱动方式:集中控制与状态自驱动

4.1 方式一:由Order聚合类驱动状态流转

第一种方式是让Order内部维护一张流转规则表,比如Map<当前状态, Map<事件, 目标状态>>。业务方法先查表,确认当前状态允许该事件,再执行动作并切换到目标状态。

public class Order { private static final Map<OrderState, Map<String, OrderState>> TRANSITIONS = new EnumMap<>(OrderState.class); static { // 待支付 -> 支付 -> 已支付 register(NewState.getInstance(), "pay", PaidState.getInstance()); register(NewState.getInstance(), "cancel", CancelledState.getInstance()); // 已支付 -> 发货 -> 已发货 register(PaidState.getInstance(), "ship", ShippedState.getInstance()); // ... } public void pay() { transitionTo("pay"); } private void transitionTo(String event) { OrderState next = TRANSITIONS.get(this.state).get(event); if (next == null) { throw new IllegalStateException("当前状态不支持" + event); } this.state = next; } }

这种方式的优点是流转规则一目了然,集中在一张表里,适合后续接入可视化状态图。缺点是要专门维护注册表,代码量略大。

4.2 方式二:由具体状态类直接切换到下一个状态

第二种方式就是前面第3章写的:状态类自己处理切换。NewState.pay()方法内部直接调用order.setState(PaidState.getInstance())。

这种方式的代码相对分散,每个状态类只知道自己能去哪些状态,不知道整张流转图。它的好处是直观,状态类的行为内聚,不需要额外注册表。

4.3 两种方式的取舍:我为什么推荐集中控制

维度状态自驱动聚合类集中控制
流转规则位置分散在各状态类集中在Order或注册表
新增事件时改动需修改相关状态类只改注册表
规则可审计性需要读多个类一张表看完
代码量少略多
适合场景状态少、规则简单状态多、需要可维护性

个人经验:订单、审批、工单这类业务,状态数量通常不会少于五个,而且流转规则经常会变。我更喜欢集中控制的方式,因为它把“状态可以怎么走”这个核心规则抽离出来,业务代码和状态类都更干净。

但集中控制也有一个坑:如果事件很细,比如支付成功回调、退款申请、退款到账通知,注册表会膨胀。这时建议不要把所有事件都塞到Order里,而是按业务域拆分,一个领域一张状态表。

5. 写状态模式最容易踩的三个坑:单例、线程安全与恢复

5.1 无状态状态类用单例还是每次new

刚开始写状态类时,有的人会为每个Order new一个PaidState,结果产生一堆临时对象,纯属浪费。如果状态类里没有成员变量,就应该是无状态的,可以直接用单例。

反过来,如果状态类里放了“这个状态被执行了几次”之类的成员变量,单例就会出并发事故。比如:

// 错误示例 public class PaidState implements OrderState { private int processCount = 0; @Override public void ship(Order order) { processCount++; // 这个count会被所有订单共享 } }

多线程环境下,两个订单同时发货,count互相覆盖,排查起来极其痛苦。所以我的原则是:状态类只表达规则,不保存任何业务数据。业务数据放在Order里。

5.2 多线程切换状态时的可见性与原子性

订单状态变更在真实系统里往往发生在多线程环境。比如支付回调线程、管理后台发货线程、用户取消订单线程同时操作同一个订单,状态对象单例没问题,但Order里的state字段会有并发问题。

至少要做两步保护。第一步,state字段加volatile保证可见性;第二步,状态切换方法加锁保证原子性。

public class Order { private volatile OrderState state; public synchronized void changeState(OrderState target) { this.state = target; } }

注意,这样只能保证状态字段的安全,不能保证“校验+切换”这一个复合动作的原子性。比如用户点击取消的同时,后台刚好发货,两个线程都通过了校验,就可能出现脏切换。针对这种情况,有两种处理思路:

  1. 给整个业务入口加锁,比如synchronized (order.getId().intern()),按订单维度加锁。
  2. 用乐观锁,在Order表中维护version字段,更新时带上version,失败就重试或拒绝。

具体选哪种,取决于并发量。并发量不大时,按订单ID加锁最简单可靠。

5.3 从数据库恢复订单状态时,状态对象如何重建

状态模式落地后,数据库里存的通常还是状态字符串或状态码,比如"NEW"、"PAID"。查询订单时,需要根据这个码还原出对应的状态对象。写法和之前那个if-else长得差不多,但这里是可以接受的,因为它在唯一入口做映射:

public enum StateCode { NEW, PAID, SHIPPED, COMPLETED, CANCELLED; public OrderState toState() { switch (this) { case NEW: return NewState.getInstance(); case PAID: return PaidState.getInstance(); // case ... } } }

用枚举映射状态对象,比用字符串判断更安全。我在实际项目里会把映射表放进状态类本身,比如StateRegistry.of(code),这样查询出来后,Order构造器里传入对应的状态对象,后续流转规则照常生效。

6. 状态模式与策略模式:长得像,但意图完全不同

6.1 从代码结构看,两个模式几乎一样

策略模式和状态模式的类图几乎一致:一个Context持有某个接口的引用,接口下一堆实现类,Context把行为委托给实现类。很多人在面试时被问“这两个模式有什么区别”,第一反应是结构上没区别。结构确实像,但这就像螺丝刀和起子,看起来差不多,用途完全不同。

6.2 从意图看:策略是“被挑选”,状态是“自己走”

策略模式的核心是算法封装:客户端显式决定用哪个算法,并且可以随时替换。典型的例子是折扣计算,满减、打折、新人券,用户在结算页选择了优惠组合,系统根据选择执行对应策略。策略模式不会改变对象本身的状态,只是换了一个算法。

状态模式的核心是行为随状态自动切换:Context的内部状态自己流转,调用方从头到尾不需要选择状态,也不需要知道状态的存在。

我自己常用的区分方法是画一张对比表:

对比项策略模式状态模式
核心意图提供一组可互换的算法封装与状态绑定的行为
谁来选择实现客户端状态自身流转
行为是否改变对象状态不改变会改变Context状态
是否有状态属性通常无Context持有状态字段
典型例子优惠券计费、压缩算法订单流转、审批流

6.3 判断一句话:看谁在触发状态切换

写代码时,我建议问自己一个问题:下一次切换行为是谁触发的?

如果是外部调用方明确说“我现在要用A算法”,这是策略模式。如果外部只是说“执行一次支付操作”,而具体行为由内部状态决定,支付后状态自动变成已支付,这是状态模式。

还有一个细节:策略模式通常没有“非法操作”的概念,因为客户端只会选存在的策略。状态模式必须有非法操作保护,因为状态对象不知道调用方会不会乱来,所以每个不允许的操作都要抛异常。这是两个模式在实现上一个很大的差异,也是从代码层面快速区分的信号。

7. 工程落地:手写状态机写到什么程度可以停

7.1 状态少、流程固定,手写状态机完全够用

回到文章开头说的那个订单模块,如果你只是自己要维护五六个状态,规则也清楚,手写状态模式完全够用。不要把问题想复杂,先写OrderState接口、一组状态类、一个Order上下文,把规则表达清楚就够了。你还能收获一个额外好处:每个状态类都可以单测。比如直接测试NewState.pay是否把Order切到PaidState,测试ShippedState.deliver是否切到CompletedState,不用再模拟一堆分支条件。

7.2 状态多且需要可视化和审计,再上状态机框架

如果状态数量很多,流转事件也很多,比如超过20个状态、事件类型也很丰富,纯手写状态模式会因为依赖关系复杂而难以维护。这时候建议接入现成的状态机框架,比如Java生态里的Spring StateMachine,或者一些面向状态流设计的开源组件。

框架能帮你管理状态机定义、事件触发、守卫条件、动作监听,还支持状态图可视化。但是框架的学习成本和引入成本都不低,不要为了“上框架”而上框架。我见过一个项目状态只有四个,却为了状态机框架引入了大量配置,反而把本来简单的流程绕复杂了。

我这里给出的选型建议比较朴素:

场景特征推荐方案
状态少于8个,流转规则稳定手写状态模式
状态多但流转是线性的,无复杂分支手写+集中规则表
状态多、事件多、需要可视化/审计/持久化引入状态机框架
分布式场景,多服务协同改状态需要考虑分布式锁和事务,框架只解决模型层

7.3 一个被实践证明好用的改造顺序

最后分享一个我在实际项目里总结出来的改造顺序,适用于把if-else重构为状态模式的情况。第一步先梳理状态流转规则,对比业务文档写出一张状态转换表;第二步根据转换表编写State接口和状态类;第三步给Order类加好setState的访问控制,并把所有非法操作抛异常;第四步写单元测试,覆盖状态表中的每个合法流转和几个典型非法流转。跑完测试再看效果,你会发现新增一个状态时,改动量几乎都集中在新类和一个规则注册点,原有的业务入口代码一行都不用动。

这也是我为什么在项目里一直推荐在订单、审批、任务这类强状态业务上使用状态模式的原因。刚开始写会觉得“为了一个状态切换搞出这么多类,值得吗”,等状态从五个涨到十几个、需求方屡次调整流转规则时,你就知道把它拆开的好处了。如果本文里哪个细节可以在你的场景里进一步讨论,欢迎在评论里说说你遇到的状态流转难题。

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

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

立即咨询