设计模式预计阅读 17 分钟6715 字1320 次浏览

设计模式修炼之路 ⑲ 状态模式——状态驱动行为

状态驱动行为、流转规则住进状态家、Thread 状态机、状态 vs 策略终局对决

目录

行为型第六站。

上一篇结尾留了个场景:电商订单。"待支付"的订单点取消,直接关单连退款都不用;"已发货"的订单点取消,只能走退货流程;"已取消"的订单点什么都没用。同一个按钮,状态不同,行为完全不同。

这一篇就把订单这套流转讲透——状态模式,把"如果/否则"炼成"对象"的终极形态。

一、状态模式是什么

GoF 的原始定义:

Allow an object to alter its behavior when its internal state changes. The object will appear to change its class. (允许对象在内部状态改变时改变它的行为,对象看起来好像修改了它的类。)

翻译成人话:**把每种状态做成一个类,行为跟着状态走。**同一个方法调用,状态对象不一样,执行的行为就不一样——外面看起来,订单对象像是"整个换了一个类"。

两个核心角色:

角色 干什么 本文对应
State(抽象状态) 定义该状态下所有动作的接口 OrderState
ConcreteState(具体状态) 每个动作:放行并流转,或者就地拒绝 待支付/已支付/已发货/已取消
Context(上下文) 持有当前状态,把动作原样委托出去 OrderContext

类比还是订单本身:与其让订单对象背着一本《全状态操作手册》每次办事前翻一遍(if-else),不如给订单配一个"当前状态客服"——你说"取消",客服看自己是哪个状态的:待支付客服直接关单,已发货客服让你走退货。订单自己永远只说一句话:"这事儿找我当前状态的客服。"

二、优点与缺点

优点:

  1. 状态判定本地化。每种状态只在自己的类里写自己的规则——待支付类里再也不会出现"已发货该怎么办"的分支。改哪个状态的行为,就打开哪个状态的类,一目了然。
  2. 新增状态是加法不是改法。新加一个"已完成"状态,写一个新类,只需要让相邻状态知道它——不用把所有方法的所有分支全部过一遍。
  3. 消灭非法状态下的灵异操作。"已取消的订单还在发货"这种 bug,在 if-else 版本里靠人肉细心防守,在状态模式里由类型系统保证——CancelledState 的 ship 方法里就一句提示,想发都发不出去。
  4. 状态流转与业务行为内聚。流转规则(支付后去已支付)写在状态自己的类里,规则和行为住在一起,而不是散落在十几个 if 分支里。

缺点:

  1. 类数量膨胀。一个状态一个类,状态多了类也多——和命令模式 ⑱ 同款"病"。
  2. 流转规则分散。if-else 版本虽然丑,但所有流转集中一眼看完;状态模式里流转规则散落在各个状态类里,想看全貌得画图。
  3. 状态间耦合。UnpaidState 要 new PaidState——状态类之间互相认识。流转图一复杂,状态类之间容易缠成一团。
  4. 简单场景杀鸡用牛刀。两三个状态、每个状态就一两条规则,一个 enum 加 switch 就够了,硬上状态模式纯属自虐。

三、什么时候用

判断标准:对象的行为随状态改变,而且状态之间存在流转关系、状态数量会增长、非法操作必须被可靠拦截。

  • 订单流转:待支付/已支付/已发货/已完成/已取消——本文场景
  • 审批流:草稿→提交→审核中→通过/驳回,工作流引擎的看家本领
  • 连接生命周期:TCP 三次握手四次挥手那张状态图
  • 游戏 AI:怪物巡逻→发现玩家→追击→攻击→返回,经典有限状态机
  • 播放器/电梯/红绿灯:所有"按状态出牌"的设备逻辑

反面对照:状态固定且少(两三个)——enum + switch 足够;各分支是平行算法而非流转关系——那是策略模式 ⑮;分支是在选处理者而非改行为——那是责任链 ⑰。

四、实现方式:订单状态机

4.1 反面教材:if-else 状态机

状态用 String 存着,每个方法先问一遍"现在是什么状态":

public class BadOrder {

    private String state = "待支付";

    public void pay() {
        if (state.equals("待支付")) {
            state = "已支付";
            System.out.println("  支付成功,当前状态:已支付");
        } else if (state.equals("已支付")) {
            System.out.println("  该订单已支付,请勿重复支付");
        } else if (state.equals("已发货")) {
            System.out.println("  订单已发货,无需支付");
        } else if (state.equals("已取消")) {
            System.out.println("  订单已取消,无法支付");
        }
    }

    public void cancel() {
        if (state.equals("待支付")) {
            state = "已取消";
            System.out.println("  未支付直接关单,当前状态:已取消");
        } else if (state.equals("已支付")) {
            state = "已取消";
            System.out.println("  走退款流程,当前状态:已取消");
        } else if (state.equals("已发货")) {
            System.out.println("  已发货订单不能直接取消,请走退货流程");
        } else if (state.equals("已取消")) {
            System.out.println("  订单已取消,请勿重复操作");
        }
    }

