State接口必须用指针接收器实现,否则字段修改会因结构体复制而丢失;转移逻辑须由上下文统一调用SafeTransition查表校验,不可硬编码或塞入HandleEvent。

State 接口必须用指针接收器实现,否则状态内字段(如重试计数、定时器)会复制丢失;转移逻辑不能塞进 HandleEvent 里直接赋值,而要由上下文统一调用并校验——这是 Go 状态机最容易崩的两个点。
为什么不能用值接收器实现 HandleEvent
状态对象常需维护临时数据:比如 RetryState 里的 attemptCount,或 TimeoutState 里启动的 time.Timer。若用值接收器:func (s IdleState) HandleEvent(e Event) State,每次调用都会复制整个结构体,导致计数器归零、定时器被丢弃。
必须改用指针接收器:func (s *IdleState) HandleEvent(e Event) State,才能让字段修改持续生效。
- 非导出字段(小写开头)是前提,避免外部绕过方法直接改字段
- 若某状态纯转发、无内部状态(如
PassThroughState),可用值接收器,但得在注释里写明 - IDE 和 vet 工具不会报错,但运行时行为诡异——测试时状态“看起来变了”,实际没存住
SafeTransition 必须查表,不能靠 if/else 或 switch
硬编码 if state == Pending && event == PayEvent { state = Paid } 在 3 个状态 + 4 类事件时还勉强可读,到 5×6 就开始漏路径、难覆盖、无法生成转移图。Go 没有编译期检查,全靠人肉维护。
立即学习“go语言免费学习笔记(深入)”;
正确做法是用两级 map:map[State]map[Event]State,初始化一次,查表即得目标状态。
- 转移表应设为包级变量或结构体私有字段,禁止外部修改
-
SafeTransition方法只做三件事:查表、更新f.state、返回 error(非法转移时) - 别在
SafeTransition里加日志或埋点——那是调用方的事,状态机只管“能不能转”
事件必须是具名 struct,不能用 string 或 int
用 "pay" 或 1 表示事件,会导致拼写错误难发现、无法携带参数、IDE 不补全、反序列化易错。类型系统在这儿不是摆设。
应该定义明确的事件类型:
type PayEvent struct {
UserID string
Amount float64
Timestamp time.Time
}
<p>type TimeoutEvent struct {
Reason string
}
- 不同事件类型无法误传,编译期就拦截
- 字段可选、可嵌套,后续加字段不影响旧逻辑
- 序列化(JSON/Protobuf)时字段名自动对齐,不用额外映射
耗时操作必须移出 HandleEvent,改用事件驱动
HandleEvent 运行在状态机 goroutine 中,一旦里面调用 http.Do、time.Sleep 或无超时的 chan 操作,整个状态机就卡死——这不是并发问题,是设计越界。
正确解法是:状态响应后启动 goroutine 执行任务,并在完成时发新事件回状态机。
- 允许:
go s.doPayment(e, sm.PostEvent),其中sm.PostEvent(&PaymentSucceeded{...}) - 禁止:
resp, _ := http.DefaultClient.Do(req)或time.Sleep(2 * time.Second) - goroutine 的生命周期必须和状态绑定:在
Exit()里 cancel context,防止泄漏
真正难的不是写出来,而是让每个状态的 HandleEvent 都不碰 I/O、不改共享内存、不依赖全局变量——这需要把业务副作用全部推给外部回调或事件,状态机只做“判断+转移”这一件事。稍一松懈,就退回 if/else 泥潭。


















