必须通过统一入口transitionto变更状态,禁用直接赋值;state字段非导出;转移规则用map[state]map[reflect.type]state定义;handle方法须用指针接收器;耗时操作须异步执行。

状态转移必须走统一入口 TransitionTo,禁用直接赋值
直接写 order.State = StatePaid 是最危险的起点。它绕过所有校验逻辑,导致并发下状态覆盖、日志丢失、钩子不触发——比如“已支付”状态该停掉超时任务,但硬赋值后这个动作永远不执行。
正确做法是只暴露一个方法:order.TransitionTo(event Event) error,所有状态变更都必须经过它。这个函数内部做三件事:查转移表验证合法性、调用当前状态的 CanHandle() 做业务前置检查(如用户是否实名)、最后调用 Handle() 执行副作用并返回新状态。
- 所有外部代码只能调用
TransitionTo,不能碰State字段 -
State字段必须是非导出的(小写开头),防止被误改 - 如果事件来自 HTTP handler 或 MQ 消费,统一在入口处封装成具名 struct 再传入,别用字符串或 map[string]interface{}
用 map[State]map[reflect.Type]State 定义转移规则
字符串状态 + 字符串事件的组合(如 map[string]map[string]string)类型不安全,拼错只能 runtime 报错。Go 的类型系统能帮你提前发现问题,就该用起来。
推荐结构:var transitions = map[State]map[reflect.Type]State{ StatePending: { reflect.TypeOf((*PayEvent)(nil)).Elem(): StatePaid, }, }。注册时用 reflect.TypeOf((*PayEvent)(nil)).Elem() 获取事件类型,运行时查表就能知道当前状态能否响应这个事件类型。
- 初始化时在
init()里遍历transitions,检查每个状态是否有至少一个合法出边,避免漏配 - 新增事件类型时,编译器会强制你为每个已有状态补上
Handle(PayEvent)方法,不会漏处理 - 不要把事件携带的数据塞进转移表——转移表只管“能不能转”,数据解析交给具体
Handle()方法
状态类型必须用指针接收器实现 Handle 方法
写成 func (s IdleState) Handle(e PayEvent) State 看似干净,实际会导致状态内字段(如重试计数 s.retryCount、定时器 s.timer)每次调用都被复制,上次变更完全丢失。
正确写法是 func (s *IdleState) Handle(e PayEvent) State。这样 s.retryCount++ 才真正生效,后续事件能感知到累积次数。
- 所有需要维护临时状态(如上传进度、连接句柄、重试上下文)的状态,必须用指针接收器
- 若某个状态纯转发、无内部状态,可用值接收器,但需在注释里明确说明
-
Handle()返回新状态实例(如&PaidState{}),而不是修改自身——保证不可变语义,方便测试和并发安全
耗时操作必须移出 TransitionTo,异步启动
TransitionTo 必须是纯内存操作:只改状态、记日志、发事件。一旦在里面调 http.Do、写 DB 或 time.Sleep,整个状态机就卡死——尤其在协议解析、设备控制等单 goroutine 场景下,后果严重。
正确姿势是:状态变更完成后,通过 channel 或回调通知外部协程去执行耗时动作。例如:go sendShippingRequest(order.ID),并在完成后再发一个 ShippingConfirmedEvent 回来触发下一步。
- 别在
Handle()里启动 goroutine 后就返回——要确保 goroutine 真正启动了,否则可能漏事件 - 如果需要重试逻辑,用元数据机制:
e.FSM.SetMetadata("retry_count", 2),别塞进状态字段 - 并发多路触发时,
TransitionTo内部只需对stateMu sync.RWMutex加写锁,其他字段(如订单金额、用户信息)保持无锁读取
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