    public void ship() {
        if (state.equals("待支付")) {
            System.out.println("  未支付订单不能发货");
        } else if (state.equals("已支付")) {
            state = "已发货";
            System.out.println("  发货成功,当前状态:已发货");
        } else if (state.equals("已发货")) {
            System.out.println("  订单已发货,请勿重复发货");
        } else if (state.equals("已取消")) {
            System.out.println("  订单已取消,无法发货");
        }
    }

    // 状态是 String,流转规则全靠每个方法里的人肉 if-else 对齐:
    // 新加一个"已完成"状态?三个方法全要改,十二个分支挨个检查,
    // 漏掉任何一处,就会出现"已取消的订单还在发货"这种灵异事件
}

病灶拆解:三个方法 × 四个状态 = 十二个分支,而且这个数字是乘法关系增长的——状态加到六个、动作加到五个,就是三十个分支的矩阵。更阴险的是,这个矩阵没有任何东西帮你对齐:某次需求改了"已发货不能取消"的文案,你只想改一处,却必须在三个方法里分别确认有没有散落的同款逻辑。

4.2 正解:每种状态一个类

第一步,抽象状态——把订单的所有动作定义为接口:

public interface OrderState {

    void pay(OrderContext order);

    void cancel(OrderContext order);

    void ship(OrderContext order);
}

第二步,上下文。注意它有多"闲"——三个动作全是原样转发:

public class OrderContext {

    private final String orderId;
    private OrderState state;

    public OrderContext(String orderId) {
        this.orderId = orderId;
        this.state = new UnpaidState();
        System.out.println("  订单 " + orderId + " 下单成功,当前状态:待支付");
    }

    public void pay() { state.pay(this); }
    public void cancel() { state.cancel(this); }
    public void ship() { state.ship(this); }

    public void setState(OrderState state) { this.state = state; }
    public String getOrderId() { return orderId; }
}

第三步,具体状态。待支付状态:支付放行、取消放行、发货拒绝——流转规则写在状态自己家里:

public class UnpaidState implements OrderState {

    @Override
    public void pay(OrderContext order) {
        order.setState(new PaidState());
        System.out.println("  [待支付] 支付成功,流转:待支付 → 已支付");
    }

    @Override
    public void cancel(OrderContext order) {
        order.setState(new CancelledState());
        System.out.println("  [待支付] 未支付直接关单,流转:待支付 → 已取消");
    }

    @Override
    public void ship(OrderContext order) {
        System.out.println("  [待支付] 未支付订单不能发货");
    }
}

已支付(重复支付拒绝、取消走退款、发货放行)、已发货(取消只能走退货流程)、已取消(全部拒绝)三个状态类结构完全相同,各自安放各自的规则。

最后测试——重点看两笔订单的对照:订单 A 一路走到已发货再取消,订单 B 下单就反悔:

public class StateTest {

    public static void main(String[] args) {
        System.out.println("========== 反面教材:if-else 状态机 ==========");
        BadOrder bad = new BadOrder();
        bad.pay();
        bad.pay();
        bad.ship();
        bad.cancel();

        System.out.println();
        System.out.println("========== 正解:状态模式 ==========");

        System.out.println("-- 订单A:一路走下去,再想取消 --");
        OrderContext orderA = new OrderContext("A-2024-001");
        orderA.pay();
        orderA.ship();
        orderA.cancel();

        System.out.println("-- 订单B:下单就反悔 --");
        OrderContext orderB = new OrderContext("B-2024-002");
        orderB.cancel();
        orderB.pay();
        orderB.ship();
    }
}

运行输出:

========== 反面教材:if-else 状态机 ==========
  支付成功,当前状态:已支付
  该订单已支付,请勿重复支付
  发货成功,当前状态:已发货
  已发货订单不能直接取消,请走退货流程

========== 正解:状态模式 ==========
-- 订单A:一路走下去,再想取消 --
  订单 A-2024-001 下单成功,当前状态:待支付
  [待支付] 支付成功,流转:待支付 → 已支付
  [已支付] 发货成功,流转:已支付 → 已发货
  [已发货] 已发货不能直接取消,请走退货流程
-- 订单B:下单就反悔 --
  订单 B-2024-002 下单成功,当前状态:待支付
  [待支付] 未支付直接关单,流转:待支付 → 已取消
  [已取消] 订单已取消,无法支付
  [已取消] 订单已取消,无法发货

4.3 三处细节咂摸一下

细节一:流转规则搬家了。order.setState(new PaidState()) 写在 UnpaidState 内部——"我支付之后去哪"由我自己决定,不再由上下文的某个 if 分支决定。每个状态类就是流转图上的一个节点,节点只画自己出边的箭头。想看完整流转图?把每个状态类的方法扫一遍,出边就全齐了。

