别再写满屏的 switch 了:让你的对象自己“变身”——状态模式
先问一个实在问题:你有多久没在代码里见过那种一眼望不到头的 switch 了?每次加一个新状态,就要小心翼翼地在每个 case 里补逻辑,生怕漏掉某个分支,改完一个方法还要全局搜索还有哪里也 switch 了同一个字段。我之前维护过一个订单模块,状态从新建到完成中间有七八种流转,每个流转节点都牵扯到库存、支付、通知,那段时间我见到 switch 这两个字母就头皮发麻。后来我把这些逻辑全部重构成了状态模式,对象不再是被动地被判断“你现在是什么状态,所以你应该做什么”,而是自己就知道“我现在是什么状态,我自己来完成对应的行为”。这篇文章我就把这段重构经验拆开来讲,从痛点到设计思路再到完整落地,最后附上我踩过的坑。不吹不黑,状态模式不是万能药,但在“状态多、行为杂、流转复杂”的场景下,它真的是能把代码救回来的那种模式。
这篇文章适合谁看?已经在写业务代码、开始觉得 if/switch 越堆越难维护的初中级开发,以及在系统设计阶段想提前规避状态逻辑混乱的技术负责人。如果你现在项目里恰好有一个状态字段被十几个地方各自判断,那这篇文章正好能给你一套可以直接迁移的解法。
1. 状态模式到底解决了什么问题
1.1 从一段熟悉的“坏味道”开始
我敢打赌,你一定写过类似的代码:
public void handleOrder(Order order, Event event) { switch (order.getStatus()) { case CREATED: if (event == Event.PAY_SUCCESS) { // 校验库存、锁定库存、更新状态为 PAID... } else if (event == Event.CANCEL) { // 释放优惠券、更新状态为 CLOSED... } break; case PAID: if (event == Event.REFUND_APPLY) { // 发起退款、更新状态为 REFUNDING... } else if (event == Event.SHIPPED) { // 调用物流接口、更新状态为 SHIPPED... } break; // 后面还有一堆 case... } }这段代码其实已经体现出了状态模式要解决的核心问题:行为被“状态”和“事件”两个维度切碎了。每增加一种状态,每增加一种事件,你的 switch 分支数量都可能成倍增长。而且这些分支往往不是集中在同一个方法里,而是散落在订单校验、支付回调、用户点击、后台操作等各种入口中,形成了事实上的“多处判断、多处维护”。一旦某个状态下的某些行为定义发生了变化,你不得不去所有入口里翻找对应的分支,改漏一个就是线上事故。
状态模式解决这个问题的思路很直白:既然行为是根据状态决定的,那不如把“状态”和“行为”绑定在一起,让每个状态自己管理自己在接收到事件后做什么。判断逻辑不再集中在外部的一个 switch 里,而是被分发到了各个状态对象内部。外部调用方不再关心当前是什么状态、该走哪个分支,它只需要把事件告诉状态对象,“你看着办”。
1.2 状态机思维:所有状态流转都可以画成一张图
在学习状态模式之前,建议你先建立状态机的思维。状态机(State Machine)是一个在计算机科学里被广泛验证过的模型:它包含有限个状态,在某个状态下接收到某个事件后,系统会执行一系列动作,然后迁移到另一个状态。
比如一个简化的订单流程,可以画成这样(放心,我不画图,用文字描述):
- 新建(CREATED):收到支付成功事件 -> 锁定库存、标记已支付,迁移到已支付(PAID);收到取消事件 -> 释放优惠券,迁移到已关闭(CLOSED)。
- 已支付(PAID):收到发货事件 -> 调用物流接口,迁移到已发货(SHIPPED);收到用户主动取消申请 -> 发起退款流程,迁移到退款中(REFUNDING)。
- 已发货(SHIPPED):收到确认收货事件 -> 迁移到已完成(COMPLETED);收到售后申请 -> 进入售后(AFTER_SALE)流程。
你会发现,任何一个状态,它能够响应的事件、执行的动作、迁移的目标状态,都是确定的。这正是状态模式的最佳适用土壤:每一个状态在这个模型中是一个“节点”,而状态对象做的事情,就是在定义这个节点在遇到输入时如何决策和下跳。如果没有用状态模式,你就要用嵌套的 if/switch 自己去模拟这个决策逻辑,写出来的代码本质上是在“手工实现状态机”,而且还常常实现得残缺不全——漏掉了某些事件分支,或者允许了不该发生的非法迁移。
状态模式的优势在于,它把这张状态迁移图“编译”成了代码结构。每一条迁移规则,都在某个状态类的方法里明明白白地写着:遇到什么事件,做什么动作,跳转到什么状态。规则没有散落在外面的业务逻辑里,而是内聚在状态类内部,代码即文档。
1.3 状态模式的定义与结构
状态模式(State Pattern)是 GoF 23 种设计模式之一,它的正式定义是:允许一个对象在其内部状态改变时改变它的行为,对象看起来似乎修改了它的类。后半句“看起来似乎修改了它的类”非常精髓,翻译成直白的话就是:同一个对象,在不同状态下调用同一个方法,执行的结果有可能完全不同,就像这个对象换了一个“类”一样。
在实现上,状态模式通常由三个核心角色组成:
- 上下文(Context):持有当前状态对象的引用,对外暴露业务方法,并将调用委托给当前状态对象。对于外部调用方来说,它只需要面对 Context,不需要感知状态对象的存在。
- 状态接口(State):定义所有状态类必须实现的行为方法,比如上面例子里的 handleOrderEvent。
- 具体状态类(ConcreteState):实现状态接口,定义当前状态对外部事件的具体响应,以及状态迁移的目标。
为了让这篇文章不是纸上谈兵,我决定用一个完整且可运行的订单状态机案例,从头到尾走一遍状态模式的落地过程。如果你项目中的状态逻辑和订单不太一样,也没关系,思路是通用的,照着这个骨架改就行。
2. 状态模式的设计思路与方案选型
2.1 为什么不用策略模式把分支拆掉
有人可能会说:既然 switch 分支太多,那我用策略模式,把每个分支的代码抽到独立的策略类里,用事件类型去路由,不也能减少 switch 吗?没错,策略模式确实能解决一部分问题,但策略模式和状态模式的核心意图是不同的。
策略模式解决的是“算法族的自由切换”:使用哪种策略由调用方决定,策略之间相互独立,通常不涉及状态的迁移。而状态模式解决的是“状态驱动的行为变化”:当前状态决定了行为,同时行为执行后往往会改变状态,而状态的变化又会影响下一次的行为。简单来说,状态模式里“策略”不是被外界选择的,而是被状态迁移自然驱动的。状态模式下,同一个对象内部的状态在流转,对外表现的行为也随着状态流转而变化。
用订单场景对比一下:如果我用策略模式处理订单事件,那么我大概率会根据“事件类型”去路由到不同的处理器(PaySuccessHandler、CancelHandler、ShipHandler)。看起来每个事件的逻辑被拆开了,但“这个事件在当前状态下是否合法”这个问题,仍然需要每个 handler 自己判断。也就是说,非法状态迁移的校验,会被重复地写进每个 handler 里去。而状态模式把“只有处于 CREATED 状态的订单才能响应支付成功事件”这个规则,直接封装在了 CreatedState 类内部,新加一种事件时,其他状态类根本不需要改动,自然也不会出现“忘记在某个状态里处理新事件”的问题。
2.2 手动 if/switch、查表法、状态模式,哪个更靠谱
在实际项目里,处理状态流转有三种常见方案:
| 方案 | 核心思路 | 优点 | 缺点 |
|---|---|---|---|
| 方法内 if/switch 判断 | 操作时检查状态字段,执行完直接更新状态 | 实现简单,容易理解,适合状态极少且流转单一的代码 | 状态行为散落,新增状态时容易遗漏分支,非法迁移校验只能在调用处防御 |
| 状态流转表(查表法) | 定义事件 -> 前置状态 + 后置状态的映射表,配合动态路由 | 流转规则集中,可配置化强,适合规则频繁变化的场景 | 行为动作仍需要另外维护,查表只解决了“能不能迁”的问题,没解决“迁移时做什么”的问题 |
| 状态模式 | 状态行为内聚到状态对象,状态对象自身完成动作与迁移 | 职责清晰,扩展性好,新增状态基本不影响其他类 | 类数量会变多,结构需要提前设计,小型场景用起来略重 |
我从实际项目经验说:如果你只是一个支付回调里有三个状态要判断,我完全支持你用 switch,杀鸡不用牛刀。但当你的状态数量到了五六个以上,且每个状态都关联多个不同的操作行为时,状态模式带来的收益是指数级的。还有一个判断维度:你的代码里是否已经在多个地方重复地写着“if (status == XXX && event == YYY)”这样的判断?如果答案是肯定,那这就是状态模式上场的信号。
2.3 使用状态模式前先想清楚的三件事
状态模式不是银弹,落地前有几个问题需要你自己想明白,否则只会从“switch 地狱”变成“状态类地狱”。
第一,状态迁移的行为是否足够复杂。如果某个状态下的事件处理仅仅是“改一下状态字段”,没有任何附加动作,那为它单独写一个状态类确实是过度设计。状态模式适合的是那种“状态一变,很多行为逻辑都要跟着变”的场景,行为耦合度越高,收益越大。
第二,状态的迁移路径是否相对稳定。我见过有人把状态模式用在状态流转非常灵活、用户可任意跳转的配置型系统里,结果每个状态类里都要处理其他所有状态的跳转,类与类之间的引用关系乱成一团。状态模式适合状态路径清晰、流转方向固定的场景,如果每个状态几乎都能跳到其他所有状态,那说明你在用的可能是一个图而不是一条链,状态模式的意义就大打折扣。
第三,事件驱动还是方法驱动。状态模式天然适合事件驱动模型:外部只需要一句context.handleEvent(event),状态对象内部自己决定怎么响应、怎么迁移。如果你的业务是直接调用“支付成功()”这种强语义方法,你也可以把方法定义在状态接口里,让每个状态类自己去实现。两种风格没有对错,但一定要在项目里保持一致,否则团队看代码时会很混乱。
3. 状态模式完整落地:以订单状态机为例
3.1 先梳理业务规则,别急着写代码
这一步很多人会忽略。我见过的失败重构案例,大多不是因为状态模式本身有问题,而是业务规则没梳理清楚就开始拆类,拆到一半发现某个状态漏了一种事件,又回来改结构,改得面目全非。
所以第一步,先把订单的状态、事件、动作、迁移目标列成一张表。这张表,建议你直接贴在团队文档里,比任何代码注释都管用。以我这次重构为例,我整理出的简化版规则如下:
| 当前状态 | 接收事件 | 执行动作 | 迁移后状态 |
|---|---|---|---|
| 待支付(CREATED) | 支付成功 | 校验并锁定库存、记录支付流水 | 已支付(PAID) |
| 待支付(CREATED) | 用户取消 | 释放优惠券、记录操作日志 | 已关闭(CLOSED) |
| 已支付(PAID) | 卖家发货 | 调用物流下单、记录物流单号 | 已发货(SHIPPED) |
| 已支付(PAID) | 用户申请退款 | 发起退款、冻结订单 | 退款中(REFUNDING) |
| 已发货(SHIPPED) | 确认收货 | 释放预占库存、订单完成 | 已完成(COMPLETED) |
| 已发货(SHIPPED) | 申请售后 | 进入售后流程、通知客服 | 售后中(AFTER_SALE) |
| 退款中(REFUNDING) | 退款到账 | 释放库存、通知用户 | 已关闭(CLOSED) |
注意,这张表里我没有写“哪些状态不允许接收哪些事件”,因为状态模式的优雅之处就在于:事件到了不属于它的状态类,根本不会被处理。
3.2 搭建状态接口与上下文环境
确定了规则,下面开始写代码。我是用 Java 演示的,但你完全可以用同样的结构映射到 Python、TypeScript、C++,思路是通用的。
先定义一个顶层状态接口:
public interface OrderState { /** * 处理订单事件 * * @param orderContext 订单上下文,持有当前状态 * @param event 订单事件 * @throws IllegalStateException 当事件在当前状态下不被支持时抛出 */ void handleEvent(OrderContext orderContext, OrderEvent event); }接着定义订单事件枚举:
public enum OrderEvent { PAY_SUCCESS, USER_CANCEL, SELLER_SHIP, USER_REFUND_APPLY, USER_CONFIRM_RECEIPT, USER_AFTER_SALE_APPLY, REFUND_SUCCESS }然后是上下文类。这里的 OrderContext 就是外部调用方见到的门面,它内部持有一个当前状态引用,同时把订单的基本信息(订单号、金额、商品列表)也放在里面,因为状态类在执行动作时需要读取这些信息:
public class OrderContext { private OrderState currentState; private String orderId; private BigDecimal amount; private Long skuId; private Integer quantity; private String logisticsNo; // 其他业务字段... public OrderContext(OrderState initialState) { this.currentState = initialState; } // 核心方法:把事件委托给当前状态对象处理 public void handleEvent(OrderEvent event) { currentState.handleEvent(this, event); } // 状态变更方法 public void setState(OrderState state) { this.currentState = state; } public OrderState getCurrentState() { return currentState; } // 业务字段的 getter/setter 省略... }外部调用方式变得极其简单,比如在支付回调里,只需要初始化一个处于“待支付”状态的 OrderContext,然后调用context.handleEvent(OrderEvent.PAY_SUCCESS)。至于当前状态到底能不能处理支付成功事件,处理之后要不要锁定库存、要不要更新状态字段,这些统统交给状态对象自身去决策。
3.3 一个状态一个类,规则内聚
现在我们来实现具体状态类。这里以“待支付”状态为例,其他状态类结构完全对称:
public class CreatedOrderState implements OrderState { @Override public void handleEvent(OrderContext context, OrderEvent event) { switch (event) { case PAY_SUCCESS: // 1. 校验并锁定库存(调用库存服务) boolean locked = inventoryService.lockStock( context.getSkuId(), context.getQuantity()); if (!locked) { throw new RuntimeException("库存不足,支付失败"); } // 2. 记录支付流水、发送支付成功通知... // 3. 迁移状态:待支付 -> 已支付 context.setState(new PaidOrderState()); break; case USER_CANCEL: // 1. 释放优惠券、记录取消日志... // 2. 迁移状态:待支付 -> 已关闭 context.setState(new ClosedOrderState()); break; default: // 兜底:当前状态不支持该事件,抛出异常或记录告警 throw new IllegalStateException( "订单状态[" + context.getCurrentState().getClass().getSimpleName() + "]不支持事件[" + event + "]"); } } }你可能注意到了,这里我在“待支付”状态内部又用了 switch。那这算不算又绕回了 switch 地狱?其实不然,这里的 switch 和前文那个“满屏 switch”有本质区别。原来那个 switch 是同时按状态和事件两个维度展开的,状态一旦多了,分支数量是乘积级的;而现在这个 switch 只负责“当前状态下的不同事件”,维度只有事件一个。每个状态类只关心自己状态内部的事件分支,状态之间互不干扰。
比如“已支付”状态的实现:
public class PaidOrderState implements OrderState { @Override public void handleEvent(OrderContext context, OrderEvent event) { switch (event) { case SELLER_SHIP: // 1. 调用物流服务生成物流单号 String logisticsNo = logisticsService.createOrder( context.getOrderId(), context.getSkuId()); context.setLogisticsNo(logisticsNo); // 2. 迁移状态:已支付 -> 已发货 context.setState(new ShippedOrderState()); break; case USER_REFUND_APPLY: // 1. 发起退款流程(调用支付平台退款接口,异步回调) refundService.apply(context.getOrderId(), context.getAmount()); // 这里注意:退款到账是异步事件,因此只是迁移到“退款中” context.setState(new RefundingOrderState()); break; default: throw new IllegalStateException( "订单状态[" + context.getCurrentState().getClass().getSimpleName() + "]不支持事件[" + event + "]"); } } }再往下,“已发货”状态和“退款中”状态的实现方式相同,只是事件分支不同。全部写完之后,整个订单状态机的代码就非常清晰了:你找任何一个状态,只需要打开对应的状态类,就能看到该状态下所有支持的事件、动作和迁移目标。没有散落在各处的散装判断,没有需要全项目搜索的分支。
3.4 状态对象的单例优化
你可能会注意到上面的写法里,我每次迁移状态时都new了一个新的状态对象。在订单这种低频业务场景下,这种写法完全没问题,不会有性能瓶颈。但如果你的系统是高频状态机(比如网络连接管理、游戏角色状态切换,每秒切换频繁),频繁 new 状态对象会造成不必要的内存分配。
解决方案很简单:因为状态对象本身在绝大多数实现里是不包含自身运行时的可变字段的(状态字段都在 Context 里),所以可以把状态类实现为单例。我习惯用一个简单的枚举或者静态常量来持有所有状态实例:
public final class OrderStates { private OrderStates() { } public static final OrderState CREATED = new CreatedOrderState(); public static final OrderState PAID = new PaidOrderState(); public static final OrderState SHIPPED = new ShippedOrderState(); public static final OrderState REFUNDING = new RefundingOrderState(); public static final OrderState CLOSED = new ClosedOrderState(); public static final OrderState COMPLETED = new CompletedOrderState(); public static final OrderState AFTER_SALE = new AfterSaleOrderState(); }然后在状态迁移时写context.setState(OrderStates.PAID)即可。这样一来,状态对象全系统只有一份,Context 里只保存状态的引用,内存零浪费。但我建议你在团队规模不大、业务不复杂的时候不必一上来就上单例,先把代码写对,再谈优化。
3.5 依赖注入与状态枚举的替代实现
如果你用的是 Spring 这类依赖注入框架,还有一种更优雅的状态对象装配方式:把每个状态类注册成 Spring Bean,然后通过状态枚举去容器里拿对应的 Bean。这样做的最大好处是:状态类内部可以正常注入 Service、Mapper、Repository 等依赖,不用像上面示例里那样通过静态方法或者服务定位器去获取。
配合枚举,代码可以这样设计:
public enum OrderStateEnum { CREATED("created", "待支付"), PAID("paid", "已支付"), SHIPPED("shipped", "已发货"), REFUNDING("refunding", "退款中"), CLOSED("closed", "已关闭"), COMPLETED("completed", "已完成"), AFTER_SALE("after_sale", "售后中"); private final String code; private final String desc; OrderStateEnum(String code, String desc) { this.code = code; this.desc = desc; } // getter... }每个状态类里面增加一个枚举标识方法,或者用@Service标识 Bean 名称为状态枚举的 code,然后在上下文里写一个从容器中获取状态 Bean 的工厂方法。这样你在写状态迁移的时候,甚至可以做到context.setState(OrderStateEnum.PAID),由工厂方法自动解析出对应的状态对象 Bean。这属于状态模式的 Spring 工程化变体,暂不展开,但你理解了核心思想之后,可以按项目基础来选择装配方式。
4. 常见问题与排查技巧实录
4.1 状态对象直接依赖其他状态类,会不会造成循环依赖
这是我在代码评审时经常被问到的问题。状态模式中,状态类之间确实存在引用关系,比如 CreatedOrderState 在执行完支付动作后,会new PaidOrderState()并设置到 Context 里。如果两个状态类互相迁移,从代码结构上看似乎形成了循环依赖。但在状态模式里这通常不是问题,因为这里的依赖是“使用关系”而非“创建关系”。状态类本身不持有对方的状态,只是创建对方的一个实例,生命周期非常短,也不会造成内存泄漏,所以即使 A 引用 B、B 又引用 A,也不会产生实质性的循环依赖问题。
不过有一个点要注意:如果项目里使用 Spring 管理状态对象,并且状态类之间通过构造器注入对方,就真的会产生 Spring 循环依赖。这时要么改用 Setter 注入,要么采用上面提到的枚举解析工厂方式,在运行时从容器获取目标状态 Bean,避开构造器层面的循环依赖。我在一个老项目里被这个坑过,第一次用 Spring 状态模式时,两个状态类互相构造器注入,启动直接报BeanCurrentlyInCreationException,排查了快一个小时才反应过来。
4.2 事件不被当前状态支持怎么办
状态模式天然把“哪些事件在当前状态合法”收敛到了状态类内部,默认行为自然是不处理或抛异常。但在真实业务里,“异常”不能是简单粗暴的一抛了之。我建议在兜底分支里做的第一件事是记录一条结构化的告警日志,包括订单号、当前状态、到达的事件、请求来源、时间戳。因为这种异常往往意味着线上有非法调用、状态被人为篡改,或者上下游系统传输了错误的数据,需要第一时间发现。
某些业务场景下,你甚至可以把“非法事件”也当成一种合法事件来处理。比如支付成功事件重复回调(支付平台因为网络原因重试),如果你的 CreatedOrderState 已经迁移到了 PAID,第二次回调到达时当前状态已经是 PaidOrderState,而 PaidOrderState 并不支持 PAY_SUCCESS 事件,于是会走入兜底逻辑。这时候如果只是抛异常,可能导致支付平台一直重试,甚至需要人工介入处理。合理做法是在兜底逻辑里增加幂等判断:如果事件是 PAY_SUCCESS,且业务上已经支付过,那就直接返回成功,不抛异常。类似的幂等分支可以根据具体业务去补充。
4.3 状态字段存在数据库里,代码里的状态类如何与之对应
这是状态模式落地时最现实的问题。状态模式里对象“当前状态”体现为 Context 持有的状态类实例,可一旦订单被持久化到数据库,状态字段存的只是一串枚举编号。在下次请求到来时,你需要把数据库里的状态编号,还原成对应的状态对象。
常见做法有两种。第一种,启动时建一个映射表,把所有状态编号和状态类的实例关联起来,查询订单后直接查映射表获取初始状态:
private static final Map<String, OrderState> STATE_MAP = new HashMap<>(); static { STATE_MAP.put("created", OrderStates.CREATED); STATE_MAP.put("paid", OrderStates.PAID); // ... } public OrderContext loadOrder(String orderId) { OrderPO orderPO = orderMapper.selectById(orderId); OrderState initialState = STATE_MAP.get(orderPO.getStatus()); OrderContext context = new OrderContext(initialState); // 填充其他字段... return context; }第二种是在状态类里加一个getStateCode()方法,持久化时调用context.getCurrentState().getStateCode()拿到编号,查询时通过枚举的 code 反查状态类。两种方式效果等价,看团队习惯。我个人的经验是:映射表的方式更直观,一屏就能看到所有状态与类的对应关系,方便新同学快速熟悉状态机整体结构。
4.4 如何保证状态迁移的原子性
订单状态从“待支付”变成“已支付”,往往伴随着写数据库、调库存服务、发消息等多个步骤。如果其中某一步失败,状态已经变了,数据就不一致了。这片问题的关键在于:状态迁移动作是分布式的,状态模式本身不解决事务问题。我建议把状态迁移的落地拆成两层:
- 业务动作层:执行与外部系统交互的操作,比如锁库存、调物流接口。这些操作要尽可能设计成幂等的,方便失败重试。
- 状态持久化层:在业务动作全部成功之后,最后再 UPDATE 数据库的状态字段。
这里有一个细节:状态类的 handleEvent 方法是把“动作”和“迁移”放在一起执行的。系统挂掉或者外部调用失败,实际上状态类的代码已经抛异常了,setState 也不会被执行,Context 内存里的状态不会改变,数据库状态也没有被更新,相当于本次操作失效。这种“先动作、后迁移”的顺序天然是安全的。真正需要警惕的是另一种情况:你为了性能,先把数据库状态乐观锁更新了,再去做外部调用——一旦外部调用失败,数据库状态已经推进,很难回滚。所以原则上,外部动作完成之前,绝不更新数据库状态字段;可用于保护这种操作的乐观锁版本号也一定要加上。
4.5 状态类数量膨胀怎么办
如果你的业务状态到了十几个甚至几十个,每个状态一个类会让目录变得很庞大。这时候可以按业务域拆分包结构,比如order.state包下只放订单状态类,payment.state下只放支付状态类;或者按生命周期阶段合并状态类,比如把“已支付”“已发货”这种相邻状态合并为一个大状态类,在类内部再通过子状态二次判断。后者是我踩过坑后不推荐的做法——它会破坏状态模式“一个状态一个类”的清晰感,等于把多个状态的逻辑堆在同一个类里,后面的维护者很容易迷失。
对于绝大多数项目,状态数量控制在 10 个以内时,一个状态一个类就是最清晰最合理的方式。类多不是问题,职责杂才是问题。我见过一个订单系统重构后状态类从 8 个变成了 22 个,原因是把“动作”也拆成了状态——那是错误地使用了状态模式,把原本属于策略模式职责的动作拆分硬套了进来。状态模式划分的依据是“状态”,不是“行为”。一个状态接收同一个事件后无论走多少分支,只要它迁移后的结果状态不同,都应该由事件处理逻辑去判断,而不是拆成多个状态类。
5. 状态模式的扩展思考与结合实践
5.1 状态模式与有限状态机框架的取舍
写这篇文章的时候我还专门研究了一下业界在状态机这块的做法。很多团队没有手动实现状态模式,而是直接引入了 FSM 框架,比如 Java 里的 Spring StateMachine、Cola StateMachine,它们提供了比较完善的状态定义、事件路由、转换器链、持久化方案等能力。既然现成框架这么方便,为什么还要自己写状态模式?我的答案是:看场景复杂度和团队掌控力。
如果你的状态机规则简单直接,状态和事件都在个位数规模,手动实现状态模式完全足够了,类数量可控,代码逻辑一眼到底,排错成本极低。而如果状态数量和事件数量都很大,而且规则经常由运营配置调整、需求快速变更,那么引入成熟的 FSM 框架,利用它的事件发布、监听器、规则持久化能力,更能承受复杂性。状态模式是你理解一切状态机框架源码的底层基础——框架的节点概念对应状态类,事件路由对应 handleEvent 的分发逻辑。先把状态模式吃透,再去用框架,你才不会被框架的抽象层绕晕。
5.2 状态模式在游戏开发与连接管理中的应用
订单状态机是状态模式最经典的例子,但它是典型的企业应用,还远远不能覆盖状态模式的全部价值。在游戏开发中,角色的“待机、移动、攻击、受伤、死亡”就是一组非常典型的状态,每个状态下对于“玩家按下攻击键”这个事件的响应是完全不同的——攻击状态下按攻击键可能是连击,死亡状态下按攻击键则根本不该有任何反应。如果把角色行为用一大堆 if 判断状态来写,那这个游戏角色的 AI 代码很快就会膨胀到没法维护。我在业余折腾过一个小游戏 demo,把角色的每个状态抽成独立类之后,新增一个“眩晕”状态只需要写一个类,其他所有状态类的代码一行都不用动,这种扩展体验是 switch 完全给不了的。
网络连接管理也是一个典型场景:一个连接对象会经历“已创建、连接中、已连接、重连中、已断开”等多种状态。不同状态下收到“读到了数据”这个事件的响应完全不同:已连接状态下要处理业务报文,重连中状态下可能直接忽略。用状态模式去管理连接生命周期,代码的清晰度会大大提高。
5.3 状态模式与 Spring 状态机框架的小对比
用一张表来总结手动状态模式和成熟状态机框架的差异,方便你在技术选型时做判断:
| 维度 | 手动实现状态模式 | 成熟 FSM 框架(如 Spring StateMachine) |
|---|---|---|
| 上手成本 | 低,核心代码几十行 | 中高,需要理解框架的 State、Event、Transition、Action、Guard 等概念 |
| 规则可视性 | 规则内聚在状态类中,阅读源码即可理解 | 规则往往通过配置或 Java DSL 声明,适合规则外置和动态调整 |
| 事件动作扩展 | 在状态类方法中自行扩展 | 支持 Action、Guard、Interceptor 等扩展点,能力更强 |
| 持久化与审计 | 需要自己实现 | 框架提供持久化策略和完整的事件监听器,便于审计 |
| 适用场景 | 状态和事件数量可控、规则清晰的业务 | 状态事件多、规则频繁调整、需要审计报表的大型系统 |
老实说,如果你现在项目的数据量和状态复杂度还没有到需要专门上框架的程度,我的建议非常明确:先手动实现状态模式,维护成本低,理解成本也低。等到你真的需要在规则配置和事件追踪上大规模投入时,再考虑框架化,那时候你已有的状态模式代码也能让你更平滑地理解框架的设计。
5.4 状态模式与领域驱动设计的结合
最后聊一点偏设计层面的想法。状态模式天生和 DDD(领域驱动设计)合拍。在 DDD 里,一个聚合根对象的状态迁移往往是不变量约束的一部分,比如“已取消的订单不能再次支付”“已关闭的订单不能修改收货地址”等。如果这些规则散落在应用服务里,不同的人实现时可能写出不同口径的判断。而状态模式把状态和行为内聚成领域对象的一部分,聚合根内部维护状态对象,外界通过领域方法触发迁移,这正好符合 DDD 中“领域逻辑放在领域层,应用层只做编排”的原则。
我用过的一个偏 DDD 的项目里,订单聚合根内部直接持有一个 OrderState 引用,应用服务调用聚合根的public void paySuccess()/public void cancel()方法,聚合根内部再把请求委托给状态对象。这样领域层的规则完整、自动、一致,外部使用方甚至完全感觉不到状态对象的存在。对我来说,这才是状态模式最优雅的用法:状态逻辑不是暴露在外的技术细节,而是被封装进领域内部的自洽机制。
最后想说点实际的
状态模式这个设计模式,教科书里的定义很简单,但真正在工作中用好它,靠的是对“状态”和“行为”之间关系的深刻理解。我踩过不少坑,最深刻的一条是:不要为了用设计模式而用设计模式。如果一个状态机的状态只有两三个,事件也就那么两三个,你用 switch 三行就写完了,硬套状态模式反而会把代码搞复杂。状态模式的价值在状态多、行为多、流转规则复杂的时候才会被无限放大。
我自己判断是否使用状态模式时通常会问三个问题:状态数量超过五个了吗?不同状态对同一事件的响应差异大吗?新增一个状态时,现有的代码是否有至少两处需要跟着改动?如果三个问题的答案都是肯定的,我就会毫不犹豫地选择状态模式。
另外,哪怕你最后决定不重构现有的 switch 代码,我也强烈建议你画出自己业务里的状态迁移表。这个动作本身的价值就非常大——你会发现很多当前代码里根本没有考虑过的非法迁移路径,它们正是线上很多诡异 bug 的源头。状态模式不仅仅是一种代码结构,它更是一种逼迫你去审视业务规则的思维方式。跟代码较劲久了你会发现,设计模式的魅力从来不在于它本身,而在于它帮你把复杂度控制在一个可以理解的范围之内。