如何手写一个状态模式?核心代码怎么设计?
简化版
手写状态模式通常先定义 State 接口,再为每个状态写一个 ConcreteState 类,然后让 Context 持有当前 State,并把操作委托给当前状态对象。状态对象根据业务规则执行行为,并在需要时切换 Context 的当前状态。
详细版
实现状态模式可以按四步走:
- 定义状态接口,列出可能的操作;
- 为每个具体状态实现状态类;
- Context 持有当前状态;
- Context 的业务方法委托给当前状态对象。
简化代码:
interface OrderState {
void pay(OrderContext context);
void cancel(OrderContext context);
}
class OrderContext {
private OrderState state;
public void pay() {
state.pay(this);
}
public void setState(OrderState state) {
this.state = state;
}
}
具体状态类负责当前状态下的行为和迁移。真实项目里还要考虑状态持久化、非法操作返回、并发更新、状态迁移日志和事务一致性。
完整版教学
一、先定义订单状态接口
状态接口要覆盖系统可能收到的操作。
interface OrderState {
void pay(OrderContext context);
void cancel(OrderContext context);
void ship(OrderContext context);
void finish(OrderContext context);
}
注意,这里不是说每个状态都允许这些操作,而是系统可能对订单发起这些操作。每个状态类自己决定允许还是拒绝。
二、实现待支付状态
class WaitPayState implements OrderState {
public void pay(OrderContext context) {
System.out.println("支付成功");
context.setState(new PaidState());
}
public void cancel(OrderContext context) {
System.out.println("取消订单");
context.setState(new CanceledState());
}
public void ship(OrderContext context) {
throw new RuntimeException("待支付订单不能发货");
}
public void finish(OrderContext context) {
throw new RuntimeException("待支付订单不能完成");
}
}
待支付状态只处理自己状态下的规则:可以支付,可以取消,不能发货,不能完成。
三、实现已支付状态
class PaidState implements OrderState {
public void pay(OrderContext context) {
throw new RuntimeException("不能重复支付");
}
public void cancel(OrderContext context) {
System.out.println("退款并取消订单");
context.setState(new CanceledState());
}
public void ship(OrderContext context) {
System.out.println("发货成功");
context.setState(new ShippedState());
}
public void finish(OrderContext context) {
throw new RuntimeException("未发货不能完成");
}
}
已支付状态下,发货是合法迁移,重复支付是非法操作。
四、Context 负责统一入口
class OrderContext {
private OrderState state = new WaitPayState();
public void pay() {
state.pay(this);
}
public void cancel() {
state.cancel(this);
}
public void ship() {
state.ship(this);
}
public void finish() {
state.finish(this);
}
public void setState(OrderState state) {
this.state = state;
}
}
客户端始终调用 OrderContext,不需要关心当前状态。
五、真实项目里状态通常不能只放内存
上面的例子适合讲原理,但真实订单状态通常要存数据库。
例如:
订单表 status 字段
状态变更日志表
业务操作事务
状态对象里的 context.setState() 不能只改内存,还要考虑:
- 数据库状态更新;
- 乐观锁防并发修改;
- 状态变更日志;
- 消息发送;
- 事务回滚。
否则应用重启后状态就没了,或者并发请求把状态改乱。
六、非法操作要有统一处理
状态模式里很多方法在某些状态下不允许执行。
不要每个状态类随便抛不同异常。工程里最好定义统一的非法状态操作异常或统一错误码。
比如:
throw new IllegalStateTransitionException("待支付订单不能发货");
这样上层可以统一转换成接口响应。
七、状态对象是否可以复用
如果状态类本身没有成员变量,只包含行为逻辑,可以设计成单例,避免频繁创建对象。
比如:
enum OrderStates {
WAIT_PAY(new WaitPayState()),
PAID(new PaidState());
}
但如果状态对象持有请求级数据,就不能做成共享单例。更推荐让状态对象无状态,把业务数据放在 Context 中。
八、状态对象复用与持久化一致性
无字段的 PaidState 可作为单例供 1000 个订单复用;订单 id、金额等请求数据必须留在 Context 或参数中,不能写入共享状态对象。
load status -> map to stateless State -> handle event -> CAS persist -> side effects
状态题的正确性落在“当前状态 + 事件 + 守卫条件 → 目标状态”这一条边上,而不是类数量上。每条边都要说明持久化原子性、重复事件结果和副作用触发时机,非法边则必须得到稳定且可观测的拒绝。
九、迁移一致性、幂等与并发验证
| 检查维度 | 应确认的内容 |
|---|---|
| 机制正确性 | 状态类应尽量无状态,真正的订单数据属于聚合,不属于共享状态策略对象。 |
| 适用边界 | 示例只改内存适合教学,生产系统还要把状态码持久化,并用事务或乐观锁防止并发覆盖。 |
| 测试证据 | 覆盖正常迁移、重复事件、非法迁移、持久化失败和两个请求竞争同一版本 |
| 工程代价 | 重点评估状态数、迁移边数、一次迁移的数据库竞争,以及新增状态对完整迁移矩阵的影响 |
易错点:状态类应尽量无状态,真正的订单数据属于聚合,不属于共享状态策略对象。
十、常见误区与追问
- 误区:每个订单都必须 new 一套状态对象。 无请求数据的状态实现可安全复用,减少对象创建;有可变字段则要重新设计。
- 误区:把状态码换成状态类就自动消除了所有条件判断。 状态对象只能封装状态相关行为;持久化、并发控制、权限和跨聚合规则仍需显式设计。
- 追问:数据库更新失败后能先改内存状态吗? 不能把失败更新当成功,应以持久化结果为准,必要时回滚内存或重新加载聚合。
- 追问:非法状态迁移应该静默忽略吗? 不应该;应返回明确业务错误并记录当前状态、事件和聚合标识,重复事件则按幂等规则单独处理。
- 追问:状态模式最容易漏掉哪类测试? 非法迁移、重复事件和并发竞争;它们比单线程成功路径更能证明状态模型可靠。
十一、加强记忆
手写状态模式记住四步:State 接口定义操作,ConcreteState 封装各状态行为,Context 持有当前状态,客户端只调用 Context。真实项目还要补充状态持久化、并发控制、非法操作统一处理和状态变更日志,这些才是工程落地的关键。