**细节二:同一个 cancel(),三种命运。**订单 A 在已发货状态取消,被引导去退货流程;订单 B 在待支付状态取消,直接关单;若在已支付状态,则会走退款。客户端三次调用的代码一模一样,行为差异完全由"当前是谁在值班"决定——这就是"状态驱动行为",也是定义里"对象看起来好像修改了它的类"的真身:同一个 OrderContext,状态一换,整个行为模式全变。

**细节三:上下文被架空成转发器,这是设计而不是偷懒。**OrderContext 的三个方法清一色 state.xxx(this)——它存在的意义只剩"持有当前状态 + 提供身份信息"。有人嫌这层委托多余想砍掉,砍了之后调用方就得自己持有状态对象、自己管流转,状态模式的封装就破了。上下文是外界和状态机之间的防火墙。

4.4 开闭原则的成色

诚实地说:状态模式对开闭原则的兑现是"打折扣"的。新加"已完成"状态,确实不用改 if-else 矩阵了,但 ShippedState 的收货动作、CancelledState……凡是流转图上有边指向新状态的旧状态类,都得动。

它的真实收益是:**把"改"的范围从"全方法全分支"压缩到"相邻状态类"。**改动的爆炸半径从 O(方法数 × 状态数) 缩到 O(相邻状态数)——这就是那张流转图的意义:图越长越密,状态模式越划算;三两个状态的小图,enum 加 switch 反而是更好的工程选择。

五、在框架里找它

Thread——JDK 亲手实现的状态机。Thread.State 枚举:NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED,六种状态一张流转图。而你早就体验过"状态驱动行为":start() 只在 NEW 状态合法,对同一个线程调第二次 start 会抛 IllegalThreadStateException——"已启动的线程不能再启动",这正是 CancelledState 拦截 ship 的同款逻辑。顺带注意 Thread.State 是 enum:无字段的状态类用枚举实现,天然单例(② 单例模式的老朋友),本文四个状态类同样无字段,工程上完全可以枚举化。

**Spring StateMachine。**Spring 官方状态机框架:Builder 里声明状态集合、事件集合、流转规则、流转守卫(条件放行)、动作监听器。订单、支付网关、物联网设备状态,状态超过十个、流转带条件时,它比手写状态类省心得多——本篇手写状态类是为看懂骨架,生产里复杂场景请直接上框架。

**工作流引擎。**Activiti、Flowable、Camunda 把"状态+流转"做成了数据库表 + 流程图 DSL:审批流的每个节点是一个状态,审批通过是流转事件,会签、驳回、超时自动流转都是引擎内置能力。企业级审批系统的终极形态。

**TCP 协议栈。**三次握手、四次挥手那张经典状态图(CLOSED→SYN_SENT→ESTABLISHED→FIN_WAIT……),就是操作系统内核里一台真实运转的状态机——状态模式的威力,撑得起整个互联网的连接管理。

一句话总结这一节:凡是"行为随状态变、状态按图流转"的地方,从 JDK 到内核到工作流,状态机无处不在;状态模式就是状态机的面向对象实现。

六、状态 vs 策略:孪生兄弟的最后一战

⑱ 讲命令 vs 策略时留了个尾巴:状态模式和策略模式结构也几乎一样——接口、一堆实现类、一个持有者。这两个是 GoF 二十三式里最著名的孪生兄弟,系列走到这里,最后一战做个了断:

策略模式 ⑮ 状态模式 ⑲
一堆实现类是什么 平行的**算法** 流转图上的**节点**
实现类之间认识吗 互不相识 相识(知道流转目标是谁)
谁来切换 **外部**指定(调用方/工厂) **内部**流转(状态类自己 setState)
切换像什么 换插槽上的卡 图上的节点跳转
关心历史吗 不关心 当前状态就是全部历史沉淀

最狠的分辨方法来了:**把 UnpaidState 里那句 order.setState(new PaidState()) 删掉,状态模式瞬间退化成策略模式。**分水岭就一句话——有没有流转。策略是一排平行选项,选哪个、何时换,外面的世界说了算;状态是一张有向图,走哪条边,图上的规则说了算。

面试官问"状态和策略有啥区别",先答这张表,再补一句"删除 setState 调用、状态模式就变成策略模式",基本稳了。

写在最后

状态模式一句话总结:每种状态一个类,行为跟着状态走;流转规则住进状态自己家,非法操作在源头就被拦下。

下一篇 ⑳ 迭代器模式——轻松一点的话题。你每天都在写 for (String s : list),有没有想过这个冒号背后是谁在工作?为什么 ArrayList、HashSet、LinkedList 都能被 for-each,而你自己写的类就不行?怎么让你自己的集合也享受同款待遇?统一的遍历协议——迭代器模式,下一篇见。

花

花开空白

开发者 · 西安

相关文章

评论(0)

还没有评论,来抢沙发吧

发表评论