写在前面
订单流转、审批流程、设备状态、协议握手、游戏角色——任何"有限状态 + 状态间转移规则"的业务,本质上都是状态机。
写过这种业务的人都知道:
1
2
3
4
5
6
7
8
| // 反例:散落的 if-else
func Approve(order *Order) error {
if order.Status != "pending" {
return errors.New("only pending order can be approved")
}
order.Status = "approved"
// ... 还有几十处类似的散落转换逻辑
}
|
状态多了之后——没人能讲清"系统里有多少种合法转换"。这就是把 FSM 当作一等公民的价值——状态、事件、转换规则集中声明,业务代码只触发事件。
我维护的 go-fsm 是一个轻量的 Go 状态机库——零依赖、类型安全、Builder 风格 API。本文介绍它的设计理念、核心 API、典型用法。
一、设计目标
写 go-fsm 之前调研过几个 Go 状态机库——多数有几个共同问题:
- 状态/事件用
string——拼写错了运行时才发现 - API 啰嗦——每个 transition 要好几行
- 依赖大——拉一个 100KB 库,依赖几十个第三方包
- 没有 hook——不能在转换前后做副作用
go-fsm 的设计目标:
- 类型安全:State 和 Event 是自定义类型,编译期就能查错
- Fluent Builder:定义状态机像写句子
- 零依赖:纯标准库
- Hooks:转换前/后/失败都能挂钩
- 并发安全:开箱即用
二、最小可运行例子
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
| package main
import (
"fmt"
"github.com/lingcoder/go-fsm"
)
type OrderState string
type OrderEvent string
const (
StatePending OrderState = "pending"
StateApproved OrderState = "approved"
StateRejected OrderState = "rejected"
StateShipped OrderState = "shipped"
EventApprove OrderEvent = "approve"
EventReject OrderEvent = "reject"
EventShip OrderEvent = "ship"
)
func main() {
m := fsm.New(StatePending).
On(EventApprove).From(StatePending).To(StateApproved).
On(EventReject).From(StatePending).To(StateRejected).
On(EventShip).From(StateApproved).To(StateShipped).
Build()
fmt.Println(m.State()) // pending
if err := m.Trigger(EventApprove); err != nil {
fmt.Println(err)
}
fmt.Println(m.State()) // approved
if err := m.Trigger(EventApprove); err != nil {
fmt.Println(err) // ErrNoTransition: no transition for event...
}
}
|
Builder 链式语法让状态机定义读起来像配置文档:
“EventApprove 事件,从 StatePending 到 StateApproved。”
三、Hooks:转换前后做副作用
实际业务中状态切换通常伴随副作用——记日志、发通知、写 DB:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| m := fsm.New(StatePending).
On(EventApprove).
From(StatePending).
To(StateApproved).
Before(func(ctx *fsm.Context) error {
// 转换前校验
if !canApprove(ctx.Data) {
return errors.New("not eligible")
}
return nil
}).
After(func(ctx *fsm.Context) error {
// 转换后副作用
sendApprovalNotification(ctx.Data)
return nil
}).
Build()
|
Before 返回错误 → 转换被中止;After 在状态已经切换后执行——通常用于通知/日志。
全局 Hook
不只针对某个 transition——整个 FSM 也能挂全局 Hook:
1
2
3
4
5
6
7
8
9
10
| m := fsm.New(StatePending).
On(EventApprove).From(StatePending).To(StateApproved).
OnAnyTransition(func(ctx *fsm.Context) error {
log.Printf("[%s] %s → %s", ctx.Event, ctx.From, ctx.To)
return nil
}).
OnError(func(ctx *fsm.Context, err error) {
log.Printf("transition failed: %v", err)
}).
Build()
|
全局 Hook 是处理审计日志、metrics、debug 的最佳位置。
四、并发安全
go-fsm 内部用 RWMutex 保护——多 goroutine 调用 Trigger / State 是安全的:
1
2
3
4
5
6
7
8
9
| var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
m.Trigger(EventApprove) // 并发安全
}()
}
wg.Wait()
|
只有一个 goroutine 的 Trigger 能成功(其他被拒绝因为状态已经变了)——这是状态机的天然语义。
五、典型用法:订单状态机
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
| type Order struct {
ID string
Amount float64
State OrderState
}
func NewOrderFSM(order *Order) *fsm.FSM {
return fsm.New(order.State).
On(EventPay).From(StatePending).To(StatePaid).
Before(func(ctx *fsm.Context) error {
if order.Amount > 100000 {
return errors.New("require manual review")
}
return nil
}).
After(func(ctx *fsm.Context) error {
return paymentService.Confirm(order.ID)
}).
On(EventShip).From(StatePaid).To(StateShipped).
After(func(ctx *fsm.Context) error {
return logisticsService.CreateOrder(order.ID)
}).
On(EventRefund).From(StatePaid).To(StateRefunded).
On(EventRefund).From(StateShipped).To(StateRefunded).
On(EventComplete).From(StateShipped).To(StateCompleted).
OnAnyTransition(func(ctx *fsm.Context) error {
order.State = ctx.To
return orderRepo.Save(order)
}).
Build()
}
|
业务里:
1
2
3
4
| m := NewOrderFSM(order)
if err := m.Trigger(EventPay); err != nil {
return err
}
|
业务代码完全不需要写 if-else 判断状态——状态机自己拦截非法转换。
六、可视化导出
go-fsm 支持导出 DOT 格式——直接喂给 Graphviz 生成状态图:
1
2
3
| dot := m.ToDOT()
ioutil.WriteFile("order_fsm.dot", []byte(dot), 0644)
// dot -Tpng order_fsm.dot -o order_fsm.png
|
生成的图:
flowchart LR
Pending --> Paid: pay
Paid --> Shipped: ship
Paid --> Refunded: refund
Shipped --> Refunded: refund
Shipped --> Completed: complete可视化是状态机维护的核心工具——状态多了之后没图没人能 review。
七、常见使用场景
1. 订单 / 审批流转
经典场景——已经在前文展示过。
2. 协议握手
TCP/TLS/WebSocket 握手过程都是有限状态机:
1
2
3
4
5
| fsm.New(StateInit).
On(EventClientHello).From(StateInit).To(StateServerHello).
On(EventServerHello).From(StateServerHello).To(StateExchange).
On(EventExchange).From(StateExchange).To(StateEstablished).
Build()
|
3. 设备状态
IoT 设备:online → offline → maintenance → online。
4. 游戏角色
角色状态:idle → walking → running → attacking → dying。
5. 工作流引擎
简单的 BPMN 流程可以用状态机表达——节点是状态,迁移条件是事件。
八、什么时候不该用状态机
虽然状态机是好工具,但不是所有"有状态业务"都该上:
❌ 不适合:
- 状态简单(只有 2-3 种)——
if 就够 - 状态间无清晰转换规则——状态机反而限制业务
- 状态由外部驱动且不需要校验——状态机是限制非法转换的工具,不需要限制就没意义
✅ 适合:
- 状态 ≥ 5 种
- 转换规则明确
- 要求"非法转换永远不发生"
- 多人协作开发同一业务
九、go-fsm 的几个未来方向
go-fsm 是 v0.x 阶段——开源后受到一些反馈,正在演进:
- 持久化状态:把状态机的当前状态自动同步到 DB
- 超时转换:状态停留超过 X 秒自动跳到下一个状态
- 嵌套状态机:复杂业务里子状态机
- 基于 generics 的强类型——Go 1.18+ 后能做出更类型安全的 API
- 图形化编辑器:基于 DOT 反向编辑状态机
社区贡献欢迎——github.com/lingcoder/go-fsm。
十、设计反思
写 go-fsm 时学到几个关于"如何设计 Go 库"的事:
- API 优先于实现——先想"用户怎么用最爽",再想内部
- 零依赖是真正的承诺——拉了一个状态机库就拖一堆传递依赖,是对用户不负责
- 类型安全是 Go 的礼物——别学其他语言用 string 做枚举
- Builder 模式让 API 像 DSL——读起来像句子的代码是好代码
- Hooks 优于继承——比起让用户继承基类,提供 callback 更灵活
小结
把 go-fsm 的价值压一句:
业务有 5+ 状态时,状态机不是『过度设计』——它是把『非法转换永远不发生』这件事固化进代码的最佳武器。
go-fsm 的核心三句话:
- 零依赖——纯标准库
- 类型安全——State / Event 是你的自定义类型
- Builder Fluent——读起来像配置
如果你写 Go 业务系统并且为状态散落 if-else 痛苦过——值得试一下 go-fsm。
1
| go get github.com/lingcoder/go-fsm
|
期待你的 issue / star / PR。