第一次接手一个订单模块的改造需求时,我在 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 Map<String, 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); } }终态直接留空即可,所有动作都会命中父类的reject:
public 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 List<String> 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 List<String> 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;如果既要并发又要性能,可以用AtomicReference<OrderState>配合 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就是一个Map<OrderStatus, OrderState>,初始化时把五个单例塞进去。这一步千万别用switch写反查逻辑,不然状态一多,反查会变成全项目最难维护的一段代码。
这里还有个隐藏陷阱:如果某个状态类带了运行时字段(像前面那个retry),那它根本无法从数据库恢复,因为重启后这个值就丢了。所以我在 5.2 里才强调要把它放到 Context 里,而 Context 的数据本来就是要持久化的。
8.3 循环引用与静态初始化的隐秘顺序问题
如果两个状态互相在对方类里引用单例,比如PendingPaymentState和CancelledState互相import并访问INSTANCE,一般情况下没问题,但一旦涉及复杂的静态初始化块,就可能出现"读到 null"的诡异现象——因为 JVM 是按需初始化类的,A 初始化到一半去访问 B,B 又回头访问还没初始化完成的 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(),五行测试代码搞定。如果你团队里也有那种改一处状态规则要跑半小时回归测试的模块,状态模式值得试一次。