状态机核心是用map[State]map[Event]State转移表统一管理状态流转,禁止硬编码if-else、混入副作用、暴露内部字段,应封装为结构体并用SafeTransition校验非法转移,业务数据通过外部传参或回调处理。
状态机的核心是状态转移表,不是一堆 if-else
直接硬编码 if state == a { state = b } 看似简单,但一加新状态、新事件就容易漏掉组合,维护成本陡增。go 里最稳妥的做法是用 map 建立 map[state]map[event]state 转移表,把逻辑收口到一处。
常见错误是把事件处理和业务逻辑混在一起——比如在转移时直接调用 DB 写入或发 HTTP 请求。这会让状态机难以测试、无法复用。正确做法是只做状态变更,把副作用交给外部监听器或回调函数。
- 状态类型建议用自定义
type State string,避免字符串拼写错误 - 事件也应定义为
type Event string,不要用整数枚举(可读性差、易越界) - 转移表初始化放在包级变量或结构体初始化中,避免每次调用都重建 map
用结构体封装状态机,暴露 SafeTransition 方法
Go 没有类继承,但可以用结构体 + 方法模拟封装。关键是要防止非法转移:比如当前是 Idle 状态却收到 Cancel 事件。这时候不能静默失败,而应返回明确错误。
示例中 SafeTransition 方法会查表、更新内部 state 字段,并返回 error;而 CurrentState() 只读取,不修改。
type FSM struct {
state State
table map[State]map[Event]State
}
func (f *FSM) SafeTransition(e Event) error {
next, ok := f.table[f.state][e]
if !ok {
return fmt.Errorf("invalid transition: %s -> %s", f.state, e)
}
f.state = next
return nil
}
- 结构体字段
state必须是小写(state),否则外部可随意修改,破坏封装 - 转移表
table也应小写,通过构造函数初始化,禁止外部直接改 - 不要在
SafeTransition里做日志或埋点——那是调用方的事,不是状态机的职责
测试状态机必须覆盖所有合法/非法转移路径
一个 3 状态 + 4 事件的状态机,最多有 12 种合法转移组合。少测一条,上线后就可能卡死在某个状态。别依赖“看起来跑通了”,要用表格驱动测试穷举。
错误现象:测试只验证最终状态,没检查中间过程是否触发了不该触发的副作用(比如重复扣款)。这是因为测试没隔离业务逻辑。
- 用
testify/assert或原生testing都行,重点是每条测试用例包含:初始状态、输入事件、期望结果状态、期望错误 - 非法转移测试必须显式断言
err != nil,不能只看状态没变就认为 OK - 如果状态机带回调,测试时传入 mock 函数,验证它是否被调用且参数正确
别在状态机里存业务数据,用 Context 传参
状态机本身只管“我在哪、能去哪”,不该持有订单 ID、用户 token 这类上下文信息。否则每次状态变更都要同步更新这些字段,极易出错。
正确做法是让调用方在触发 SafeTransition 前,把必要数据存在结构体外,或通过闭包/函数参数传递给回调。Go 的函数值天然支持捕获变量,比塞进状态机干净得多。
- 例如:订单状态机不需要存
orderID,但回调函数可以闭包捕获它来查 DB - 如果必须透传多个参数,定义轻量
type Context map[string]interface{},但要克制——多数时候一个 ID 就够了 - 切忌在状态机结构体里加
UserID、CreatedAt这类字段,那已经不是状态机,是业务模型了
状态机最难的部分从来不是代码实现,而是厘清哪些逻辑属于“状态流转规则”,哪些属于“业务动作”。混淆这两者,再多的 map 和 switch 也救不了架构。


















