接手一个不熟悉的系统时,有一件事我几乎每次都会做:找一个核心的业务对象——订单、工单、审批单,随便哪个——然后打开三四个文件,试图在脑子里画出一张状态图。
不是从文档里画。文档如果有,多半已经过时了。是从代码里画。读 OrderService.cancel(),看到 if order.Status != Paid。读 OrderService.refund(),看到 if order.Status == Paid || order.Status == Delivered。读 OrderService.complete(),发现它根本没判断状态——是忘了,还是故意的?翻 git blame,三行 if 是三个不同的人在不同季度写的,各自单独看都合理,但拼在一起,没人能说清 paid 状态的订单到底有多少种合法的变化路径。
这件事花了半个小时。画出来的图大概长这样:5 个状态,七八个箭头,两个问号——因为有两个边界情况从代码里推不出来,原来的作者离职了。
这半个小时本来不应该花。这张图应该已经在代码里了。
我想聊的就是这件事:为什么大部分代码里的状态转换规则没有一个明确的家,以及把它显式化之后会发生什么。
不是坏习惯,是好习惯过了保质期
说说为什么我们默认用 enum + if/else 管理状态。不是因为懒,也不是水平不够——是这种做法在起点上完全合理。
一个业务对象刚开始的时候,状态很少。订单:pending → paid → shipped。三个状态,两个操作。这时候在每个操作前面加一个 if 判断状态,是最直接的写法。你甚至不会觉得这是"状态管理"——它就是几行判断,是操作的前置条件,跟"参数不能为空"是同一个性质。
问题不在这里。
问题在于业务状态有一个很常见的规律:它更容易增加,而不是减少。 今天三个状态,半年后五个,一年后七个。每次加新状态,你会很自然地在新操作前面加新的 if。每次都完全合理。没有人会在 sprint planning 里说"我们要重构状态管理"——除非已经出过事了。
但十个各自合理的局部决策叠加起来,可以产生一个不合理的全局结果。这不是设计失误,是一种组织层面的惯性。如果状态转换没有一个明确的家,它就会住到每个人的代码里,每个版本略有不同。
我觉得关键不是批判 if/else,而是意识到它有一个你不注意就会安静跨过的阈值。阈值的这边,if/else 是最佳选择。阈值的那边,它开始产生一种特殊的技术债务:不是代码写得烂,是设计知识丢失了。
隐式状态机衰退的三个方式
当状态转换规则完全靠 if/else 表达,代码里其实存在一个状态机——但它不是写出来的,是推导出来的。每次要回答"X 状态能不能执行 Y 操作",需要的不是看一个地方,而是看完所有地方然后自己做 AND。
这种隐式状态机有三个衰退机制。我观察到的,不一定全对。
第一个:分散。
同一个规则被反复表达,每次表达都可能不一致。还是订单的例子:取消操作判断 if order.Status != Paid,退款操作判断 if order.Status == Paid || order.Status == Delivered。两个判断都在说"订单处于什么状态时能做什么",但它们用的是互补的逻辑——一个是排除法,一个是枚举法。
两个月后,有人加了一个 PartiallyShipped 状态。他更新了退款判断(加了一个 || order.Status == PartiallyShipped),但没更新取消判断——因为他根本不知道取消那边也有一个状态检查,写在另一个文件里。
这不是代码审查能发现的问题。除非 reviewer 恰好记得"取消那边也有个判断"——但六个月后没人记得。
第二个:组合盲区。
N 个状态 × M 个操作 = N×M 种可能的组合。比如 6 状态 × 7 操作 = 42 种。其中合法的可能只有十来种,剩下三十多种应该被拒绝。
if/else 模式不会告诉你这三十多种非法组合是否都被覆盖了。你怎么检查?把所有操作的判断逻辑串起来审计一遍?没人做过这件事。我也没有。
更麻烦的是,有人可能为了防御性编程,在某个操作里不小心拒绝了一个其实合法的组合——"先判断一下,稳一点"。防御性编程的动机是好的,但当判断散落在各处,你没法区分"这个限制是业务规则还是程序员自己加的"。
第三个:假设腐烂。
每个 if 背后都有一个隐性假设。if status == A || status == B 的假设是"只有 A 和 B 能执行此操作"。这个假设在写下来的当天是对的。
但假设没有保质期。当新状态 C 出现、新操作出现、业务规则调整,旧的 if 不会自己更新。代码不像文档——文档你可以说它过时了,代码一直在跑,看起来像是对的。
这就是这类技术债务阴险的地方:不是"代码坏了",是"代码还在跑,但它的假设已经不是真的了"。等你发现,通常不是通过审查,是通过一个生产 bug。
给转换规则一个家
前面说了很多问题,现在聊聊我觉得更干净的做法。
核心思想很简单:把状态转换规则当成一个独立的东西,而不是每个操作的附属品。
在代码层面,这件事不需要框架,不需要库,甚至不需要设计模式。一张表就够了。Go 里是一个 map[State]map[Event]State,Java 里是一个 Map<State, Map<Event, State>>,Python 里是一个嵌套 dict。选什么语言无所谓,重要的是这个结构本身:
type State string
type Event string
var transitions = map[State]map[Event]State{
"pending": {"pay": "paid", "cancel": "canceled"},
"paid": {"ship": "shipped", "refund": "refunding"},
"shipped": {"deliver": "delivered"},
"delivered": {"refund": "refunding"},
"refunding": {"complete_refund": "refunded"},
"canceled": {},
"refunded": {},
}
func (s State) Next(e Event) (State, error) {
if nextStates, ok := transitions[s]; ok {
if next, ok := nextStates[e]; ok {
return next, nil
}
return s, fmt.Errorf("illegal transition: %s -[%s]→ ???", s, e)
}
return s, fmt.Errorf("unknown state: %s", s)
}
不是想说这 30 行代码有什么神奇。它的价值不在代码本身——在于你脑子里的东西被外化了。
外化之前,状态转换规则是一个运行时的涌现行为:你跑一遍代码,看它做了什么,推断规则是什么。外化之后,它变成了一个静态的设计产物:你可以一行一行读,可以 review,可以拿给产品经理核对——"你看,当前业务规则下,pending 只能被 pay 和 cancel,对不对?"
这张表就是我前面说的"转换规则的家"。它不需要很大,不需要很复杂。但它有一个关键属性:没声明的就是不合法。 不需要在每个操作里写防御性 if,不需要靠 else 兜底。表是你对"什么是合法转换"这个问题的完整回答。回答之外,一概拒绝。
还有一个小事我觉得值得提:这张表本身是最好的业务文档。新人入职想知道"订单的生命周期是什么样的"——看表。半年后你自己回来改这段代码——看表。表不会撒谎,不会过时,因为代码就是在按它执行。文档和实现之间的 gap 消失了。
顺带捡到的几样东西
给转换规则一个集中的入口之后,有些之前很难做的事变得几乎免费。不是设计目标,属于顺带捡的。
状态变化日志。 谁在什么时间把什么从 A 变成了 B,原因是什么事件。在 if/else 模式下,你需要在每个操作里加日志,总有人忘。现在在一个地方包一层就全有了——而且事件本身就是"为什么",日志自动带上了业务语义。
副作用管理。 一个状态变化可能需要发通知、刷新缓存、更新搜索索引。散落在 handler 里的时候,这些副作用是"顺便做一下"——有时候做了,有时候没做,取决于你在哪个 handler 里。有了集中入口,副作用可以按转换路径注册,和业务逻辑解耦:
var sideEffects = map[struct{ From, To State }]func(*Order) error{
{"paid", "shipped"}: notifyCustomer,
{"shipped", "delivered"}: updateInventory,
}
幂等。 同一个事件发两次,"已支付"收到第二次"支付"——在转换表里,"paid"状态没有"pay"这个事件的出口,直接返回 error。不需要每个 handler 里写防重逻辑。
我个人的感受是:这些横切关注点之所以散落,根源就是没有一个统一的"状态变化发生地"。一旦有了,它们就有地方可待了。反过来想,你之所以在 if/else 模式下做不好这些事,不一定是你不够小心——是这个结构不支持你做好。
什么时候应该收住
工具好用的一个副作用是:你想到处用。但设计判断力的重要一部分,是有能力说"这里不需要"。
显式状态机值得做的场景,我自己的判断标准是这样:状态之间是否存在业务上的"非法转换"。 如果所有状态到所有状态都是合法的(比如用户的"个人简介"字段——什么值变什么值都可以),那状态机没有意义。如果有明确的业务规则说"A 不能直接变成 B",那这个规则应该活在代码里而不是注释里。
但这不算一个精确的标准。聊几个具体场景。
状态少又稳定,if/else 就够了。 比如一个 feature flag 的 on/off。两个状态,不会有第三个。加一张转换表属于过度设计——跟一个 bool 过不去没必要。
合法性依赖大量动态数据。 比如"用户能不能提现"取决于余额、KYC 状态、风控结果十几个条件。这时候合法性判断本身就是一个复杂的决策过程,塞进转换表反而不对。转换表擅长回答"什么状态变化在业务规则上是允许的",不擅长回答"在所有这些条件下是否允许"。后者更适合在 Next 外面单独做。
中间地带。 大部分实际场景其实在这个地带——不是绝对的简单,也不是绝对的复杂。我个人倾向于偏显式化一侧。原因比较朴素:业务状态只增不减,今天 3 个状态觉得 if/else 挺好,半年后涨到 5 个再过一年 8 个——迁移成本不是线性的。你迁移的不只是代码,是散落各处的隐性假设。有些假设你甚至不知道自己有,直到迁移时它们变成 bug。
但这只是我的偏好,不是规律。也许你们团队的代码组织方式不同,也许你们的业务状态天生稳定。我聊这些只是想提供一个思考角度,不是验收标准。
那张不在的图
回到开头接手代码的场景。
那半个小时花在反向工程状态图上。缺少的不是代码——代码充足得很,每个 handler 都有判断。缺少的是一个被承认的设计决策。
如果在系统还简单的时候,有人坐下来花十分钟,把"这个对象能怎么变"写成一张表,放在代码里——那些后来各自添加的 if,就会有一个可以对照的东西。新加一个操作?先看表,表里没有的路径就是讨论点:是该拒绝,还是该更新设计?
没有人能在写第一个 if 的时候就预见到六个月后这个对象会有七八个状态。但有人可以把"转换规则应该有一个家"这件事做成设计习惯,让后来的变化在受控的环境里发生。
代码写错了你的数据不一定脏——但如果核心业务状态机上有个洞,数据进入非法状态的概率会显著上升。
每一个散落着 if/else 的业务对象,都在维护一个隐式的状态机。那张表没有被写出来,不是因为它不重要——是因为它太明显了,明显到所有人都觉得"不用专门写,看看代码就懂了"。
然后看代码的人离职了。
一个简单状态机,比很多"企业级架构"对系统正确性的贡献更大。