ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

状态模式实战:用C++重构订单状态机,告别if-else泥潭

状态模式实战:用C++重构订单状态机,告别if-else泥潭 我在实际项目里接手过一个订单模块当时的代码里塞满了if...else每个处理订单的函数开头都要判断当前状态判断完了还要校验状态迁移合不合法十几层嵌套下来加一个新状态要改十几个函数。后来我把这些逻辑全部重构成状态模式整个模块的可维护性上升了一个量级。这篇文章就把我这次重构的完整思路和踩过的坑写出来从为什么会选择状态模式开始到一个可以直接照抄的C实现再到工程实战里的设计权衡希望能帮到正在被状态逻辑折磨的人。1. 别再到处写if...else状态模式到底解决了什么问题先明确一个事实状态机本身不是一个可选项而是业务系统里绕不开的东西。订单有待支付、已支付、待发货、已发货、已完成、已取消玩家角色有待机、移动、攻击、受击、死亡网络连接有空闲、连接中、已连接、重连中这些本质上都是有限状态机。问题是有状态机和用状态模式实现是两码事绝大多数项目的状态逻辑都是靠散落在各处的条件判断硬堆出来的。1.1 传统写法里最常见的三宗罪我见过太多项目把状态逻辑写成这样void handleOrder(Order order, Event event) { if (order.status pending) { if (event Event::PAY) { order.status paid; deductBalance(); sendNotify(); } else { throw std::runtime_error(当前状态不能支付); } } else if (order.status paid) { if (event Event::SHIP) { order.status shipped; logistics(); } // ... } else if (order.status shipped) { // ... } }这种写法的核心痛点有三个而且是越到项目后期越明显。第一个问题是状态判断的散落。所有涉及状态的函数都得先来一轮if...else确认当前状态业务函数越多重复判断就越多。订单模块里可能有支付函数、发货函数、退款函数、查询函数每个都要先判断状态判断逻辑在六个函数里复制了六遍。这时候你想改一个状态规则得先全局搜索所有相关条件改漏一个就是线上事故。第二个问题是状态迁移规则混在业务逻辑里。上面那个例子里待支付状态下收到PAY事件这个迁移规则和扣减余额发送通知这些具体业务动作全塞在一起。表面上看起来代码量不大但随着业务复杂每多加一个动作这个函数就膨胀一圈最后变成几百行的巨无霸。第三个问题是最隐蔽的——非法迁移的校验不完整。新手很容易写出只处理正常状态的逻辑但已取消的订单又收到发货请求已完成的订单又发起支付这些异常流呢用if...else写的时候大家往往只覆盖了业务上最重要的几条路径边界情况全靠运气。1.2 状态模式的核心就是把判断换成分发状态模式解决这些问题的思路其实很朴素把每种状态本身变成一个对象状态能做什么、允许迁到哪个状态都由这个状态对象自己决定。打个比方传统写法像是前台一个人拿着登记表看到一个人来了就查表这个人是什么身份、有没有权限进哪个房间、进去以后能做什么全在这一张表上判断。状态模式则是给每个身份配了一个专属的引导员你是什么身份就会遇到对应的引导员他能带你去哪些房间、能让你做什么事全由这个引导员说了算前台根本不用再切换到对应的规则去判断。换算到C代码层面状态模式用一个纯虚接口抽象基类定义当前状态下处理各事件的方法每个具体状态类实现自己的行为。原来那一大坨if...else换成了虚函数调用由运行时类型决定走哪个分支。这种方式在维护上最大的好处是要加一个新状态只要新增一个类改动的范围被限制在状态转换涉及的几个类里而不是像if...else那样在十几个地方各加一个分支。2. 从业务里长出来的状态模式一份完整的C实现讲理论不说代码等于白讲我结合自己重构订单模块的实际经验写一个可编译、可直接改造的完整示例。业务场景就是最常见的订单生命周期状态包括待支付、已支付待发货、已发货、已完成、已取消。2.1 状态的抽象基类和上下文#include iostream #include memory #include stdexcept #include string class Order; enum class OrderStatus { PENDING, PAID, SHIPPED, COMPLETED, CANCELED }; class OrderState { public: virtual ~OrderState() default; // 在每种状态下收到各个事件的默认处理抛出非法操作异常 virtual void pay(Order order) { throw std::logic_error(当前状态不支持支付 toString(order.status())); } virtual void ship(Order order) { throw std::logic_error(当前状态不支持发货 toString(order.status())); } virtual void confirm(Order order) { throw std::logic_error(当前状态不支持确认收货 toString(order.status())); } virtual OrderStatus status() const 0; virtual std::string statusName() const 0; }; class Order { public: explicit Order(std::string id) : orderId_(std::move(id)), state_(std::make_uniquePendingState()) {} void pay() { state_-pay(*this); } void ship() { state_-ship(*this); } void confirm() { state_-confirm(*this); } void setState(std::unique_ptrOrderState newState) { state_ std::move(newState); std::cout 订单 orderId_ 状态变更为: state_-statusName() std::endl; } OrderStatus status() const { return state_-status(); } private: std::string orderId_; std::unique_ptrOrderState state_; };这里有个设计细节值得注意OrderState里的默认方法全部抛异常而不是空实现或者只返回错误码。我这个选择是故意的——对于订单这种核心链路状态错乱属于严重bug宁可让它立刻崩溃出来让测试发现问题也不能静默吞掉。在游戏角色AI这类状态频繁切换的场景里你可以改成返回bool或者把错误信息收集起来但在交易类系统里快速失败永远是正确的选择。2.2 具体状态类的实现// 待支付状态 class PendingState : public OrderState { public: void pay(Order order) override { // 实际业务里这里执行扣款、创建支付单等操作 order.setState(std::make_uniquePaidState()); } OrderStatus status() const override { return OrderStatus::PENDING; } std::string statusName() const override { return 待支付; } }; // 已支付待发货状态 class PaidState : public OrderState { public: void ship(Order order) override { // 实际业务里这里下发物流单、通知仓库等 order.setState(std::make_uniqueShippedState()); } OrderStatus status() const override { return OrderStatus::PAID; } std::string statusName() const override { return 已支付; } }; // 已发货状态 class ShippedState : public OrderState { public: void confirm(Order order) override { // 实际业务里这里触发评价、积分结算等 order.setState(std::make_uniqueCompletedState()); } OrderStatus status() const override { return OrderStatus::SHIPPED; } std::string statusName() const override { return 已发货; } }; // 已完成状态这是终态不会再发生迁移 class CompletedState : public OrderState { public: OrderStatus status() const override { return OrderStatus::COMPLETED; } std::string statusName() const override { return 已完成; } }; // 已取消状态同样也是终态 class CanceledState : public OrderState { public: OrderStatus status() const override { return OrderStatus::CANCELED; } std::string statusName() const override { return 已取消; } };注意我把PendingState::pay里的具体业务扣款、创建支付单都留了注释占位实际项目中这些逻辑直接写在状态类的方法里就行。这就是状态模式带来的一个直观改变原来是判断状态执行动作两件事缝在一起现在是这个状态下执行动作的单一职责。2.3 用状态表把规则固化下来写代码之前我强烈建议先把状态迁移图画清楚。这是我重构订单模块时用的状态表当前状态事件结果待支付支付已支付待支付取消已取消已支付发货已发货已支付退款已取消已发货确认收货已完成已完成任意事件非法已取消任意事件非法这张表的价值在于它是你和产品经理沟通的产物也是写状态类时逐项对照的需求清单。我见过不少开发者不管三七二十一直接写代码写完以后状态迁移对不对全靠脑补最后review阶段才被问住已完成状态下能不能申请售后这种根本没在代码里考虑过的问题。先把状态表定下来代码就是按表翻译逻辑漏洞在写码前就能暴露出来一部分。3. 设计权衡状态切换归谁管状态对象怎么共享看完了基础实现很多照着写一遍的读者会开始琢磨状态切换的代码放在具体状态类内部是不是最佳实践每个状态对象都用unique_ptr新new一个状态多了会不会有性能问题这一节专门聊这些真正的设计决策。3.1 状态迁移的三种控制方式状态模式里有一个绕不开的话题状态迁移的判断逻辑应该放在哪里。我总结下来主流有三种做法各有适用场景。第一种就是上面示例里的写法状态自己决定迁到下一个状态。PendingState::pay里直接make_uniquePaidState简单直观状态机规则和业务逻辑放在一起。缺点是状态类之间产生了直接依赖想复用某个状态类到别的上下文时它却硬编码了下一个状态。第二种是把迁移逻辑收敛到上下文Order类里用一张映射表统一管理。这种方式适合状态数量特别多、迁移规则频繁调整的场景改迁移规则时只需要改表不用动状态类。代价是实现复杂度高而且状态类的自描述性变弱了光看一个状态类你不知道它接下来能去哪。我早期做通信协议栈的时候用过这种方式因为那个状态机有二十多个状态靠状态类之间互相引用会乱成一团。第三种是专门的控制器Controller来编排迁移状态类只暴露能力不管迁移。这种方式最灵活但很多项目会把它做得过度设计一个简单的订单状态机套上控制器范式代码量翻倍不说新人看半天也不知道状态迁移在哪定义的。我的个人建议是一般的业务系统比如订单、审批流、游戏角色用第一种就够了如果项目里确实有超过15个状态再考虑第二种不到万不得已别用第三种。架构上有个朴素的原则——你的实现越贴合业务的直接表达方式维护它的成本就越低。3.2 状态对象要不要共享上面代码里每次迁移都是make_unique创建一个新状态对象状态迁移一次就销毁一个旧对象。不少人对这个有疑虑状态对象内部没有数据成员每次新建是不是浪费这个担忧在性能敏感的场景里是有道理的。比如游戏服务器里每个怪物都在跑AI状态机一秒切好几次状态每次都要堆分配内存垃圾回收或内存碎片压力确实存在。这种场景下可以用享元模式把状态对象做成单例或静态实例切换时只替换指针不新建对象。class PendingState : public OrderState { public: static PendingState* instance() { static PendingState inst; return inst; } void pay(Order order) override { order.setState(PaidState::instance()); } // ... };不过我得说句公道话在绝大多数业务系统里订单状态的切换频率极低一天几百万次也远称不上性能瓶颈用unique_ptr新建对象的开销完全可以忽略。为了这点性能去用静态实例反而要小心多线程安全问题。我的建议是先按简单的方式写性能测试真发现问题再优化。很多性能优化最后被证明是过早优化这句话放在状态对象上同样成立。3.3 和持久化状态字段的映射这是状态模式落地项目时最容易被忽略的问题我特别拿出来说一下。状态模式里的状态是运行时对象但数据库里存的是字符串或者int枚举。每次订单状态变更你得把内存中的状态对象转成数据库的值存进去从数据库加载订单时又得把存的值映射回状态对象。我的习惯是状态接口里提供status()返回枚举值数据库存枚举对应的序号或字符串加载时用工厂方法根据枚举值创建对应的状态对象std::unique_ptrOrderState createStateFromStatus(OrderStatus status) { switch (status) { case OrderStatus::PENDING: return std::make_uniquePendingState(); case OrderStatus::PAID: return std::make_uniquePaidState(); case OrderStatus::SHIPPED: return std::make_uniqueShippedState(); case OrderStatus::COMPLETED: return std::make_uniqueCompletedState(); case OrderStatus::CANCELED: return std::make_uniqueCanceledState(); } throw std::invalid_argument(未知的订单状态); }这么做的好处是整个系统里状态枚举只有一个数据源状态机的规则只在状态类内部定义数据库里永远只存那个纯粹的状态标识。我见过有的项目把状态规则也塞进数据库用配置表驱动状态迁移短时间看着灵活时间长了状态和行为的对应关系散落在代码和配置两头排查问题的时候两头跑维护成本反而更高。4. 真正提升可维护性的关键从状态驱动到面向行为建模很多文章讲到状态模式的具体实现就收尾了但我认为那只是学会了形没学到神。状态模式的可维护性红利在重构的思路上才能充分体现。4.1 把能做事的状态和不能做事的状态编进模型回头看看文章开头那个if...else版本handleOrder函数里每个事件分支都要先确认状态对不对不对就报错。这种写法用状态模式重写以后形态变成了这样——PaidState里如果被调用了pay()直接抛异常。看起来只是把if状态判断换成了虚函数分派但实际上整个编程模型都变了。if...else版本的世界观是系统先收到事件再检查当前状态状态合法才执行动作。这是一种被动的、防御式的编程思路所有检查都堆在入口。状态模式版本的世界观则是每个状态对象本身就是当前语境的化身你在待支付状态下根本调不到发货逻辑因为PendingState::ship天然就是非法的。这是一种主动的、按行为组织代码的思路非法操作在编译期和设计期就被约束住了。这种思维转变对一个项目的长期影响特别大。if...else版本加一个新状态你要检查所有入口函数确保新状态在这些函数里都有对应的分支状态模式版本加一个新状态你只需要把所有事件在它身上的动作补充完整老状态一个都不用动。团队大了以后这种差异直接决定了改bug和加需求的效率差多少倍。4.2 状态类的粒度细到什么程度合适实战里经常有一个问题一个状态要不要拆成多个子状态比如订单的已支付状态可能同时包含等待发货已经分配仓库但还没出库退款处理中这几个子状态。你说这算一个状态还是三个状态我的经验是看两个指标一是不同子状态下允许的事件集合是否有差异如果等待发货能取消分配仓库后就不能取消了那必须拆二是不同子状态下相同事件的处理逻辑是否有差异如果退款处理中和已发货收到退款请求走的是完全不同的流程也应该拆。把一个粗粒度的状态绑死在一种行为模型里会让状态类内部再次长出if...else。要是发现某个状态类里的方法开始出现大量分支检查一下是不是应该再拆细一层。设计模式本身不是终点消除复杂分支才是目的。4.3 状态永远不嫌多的场景游戏AI订单系统这种业务场景里状态一般不多状态模式的优势体现得还不算淋漓尽致。游戏AI的状态机才是真正能把这个模式的价值放到最大的地方尤其是怪物的行为AI。一个基础怪物的状态就包括待机、巡逻、追击、攻击、施法、被控制、逃跑、死亡。用if...else写怪物AI逻辑每一帧都要检查所有状态和事件代码写到最后自己都不知道怪物到底在干嘛。用状态模式以后每个状态类只包含进入、执行、退出三种行为逻辑清爽很多。而且游戏AI有个显著特点——状态切换极其频繁一瞬间要判断受击了应不应该从攻击状态切到硬直状态状态模式天然适合这种场景。游戏AI里比订单系统更复杂的是状态迁移经常是多对多的任何状态下都可能受击受击状态下收到不同技能又切到不同的硬直/霸体状态。这种复杂的迁移关系光靠状态类内部的耦合已经不够了通常需要引入专门的状态机管理模块用迁移表来组织。这也是为什么状态模式在游戏引擎里会有分层状态机这种高级变体。5. 实战中容易踩的坑状态爆炸、并发读写与过度设计状态模式虽然经典但绝不是银弹。我在实际使用中踩过几个很典型的坑这里逐个说清楚免得后来人掉进去。5.1 状态模式的回马枪状态里面写if有个场景我印象很深。当时给一个视频审核系统做状态机有上传中、转码中、审核中、审核失败、已发布这些状态。一开始代码很干净每个状态类只管自己的行为。后来产品加了一个需求审核失败的状态用户重新提交后要回到审核中但如果当前是转码中的状态重试按钮置灰。这时候我差点就在FailedState::retry里加一个if判断转码状态——这个念头一冒出来我就警惕了。如果真这么写了状态类之间就出现了隐藏的依赖审核失败状态逻辑里居然要关心转码中的状态。正确的做法是重新提交这个操作本身就只能在审核失败状态下发生如果UI要控制转码中状态下重试按钮不可用那是UI层的展示逻辑不应该侵入状态类。记住一个判断标准状态类里出现对其他状态的判断一定是设计出问题了。要么是这个状态类粒度不对要么是UI/业务层的职责混进来了。5.2 异步场景下的状态竞争状态模式在单线程里很完美一旦牵扯到多线程事情就开始复杂了。订单支付和取消几乎是同时发生的两个线程同时读到一个订单状态是待支付一个执行支付成功切到已支付一个执行用户取消切到已取消谁后写谁覆盖最后状态是什么完全取决于线程调度。正确解法是给状态切换加锁把状态校验和切换做成原子操作class Order { public: void pay() { std::lock_guardstd::mutex lock(mutex_); state_-pay(*this); } private: std::mutex mutex_; std::unique_ptrOrderState state_; };关键点是锁必须加在状态方法的最外层调用入口而不是加在具体状态类内部。如果把锁分散到每个状态类方法里两个线程依然可能同时进入不同的状态方法。这也是状态模式落地到服务端时最容易被忽略的一个细节一旦线上出现状态错乱排查起来极为痛苦。5.3 别为了用模式而用模式和状态模式最相关的过度设计是明明只有两三个状态也要草率地把整个类层次搭出来。两个状态的时候if...else完全够用甚至更直观。状态模式的价值在状态数量至少四五个以上且迁移规则频繁变动的时候才会显现。我给团队定的经验法则是三个状态以下直接用简单的枚举加判断四到八个状态用状态模式八个以上考虑状态机工具库或迁移表。这样既不会过度设计也没有错过模式的收益。我在重构订单模块前代码里光待支付、已支付、已发货、已完成、已取消就五个状态了且每个状态的事件行为差异很大用状态模式是实打实的刚需不是装饰。6. 和其他模式串起来看策略、状态与职责链的边界因为工作关系我经常被问到状态模式和策略模式是不是很像状态模式和职责链模式能混用吗。这话问得不无道理它们的类图确实有相似之处都是一个接口多个实现类上下文持有其中一个对象。但三个模式的内核完全不同理解这个差异对选型非常重要。策略模式的核心是算法替换同一个任务有不同的算法实现运行时用哪个算法由外部决定。排序算法、压缩算法、交易手续费计算规则都是典型策略模式策略之间是平级的不存在迁移关系。状态模式的核心是状态迁移对象的行为随状态改变而改变状态之间是有方向的、有约束的迁移关系。状态模式里上下文的行为是整个状态机运转的结果而策略模式里上下文的行为只是当前选的算法执行一次。职责链模式的核心是请求沿链传递一个请求被多个处理器依次尝试处理找到能处理的那个就终止。审批流就是典型的职责链请假申请从组长传到经理传到总监每人都有处理的机会。状态模式里的请求是发给当前状态的不会沿着一个链传播。实际工程里这三个模式经常搭配使用。比如订单状态机里每个状态处理支付事件时具体怎么扣款可以用策略模式选不同渠道算法发生退款争议时可以构造一条职责链依次尝试自动退款、人工介入、升级处理。理解这层关系你写出来的设计会更灵活而不是生搬硬套某一个模式。7. 状态模式在大型项目里的扩展玩法分层状态机和状态图工具前面聊的都是最经典的单层状态机但实际项目尤其是游戏和服务端里状态模式会被扩展出更高级的用法。这一节给想深入的人指个方向。7.1 分层状态机游戏角色的状态机里如果角色有死亡状态那不管它之前是攻击还是受击死亡时都要中断一切行为。如果每个状态类里都写死亡时怎么退出当前状态重复代码会非常多。分层状态机的思路是状态类可以有自己的父状态。比如攻击和受击都属于活跃状态死亡是活跃的一个兄弟状态。处理事件时如果当前子状态没处理就往父状态抛。这样死亡时中断一切行为的逻辑只需要在活跃这个父状态里写一次。这种设计在复杂游戏AI里几乎是标配实现时可以让State类持有一个指向父状态的指针事件处理函数先问子状态子状态不处理就转发给父状态。代价是复杂度上升调试时需要同时追踪子状态和父状态不是小项目该轻易上的方案。7.2 用状态图工具辅助验证状态类的代码写得好不好写完之后可以用状态图工具验证一遍。我经常用PlantUML这种纯文本工具画状态图因为它能直接当成代码维护画完以后贴到文档里也方便对比代码实现。虽然主流的Markdown渲染器不一定支持但画图这个过程本身就是重新审视状态迁移逻辑的过程很多不一致在画图时就会暴露。我重构订单状态机时画完图发现已支付待发货状态下漏了退款这个事件的迁移代码里自然也就忘了写。没有状态图辅助这种遗漏在上线前几乎不可能被发现。所以即使你不用正式的工具也建议维护一张状态迁移表这是性价比极高的习惯。我个人在实际项目里还有一个体会把状态类文件组织到一起统一命名对项目整体可读性很有帮助。比如全部状态类放在State/目录下类名统一以State结尾一眼扫过去就能看清整个状态机的完整图景。8. 从重构到重构什么时候该动手引入状态模式最后结合我自己的重构经历聊聊怎么把握引入状态模式的时机。没人应该为了写模式而写模式但代码里那些状态机味道的信号一旦出现就该考虑重构了。常见信号包括某个函数里连着一长串if...else判断状态迁移新增一个状态需要改动超过三个既有函数状态逻辑散落在多个业务函数里出现了重复的状态判断。这些信号只要命中两条以上就说明状态逻辑已经复杂到值得用状态模式管理了。重构的步骤也有讲究。先把状态枚举数和迁移表完整列出来和产品对齐然后定义抽象状态接口接着逐个实现具体状态类迁移关系严丝合缝地对应迁移表最后是替换原逻辑让上下文持有状态对象删除所有散落的if...else判断。这是个渐进的过程我建议从最简单的状态类开始实现每替换掉一个入口函数就编译运行一次确保改动是可持续交付的。重构不是一次性的动作。状态模式让代码结构的演进更加稳妥每次新增状态都只影响有限的范围比在if...else堆里翻改要安全得多。这也是我最终选择状态模式并且愿意把它推荐给所有人的原因——它不只是解决当下的混乱更是为未来持续演进留下了一条安全的路。
返回列表