问题:订单流程里的 if-else 泥潭
几乎每个电商系统都有这样一段代码:
def handle_order(order, action):
if order.status == "created":
if action == "pay":
order.status = "paid"
elif action == "cancel":
order.status = "cancelled"
else:
raise InvalidAction()
elif order.status == "paid":
if action == "ship":
order.status = "shipped"
elif action == "refund":
order.status = "refunding"
else:
raise InvalidAction()
# ... 更多分支
每加一个状态或动作,就要在所有分支里补判断。测试时也只能一个个场景去构造,漏掉一条路径线上就出问题。这种写法的问题不是“不优雅”,而是状态和迁移规则散落在代码里,无法被单独验证。
状态机视角:把“规则”和“执行”分开
有限状态机(FSM)的核心只有三样东西:
- 状态(State):订单当前处于哪一步
- 事件(Event):外部触发的动作,如支付、发货、取消
- 迁移(Transition):在某个状态下收到某个事件,应该进入哪个新状态
关键转变是:把迁移规则写成一个数据表,而不是嵌套的条件分支。
TRANSITIONS = {
("created", "pay"): "paid",
("created", "cancel"): "cancelled",
("paid", "ship"): "shipped",
("paid", "refund"): "refunding",
("shipped", "confirm"): "completed",
("refunding", "approve"): "refunded",
}
这段表本身就是文档:看一眼就知道哪些操作合法、会走到哪里。
迁移表驱动的执行器
有了表,执行逻辑可以写得非常薄:
def apply(order, event):
key = (order.status, event)
if key not in TRANSITIONS:
raise InvalidTransition(order.status, event)
order.status = TRANSITIONS[key]
return order.status
如果还需要副作用(发消息、写日志),可以给迁移表加值:
TRANSITIONS = {
("created", "pay"): {"to": "paid", "on_enter": notify_paid},
# ...
}
执行器统一调用 on_enter,业务代码不再关心“从哪个状态来的”。
为什么这样更好测试
原来的 if-else 要写很多集成测试才能覆盖分支,现在可以分两层测:
- 规则层:直接断言迁移表。例如
assert TRANSITIONS[("paid", "ship")]["to"] == "shipped",以及非法组合确实不在表中。 - 执行层:用参数化测试遍历所有合法迁移,检查状态正确、副作用被调用;再遍历非法组合,检查抛出预期异常。
@pytest.mark.parametrize("state,event,expected", [
("created", "pay", "paid"),
("paid", "ship", "shipped"),
])
def test_legal_transitions(state, event, expected):
order = Order(status=state)
assert apply(order, event) == expected
新增状态时,只需在表里加一行,测试自动覆盖,不会漏掉某个分支。
落地时的几个注意点
- 状态和事件用常量或枚举,避免字符串拼写错误。
- 迁移表集中在一处,不要分散到多个文件,否则又回到“规则散落”的老问题。
- 副作用保持幂等:状态机只负责状态流转,重试或并发下要保证通知类操作不会重复产生业务影响。
- 并发控制:状态迁移通常要配合乐观锁(版本号)或数据库条件更新,避免两个请求同时从
paid迁到不同状态。 - 不必追求复杂框架:多数业务用一张字典加一个执行函数就够了,等规则复杂到需要分层状态机时再引入库。
小结
把订单流程从 if-else 改成迁移表,本质是把“隐式的控制流”变成“显式的数据”。规则可读、可枚举、可参数化测试,新增状态只需加一行,而不是在所有分支里补条件。对后端开发者来说,这是用很小的重构成本换取长期可维护性的典型做法。