写在前面
第一次意识到自己应该把状态机当一等公民,是在维护一个老订单系统的时候。
那次上线前发现一个诡异 bug——某些"已退款"订单又被标记成了"已发货"。翻日志、对照库表,最后发现根因:退款流程是新同事加的,他写的
if 没考虑到上一版同事在 cancelled 之后还能进 shipped 这条历史路径——那条线本来该删,但因为另一个老同事"
暂时留着兼容"。
三个人,三个时间点,三套 if,一个谁也讲不清的状态图。
事后我画了张图——纸上一画就发现,系统里"代码允许的转换"和"业务能容忍的转换"根本不是同一张图。代码里允许的多得多,只是平时没人触发到。
那次之后我笃定一件事:业务的"状态/事件/转换"三件套必须集中声明在一处,业务代码只触发事件——剩下的让状态机自己拦。
这个想法在 Java 圈子里有现成答案——阿里开源 COLA 框架里的 cola-statemachine
组件。它把状态机做成了一段读起来像句子的 DSL:
1
2
3
4
5
6
| builder.externalTransition()
.from(STATE1)
.to(STATE2)
.on(EVENT1)
.when(checker)
.perform(action);
|
第一次读到这种 API 我就被电到了——状态机的语义和 API 形状是同一件事:from / to / on / when / perform 五个动词排成一行就是状态规约。
可是 Go 那边类似定位的库一翻——多数还停留在 map[string]map[string]string 拼装风格,且没有 COLA 那个最关键的设计:*
*无状态(stateless)**。我自己业务里要用,索性照着 COLA 的设计思路在 Go 里实现了一个,就是 go-fsm。仓库 README 第一行就写得明白:
“a lightweight, high-performance, stateless finite state machine implementation in
Go, inspired by Alibaba’s COLA state machine component.”
本文讲清楚它的设计来源、API 形态、以及"无状态"这件事到底意味着什么。
一、从一段 if-else 谈起
先看几乎每个 Go 业务都写过的反面教材:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| func Approve(order *Order) error {
if order.Status != "pending" {
return errors.New("only pending order can be approved")
}
order.Status = "approved"
return orderRepo.Save(order)
}
func Ship(order *Order) error {
if order.Status != "approved" {
return errors.New("only approved order can ship")
}
// ...
}
func Refund(order *Order) error {
if order.Status != "paid" && order.Status != "shipped" {
return errors.New("only paid or shipped order can refund")
}
// ...
}
|
刚写的时候是清爽的——每个函数只盯一两个状态。问题在于——
- 新增状态,要回溯所有现有
if,看哪些要加分支 - 改一条转换规则,要在好几个文件里改
- 状态多了之后,没人能完整答出"系统里到底有多少种合法转换"
把状态机当一等公民的价值就在这——状态、事件、转换规则集中声明,业务代码只触发事件。
二、思路从哪来:COLA 状态机
我得多说两句 COLA。
阿里的 COLA 是张建飞主导的"应用架构"开源项目,里面 cola-statemachine 是单文件、零依赖、流式
API 的 Java 状态机。它那套 from/to/on/when/perform 五个动词的 DSL 我前面贴过,读起来像规约本身。
但 COLA 真正惊到我的不是 DSL 形态——是它是无状态的。
传统状态机库(包括 Go 圈大多数)都是"实例持有当前状态"——m := NewMachine(initial); m.Trigger(event); m.State(),状态机内部
currentState 字段被 mutate。这种设计有几个隐患:
- 不可重入:同一个实例不能并发服务多个业务对象
- 跟业务对象强耦合:每个订单要单独实例化一台状态机
- 测试麻烦:状态机是有"记忆"的,每个测试都要重置
COLA 的做法相反——整个状态机就是一张『纯 lookup 表』。你定义一次,全应用共享一个实例。触发的时候是这样:
1
2
3
| // 不是 stateMachine.fireEvent(event)
// 而是 stateMachine.fireEvent(currentState, event, payload)
States nextState = stateMachine.fireEvent(STATE1, EVENT1, payload);
|
当前状态由调用方持有(业务对象自己),状态机只回答"在 STATE1 上遇到 EVENT1,下一个是什么状态"。这让状态机变成一个**纯函数
**——可重入、线程安全、可全局缓存、可单测。
go-fsm 把这个核心搬过来了。一台状态机用 Build() 出来之后,它代表的是"订单业务的所有合法转换图"——和具体某个订单实例无关。
三、最小可运行例子
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
| package main
import (
"fmt"
"github.com/lingcoder/go-fsm/fsm"
)
type OrderState string
type OrderEvent string
const (
StateCreated OrderState = "CREATED"
StatePaid OrderState = "PAID"
StateShipped OrderState = "SHIPPED"
StateDelivered OrderState = "DELIVERED"
StateCancelled OrderState = "CANCELLED"
EventPay OrderEvent = "PAY"
EventShip OrderEvent = "SHIP"
EventDeliver OrderEvent = "DELIVER"
EventCancel OrderEvent = "CANCEL"
)
type OrderPayload struct {
OrderID string
Amount float64
}
func main() {
builder := fsm.NewStateMachineBuilder[OrderState, OrderEvent, OrderPayload]()
builder.ExternalTransition().
From(StateCreated).To(StatePaid).
On(EventPay).
WhenFunc(func(p OrderPayload) bool { return p.Amount > 0 }).
PerformFunc(func(from, to OrderState, e OrderEvent, p OrderPayload) error {
fmt.Printf("[%s] %s pay → paid\n", p.OrderID, from)
return nil
})
builder.ExternalTransition().
From(StatePaid).To(StateShipped).
On(EventShip).
PerformFunc(func(from, to OrderState, e OrderEvent, p OrderPayload) error {
fmt.Printf("[%s] %s ship → shipped\n", p.OrderID, from)
return nil
})
builder.ExternalTransitions().
FromAmong(StateCreated, StatePaid).
To(StateCancelled).
On(EventCancel).
PerformFunc(func(from, to OrderState, e OrderEvent, p OrderPayload) error {
fmt.Printf("[%s] cancel from %s\n", p.OrderID, from)
return nil
})
sm, _ := builder.Build("orderMachine")
// 触发:当前状态由调用方传入
payload := OrderPayload{OrderID: "ORD-1", Amount: 100}
next, _ := sm.FireEvent(StateCreated, EventPay, payload)
fmt.Println("after pay:", next) // PAID
next, _ = sm.FireEvent(next, EventShip, payload)
fmt.Println("after ship:", next) // SHIPPED
}
|
读这段代码,三段 ExternalTransition...From...To...On...Perform 把整张状态图说清楚了。新人接手时不用先翻 if-else,光看
Builder 就能拼出业务允许的转换图。
类型层面,OrderState / OrderEvent / OrderPayload 三个泛型参数把整条链路锁住——把 event 误传到吃 state 的方法里*
编译期就过不了*。
四、四种 transition:表达"业务流转的所有形态"
只有一种 transition 不够——真实业务有几种典型形态:
ExternalTransition:标准跨状态转换(最常见)
1
2
3
| builder.ExternalTransition().
From(StateCreated).To(StatePaid).
On(EventPay)
|
A → B,状态变了。订单付款、审批通过、设备启动——绝大多数 transition 都属于这种。
ExternalTransitions:多源到一个目标
1
2
3
4
| builder.ExternalTransitions().
FromAmong(StateCreated, StatePaid, StateShipped).
To(StateCancelled).
On(EventCancel)
|
“创建中、已付款、已发货"三种状态都允许取消——一行 FromAmong 表达。比起写三条 ExternalTransition 重复
To(StateCancelled).On(EventCancel),这是 COLA 设计上的小巧思。
InternalTransition:状态内动作(状态不变,但要做事)
1
2
3
4
5
6
| builder.InternalTransition().
Within(StatePaid).
On(EventReminder).
PerformFunc(func (from, to OrderState, e OrderEvent, p OrderPayload) error {
return notifyService.SendReminder(p.OrderID)
})
|
订单"已付款"状态下定时发个提醒——状态本身没变,但有副作用要执行。这就是 internal transition——Within(state) 而不是
From().To()。
(对应 UML statechart 里的 internal transition 概念,COLA 也是这样区分的。)
ExternalParallelTransition:并行分裂
1
2
3
4
| builder.ExternalParallelTransition().
From(StateOrderCreated).
ToAmong(StateInventoryReserve, StatePaymentPending).
On(EventSubmit)
|
“订单提交"事件同时分裂出"占库存"和"等支付"两个并行状态——适合需要建模 fork 的场景(不是常见,但底层暴露了能力)。
五、Hooks:守卫和动作
每个 transition 上挂两个钩子,用法上一一对应 COLA 的 when / perform:
1
2
3
4
5
6
7
8
9
10
11
| builder.ExternalTransition().
From(StateCreated).To(StatePaid).
On(EventPay).
WhenFunc(func (p OrderPayload) bool {
// 守卫条件:返回 false → 转换被拒,状态不变
return p.Amount > 0 && p.Amount <= 100000
}).
PerformFunc(func (from, to OrderState, e OrderEvent, p OrderPayload) error {
// 实际动作:转换发生时执行;返回 error → FireEvent 返回 error
return paymentService.Confirm(p.OrderID)
})
|
WhenFunc 是守卫(guard)——条件不满足就连转换都不发生(FireEvent 返回原状态 + 一个表示"无合法转换"的 error)。*
它是用来表达"业务前置条件"的*:金额必须 > 0、用户必须实名、风控必须通过。
PerformFunc 是动作(action)——转换确定要发生时执行。返回 error 会冒到 FireEvent 的调用方,业务可以决定是否回滚。
如果动作里要更复杂的对象(不止 payload),还有 When(Condition[P]) / Perform(Action[S,E,P]) 接收完整 interface
实现的版本——基本和函数版等价,只是写法。
六、无状态带来的并发安全
COLA 状态机和 go-fsm 都强调"线程安全”——但它们的"线程安全"和传统状态机库的根本不一样。
传统库的"线程安全"靠加锁——内部 currentState 字段被读写锁保护,多个 goroutine 抢锁排队。一个状态机实例就是一个串行点。
go-fsm 的"线程安全"是结构性的——Build() 出来之后整个状态机就是只读的:
1
2
3
4
5
6
7
8
9
10
11
12
13
| sm, _ := builder.Build("orderMachine")
// 全局共享一个实例,业务对象自己持有状态
go handleOrder(sm, order1) // 服务订单 1
go handleOrder(sm, order2) // 服务订单 2
go handleOrder(sm, order3) // 服务订单 3
func handleOrder(sm fsm.StateMachine[...], o *Order) {
next, err := sm.FireEvent(o.Status, EventPay, payload)
if err != nil { return }
o.Status = next // 状态归业务对象所有
orderRepo.Save(o)
}
|
FireEvent 不 mutate 状态机本身——它只是查表 + 跑 hook + 返回新状态。整个调用是天然可重入的,甚至理论上可以拿来当全局单例(实践中通常按业务
module 一台)。
代价是业务方要自己保存当前状态——但这往往是它本来就要做的事(订单的 status 字段、设备的 state
字段都要落库)。换来的好处是:状态机本身不需要为每个业务实例克隆一份。
七、把订单系统串起来
回到开头那个让我入坑状态机的"老订单系统”。如果当时是这套 API,定义大概长这样:
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
| package order
import "github.com/lingcoder/go-fsm/fsm"
type State string
type Event string
const (
StateCreated State = "CREATED"
StatePaid State = "PAID"
StateShipped State = "SHIPPED"
StateDelivered State = "DELIVERED"
StateRefunded State = "REFUNDED"
StateCancelled State = "CANCELLED"
EventPay Event = "PAY"
EventShip Event = "SHIP"
EventDeliver Event = "DELIVER"
EventRefund Event = "REFUND"
EventCancel Event = "CANCEL"
)
type Payload struct {
OrderID string
Amount float64
UserID string
}
// 全局只构造一次
var Machine fsm.StateMachine[State, Event, Payload]
func init() {
b := fsm.NewStateMachineBuilder[State, Event, Payload]()
// 付款(大额走人工审核)
b.ExternalTransition().
From(StateCreated).To(StatePaid).
On(EventPay).
WhenFunc(func(p Payload) bool { return p.Amount <= 100000 }).
PerformFunc(func(from, to State, e Event, p Payload) error {
return paymentService.Confirm(p.OrderID)
})
// 发货
b.ExternalTransition().
From(StatePaid).To(StateShipped).
On(EventShip).
PerformFunc(func(from, to State, e Event, p Payload) error {
return logisticsService.CreateOrder(p.OrderID)
})
// 签收
b.ExternalTransition().
From(StateShipped).To(StateDelivered).
On(EventDeliver)
// 退款(已付款、已发货都可以发起)
b.ExternalTransitions().
FromAmong(StatePaid, StateShipped).To(StateRefunded).
On(EventRefund).
PerformFunc(func(from, to State, e Event, p Payload) error {
return refundService.Process(p.OrderID, p.Amount)
})
// 取消(只在已创建/已付款时允许)
b.ExternalTransitions().
FromAmong(StateCreated, StatePaid).To(StateCancelled).
On(EventCancel)
Machine, _ = b.Build("orderMachine")
}
|
业务侧的代码一行:
1
2
3
4
5
6
7
8
9
10
11
12
13
| func PayOrder(orderID string) error {
o, _ := orderRepo.Get(orderID)
next, err := order.Machine.FireEvent(o.State, order.EventPay, order.Payload{
OrderID: o.ID, Amount: o.Amount, UserID: o.UserID,
})
if err != nil {
return err // 自动返回"非法转换" / 守卫拒绝 / 动作失败
}
o.State = next
return orderRepo.Save(o)
}
|
业务代码不再写 if order.State != "..."。所有"哪个状态能做哪个事件"集中在 init() 里——这一处出错就所有出错,但这处一对就所有对。
回头看开头那个 bug:如果当时是这套定义,新同事加退款逻辑只能在 Builder 里加一行 FromAmong(...).To(StateRefunded)——加哪些状态进
FromAmong 一目了然,不会再有"哪条历史路径忘了删"的隐患。
八、可视化:纸上一画就清楚
开头那个故事我说过——“事后我画了张图,纸上一画就发现真正的转换关系和代码不是一张图。”
go-fsm 把"画图"这一步内置了。Build() 之后能直接生成 PlantUML / Markdown 等多种格式:
1
2
3
| diagram := sm.GenerateDiagram() // 默认 PlantUML
// 或者:sm.GenerateDiagram(fsm.FormatMarkdownFlowchart) 等
os.WriteFile("order_fsm.puml", []byte(diagram), 0644)
|
生成的图(这里用 mermaid 示意大概形状):
stateDiagram-v2
[*] --> Created
Created --> Paid: PAY
Paid --> Shipped: SHIP
Shipped --> Delivered: DELIVER
Paid --> Refunded: REFUND
Shipped --> Refunded: REFUND
Created --> Cancelled: CANCEL
Paid --> Cancelled: CANCEL状态机一旦超过 5 个状态,没图没人能 review。把图生成做进库里,是为了让"画图"不依赖个人自觉——make doc 就生成最新状态图,跟代码一起进
PR,reviewer 一眼能看出来这次改动新增/删了哪条边。
九、什么时候不该用状态机
工具好不代表事事都该用。下面这些场景上 FSM 是过度设计:
❌ 不适合:
- 状态简单(只有 2-3 种)——
if 就够,引入 FSM 是给后人挖坑 - 状态由外部任意写入——状态机限制反而碍事
- CRUD 型业务——表里的 status 字段就是个标签,跟"流转"无关
✅ 适合:
- 状态 ≥ 5 种
- 业务上不允许某些转换(这是状态机的核心价值)
- 多人协作维护同一段流转——FSM 是天然的"共同语言"
- 审计 / 监管要求"非法转换永远不发生"——状态机给你一个集中拦截点
简单一句:只有在"非法转换是 bug"的业务里,状态机才有意义。状态怎么转都行的业务,根本不需要状态机。
十、未来方向
go-fsm 还在 v0.x 阶段——开源后陆续收到一些反馈,正在演进的方向:
- Saga / 补偿语义——动作失败时按图反向执行
- 超时事件——状态停留超过 X 秒自动 fire 一个事件(订单 30 分钟未支付自动取消)
- 持久化适配——可选的 storage adapter,把状态变更钩子上 DB / outbox
- Hierarchical / Composite states——支持 UML statechart 的子状态机
- 可视化编辑器——基于已生成的 PlantUML 反向生成代码
issue / PR 都在 github.com/lingcoder/go-fsm 上。
十一、写完这个库之后的几点反思
写一个小库总能学到一些"做库"和"做应用"不一样的事:
- API 优先于实现——先把"用户最常写的那五行代码"在脑子里写出来好不好看,再想内部怎么实现。COLA 给我的最大启发就是这点
- 零依赖是给用户的承诺——“我这个库依赖一个工具包"听着无所谓,但用户一拉就连带几十个传递依赖,是开源库的原罪
- Stateless > Stateful——能做无状态就做无状态。可重入、可缓存、可单测、天然并发安全——一个设计四个好处
- 类型安全用 Go 泛型——
type State string 加常量是最低门槛,但泛型 Builder[S, E, P] 把整条链路从 Builder 到
FireEvent 都锁死——这是 Go 1.18+ 后才能做出的库 - Builder 模式 + Marker Interface——COLA 的 Java 实现里用接口隔离了"调用顺序”(必须 from → to → on → when →
perform),go-fsm 也是同样的招——这种"用类型系统强制 API 顺序"的招在写 Builder 时几乎是必备
小结
把全文压一句:
状态机的价值不在"切状态"——在于把"非法转换永远不发生"这件事固化进代码。go-fsm 把 COLA 的『无状态 + Fluent Builder +
守卫/动作』搬到了 Go,借 Go 泛型把类型安全做到了 COLA 也做不到的程度。
go-fsm 的核心几点:
- Stateless——状态机是只读 lookup 表,业务对象自己持有当前状态
- 泛型 [S, E, P]——三个类型参数从 Builder 到 FireEvent 一路守住
- 四种 transition——External / ExternalTransitions / Internal / Parallel,覆盖业务流转的常见形态
- Fluent Builder + 强制顺序——From → To → On → WhenFunc → PerformFunc,读起来像规约
- 零依赖——只依赖标准库
如果你写 Go 业务系统、为状态散落 if-else 痛苦过——值得试一下:
1
| go get github.com/lingcoder/go-fsm
|
期待你的 issue / star / PR。