在 Go 中如何使用有限狀態機優雅解決程序中狀態轉換問題
在編程中,有限狀態機(FSM)是管理複雜狀態流轉的優雅工具,其核心在於通過明確定義狀態、事件和轉換規則,將業務邏輯模塊化。本文將探討在 Go 中如何使用有限狀態機。
有限狀態機
在介紹有限狀態機之前,我們可以先來看一個示例程序:
https://github.com/jianghushinian/blog-go-example/blob/main/fsm/main.go
package main
import (
"fmt"
)
type State string
const (
ClosedState State = "closed"
OpenState State = "open"
)
type Event string
const (
OpenEvent Event = "open"
CloseEvent Event = "close"
)
type Door struct {
to string
state State
}
func NewDoor(to string) *Door {
return &Door{
to: to,
state: ClosedState,
}
}
func (d *Door) CurrentState() State {
return d.state
}
func (d *Door) HandleEvent(e Event) {
switch e {
case OpenEvent:
d.state = OpenState
case CloseEvent:
d.state = ClosedState
}
}
func main() {
door := NewDoor("heaven")
fmt.Println(door.CurrentState())
door.HandleEvent(OpenEvent)
fmt.Println(door.CurrentState())
door.HandleEvent(CloseEvent)
fmt.Println(door.CurrentState())
}
這個示例中,定義了一個核心結構體 Door
:
type Door struct {
to string
state State
}
Door
結構體表示這是一扇門,to
屬性表示這扇門通往哪裏,state
屬性標識這扇門當前處於哪種狀態。門只有開和關兩種狀態,分別對應 open
和 closed
。我們可以執行兩個動作(事件)開門和關門,分別對應 open
和 close
。
我們在 main
函數中使用 NewDoor("heaven")
構造了一個 door
對象,然後打印當前門所處的狀態。接着調用 door.HandleEvent(OpenEvent)
實現開門操作,並打印現在門所處的狀態。最後調用 door.HandleEvent(CloseEvent)
實現關門操作,並打印最終門所處的狀態。
執行示例代碼,得到輸出如下:
$ go run main.go
closed
open
closed
以上,我們就通過 Go 程序模擬了真實世界中的門。
那麼這跟有限狀態機有什麼關係呢?其實,門就是一種有限狀態機的模型。
維基百科中對有限狀態機的定義比較晦澀,在這裏,我以有限狀態機中最核心的三個特徵來爲你介紹到底什麼是有限狀態機。
有限狀態機(英語:finite-state machine,縮寫:FSM)是一個數學計算模型,其特徵如下:
-
• 狀態(state)個數是有限的。
-
• 任意一個時刻,只處於其中一種狀態。
-
• 某種條件下(觸發某種 event),會從一種狀態轉變(transition)爲另一種狀態。
滿足以上三個特徵的對象,我們都可以稱其爲有限狀態機。
對於 Door
來說,其狀態只有兩種,分別爲open
和closed
;任意一個時刻,門只會處在open
或closed
中的一種狀態;如果門處於closed
狀態,當觸發open
事件時,門就會從closed
狀態變爲open
狀態,反之亦然。所以Door
對象就是一個有限狀態機。
在我們的日常生活中,有限狀態機非常多,比如過馬路時的紅綠燈,只有三種顏色(狀態)紅、黃、綠;任意一個時刻,也只會處於一種顏色(狀態),其觸發條件是倒計時。
程序中也有很多常見的有限狀態機,比如電商的訂單,有已創建、已支付、已配送、已完成、已取消、已退款等有限的狀態枚舉;任意一個時刻,只處於其中一種狀態;觸發條件則是支付、申請退款等操作。
可以發現,有限狀態機中最重要的兩個概念就是狀態和事件。一個對象存在有限個狀態,並在某些事件發生時可以實現狀態轉換,這是一個非常常見的模型,我們在寫程序的過程中,可以將很多對象都抽象成有限狀態機。
既然有限狀態機的模型比較統一,我們是否可以專門抽象出來一個有限狀體機程序,來處理這些有限狀態機對象?
looplab/fsm 包就是幹這個事情的,這是一個有限狀態機的 Go 語言實現。接下來,我們來一起學習一下這個包的使用。
使用示例
安裝
可以通過如下命令來安裝 fsm
包:
$ go get github.com/looplab/fsm
簡單使用
我們可以用 fsm
包來重寫一下前文中介紹的 Door
對象實現:
https://github.com/jianghushinian/blog-go-example/blob/main/fsm/examples/simple.go
package main
import (
"context"
"fmt"
"github.com/looplab/fsm"
)
func main() {
fsm := fsm.NewFSM(
"closed",
fsm.Events{
{Name: "open", Src: []string{"closed"}, Dst: "open"},
{Name: "close", Src: []string{"open"}, Dst: "closed"},
},
fsm.Callbacks{},
)
fmt.Println(fsm.Current())
err := fsm.Event(context.Background(), "open")
if err != nil {
fmt.Println(err)
}
fmt.Println(fsm.Current())
err = fsm.Event(context.Background(), "close")
if err != nil {
fmt.Println(err)
}
fmt.Println(fsm.Current())
}
示例中,通過 fsm.NewFSM
函數可以構造一個有限狀態機對象fsm
,構造函數接收 3 個參數,第一個參數表示有限狀態機的當前狀態(或者叫初始狀態);第二個參數是一個fsm.Events{}
對象,它底層類型是一個slice
,即可以註冊多個事件,比如{Name: "open", Src: []string{"closed"}, Dst: "open"}
表示,當前狀態爲closed
的情況下,如果觸發open
事件,則狀態機的狀態將轉換成open
,注意,這裏面Name
對應的open
表示事件,Dst
對應的open
表示狀態;第三個參數是一個回調函數列表fsm.Callbacks{}
,暫時設爲空。
接下來,我們先用 fmt.Println(fsm.Current())
輸出 fsm
的當前狀態;接着,觸發 open
事件並輸出 fsm
的最新狀態;最後,觸發 close
事件,並輸出 fsm
的最終狀態。
執行示例代碼,得到輸出如下:
$ go run examples/simple.go
closed
open
closed
可以看到,我們使用 fsm
包,實現了 Door
狀態機。
對比之下,我們可以發現,fsm
包是有限狀態機的高度抽象。在使用 fsm
包時,我們無需像在使用 Door
時一樣,手動編寫一個 *Door.HandleEvent
方法來處理事件實現狀態轉換。而是可以直接在構造有限狀態機時,通過類似 {Name: "open", Src: []string{"closed"}, Dst: "open"}
的方式,來定義事件觸發時的狀態轉換規則。這樣,當調用 fsm.Event(ctx, "open")
觸發事件時,fsm
包就會根據預置的規則自動幫我們完成狀態轉換,將對象從原狀態(Src
)轉換成目標狀態(Dst
)。
這樣做的好處是,我們將狀態轉換規則進行了預置,在代碼邏輯中,我們只需關注何時該觸發某個事件即可,無需手動轉換狀態。這會大大減少複雜業務代碼中出現 Bug 的概率,並且也提升了代碼的可維護性。
在結構體中使用
此外,fsm
包還有另一個常見用法,它可以作爲結構體字段來使用。
示例如下:
https://github.com/jianghushinian/blog-go-example/blob/main/fsm/examples/struct/struct.go
package main
import (
"context"
"fmt"
"github.com/looplab/fsm"
)
type Door struct {
To string
FSM *fsm.FSM
}
func NewDoor(to string) *Door {
d := &Door{
To: to,
}
d.FSM = fsm.NewFSM(
"closed",
fsm.Events{
{Name: "open", Src: []string{"closed"}, Dst: "open"},
{Name: "close", Src: []string{"open"}, Dst: "closed"},
},
fsm.Callbacks{
"enter_state": func(_ context.Context, e *fsm.Event) { d.enterState(e) },
},
)
return d
}
func (d *Door) enterState(e *fsm.Event) {
fmt.Printf("The door to %s is %s\n", d.To, e.Dst)
}
func main() {
door := NewDoor("heaven")
err := door.FSM.Event(context.Background(), "open")
if err != nil {
fmt.Println(err)
}
err = door.FSM.Event(context.Background(), "close")
if err != nil {
fmt.Println(err)
}
}
此處,我們使用 Door
結構體重新實現了有限狀態機,將 FSM
對象作爲 Door
結構體的一個屬性,這樣,Door
結構體看起來更加符合業務。
並且,這裏我們還爲有限狀態機定義了一個回調函數:
fsm.Callbacks{
"enter_state": func(_ context.Context, e *fsm.Event) { d.enterState(e) },
},
enter_state
是事件觸發後的回調函數,定義了任意一個事件結束後觸發的函數,即當觸發 FSM.Event(ctx, event)
時會調用此函數。
執行示例代碼,得到輸出如下:
$ go run examples/struct/struct.go
The door to heaven is open
The door to heaven is closed
可以發現,無論是觸發 open
事件,還是觸發 close
事件,enter_state
定義的回調函數都會被調用。
事實上,fsm
包不止提供了這一個回調函數,它共計爲我們提供了 8 個回調函數。
完整回調函數使用示例如下:
https://github.com/jianghushinian/blog-go-example/blob/main/fsm/examples/struct/struct.go
package main
import (
"context"
"fmt"
"github.com/fatih/color"
"github.com/looplab/fsm"
)
type Door struct {
To string
FSM *fsm.FSM
}
func NewDoor(to string) *Door {
d := &Door{
To: to,
}
d.FSM = fsm.NewFSM(
"closed",
fsm.Events{
{Name: "open", Src: []string{"closed"}, Dst: "open"},
{Name: "close", Src: []string{"open"}, Dst: "closed"},
},
fsm.Callbacks{
// NOTE: closed => open
// 在 open 事件發生之前觸發(這裏的 open 是指代 open event)
"before_open": func(_ context.Context, e *fsm.Event) {
color.Magenta("| before open\t | %s | %s |", e.Src, e.Dst)
},
// 任一事件發生之前觸發
"before_event": func(_ context.Context, e *fsm.Event) {
color.HiMagenta("| before event\t | %s | %s |", e.Src, e.Dst)
},
// 在離開 closed 狀態時觸發
"leave_closed": func(_ context.Context, e *fsm.Event) {
color.Cyan("| leave closed\t | %s | %s |", e.Src, e.Dst)
},
// 離開任一狀態時觸發
"leave_state": func(_ context.Context, e *fsm.Event) {
color.HiCyan("| leave state\t | %s | %s |", e.Src, e.Dst)
},
// 在進入 open 狀態時觸發(這裏的 open 是指代 open state)
"enter_open": func(_ context.Context, e *fsm.Event) {
color.Green("| enter open\t | %s | %s |", e.Src, e.Dst)
},
// 進入任一狀態時觸發
"enter_state": func(_ context.Context, e *fsm.Event) {
color.HiGreen("| enter state\t | %s | %s |", e.Src, e.Dst)
},
// 在 open 事件發生之後觸發(這裏的 open 是指代 open event)
"after_open": func(_ context.Context, e *fsm.Event) {
color.Yellow("| after open\t | %s | %s |", e.Src, e.Dst)
},
// 任一事件結束後觸發
"after_event": func(_ context.Context, e *fsm.Event) {
color.HiYellow("| after event\t | %s | %s |", e.Src, e.Dst)
},
},
)
return d
}
func main() {
door := NewDoor("heaven")
color.White("--------- closed to open ---------")
color.White("| event\t\t | src\t | dst\t |")
color.White("----------------------------------")
err := door.FSM.Event(context.Background(), "open")
if err != nil {
fmt.Println(err)
}
color.White("----------------------------------")
}
執行示例代碼,得到輸出如下:
fsm
這是我們觸發 open
事件,將 Door
狀態機從 closed
狀態轉換成 open
狀態的完整生命週期回調函數執行記錄。
先不要覺得多,記不住,從而有牴觸情緒。我忙你依次來分析一下這些回調函數你就理解了。
首先,這些回調函數執行順序與定義順序無關,所以以上示例代碼無論如何調整回調函數定義順序,其執行結果仍是一樣的。
接着,其實你可以發現,我用不同顏色,區分了每一個回調函數的輸出結果。細心觀察,你還可以察覺到每兩個連續的回調函數的輸出顏色是用一個淺色和一個高亮色來區分的。雖然有 8 個回調函數,但其實可以分爲 4 類,分別是 before
、leave
、enter
以及 after
,所以每兩個挨着的同色系輸出屬於同一類回調函數。
-
•
before
表示在某個事件觸發之前執行的回調函數: -
•
before_open
表示在open
事件發生之前觸發。 -
•
before_event
表示任意一個事件發生之前觸發。 -
• 如果同時定義了
before_<EVENT>
和before_event
,則before_<EVENT>
先於before_event
觸發。 -
•
leave
表示在離開某一狀態時執行的回調函數: -
•
leave_closed
表示在離開closed
狀態時觸發。 -
•
leave_state
表示離開任意一個狀態時都會觸發。 -
• 如果同時定義了
leave_<OLD_STATE>
和leave_state
,則leave_<OLD_STATE>
先於leave_state
觸發。 -
•
enter
表示在進入某一狀態時執行的回調函數: -
•
enter_open
表示在進入open
狀態時觸發。 -
•
enter_state
表示進入任意一個狀態時都會觸發。 -
• 如果同時定義了
enter_<NEW_STATE>
和enter_state
,則enter_<NEW_STATE>
先於enter_state
觸發。 -
•
after
表示在某個事件觸發之後執行的回調函數: -
•
after_open
表示在open
事件發生之後觸發。 -
•
after_event
表示任意一個事件發生之後觸發。 -
• 如果同時定義了
after_<EVENT>
和after_event
,則after_<EVENT>
先於after_event
觸發。
我們通過回調函數執行時機,將這 8 個回調函數分爲了 4 大類。如果站在狀態和事件的角度,則可以分爲兩類,有些回調函數是在事件觸發時執行的,如 before_xxx
、after_xxx
,另外一些回調函數則是在狀態發生轉換時執行的,如 leave_xxx
、enter_xxx
。
這些回調函數,可以在事件觸發或狀態轉換的生命週期內,輔助我們實現一些特有的業務邏輯。
其實,fsm
還爲我們提供了兩種定義回調函數的簡寫形式,比如:
"closed": func(_ context.Context, e *fsm.Event) {
color.Green("| enter closed\t | %s | %s |", e.Src, e.Dst)
},
等價於:
"enter_closed": func(_ context.Context, e *fsm.Event) {
color.Green("| enter closed\t | %s | %s |", e.Src, e.Dst)
},
即 <NEW_STATE>
是 enter_<NEW_STATE>
的簡寫形式。
再比如:
"close": func(_ context.Context, e *fsm.Event) {
color.Yellow("| after close\t | %s | %s |", e.Src, e.Dst)
},
等價於:
"after_close": func(_ context.Context, e *fsm.Event) {
color.Yellow("| after close\t | %s | %s |", e.Src, e.Dst)
},
即 <EVENT>
是 after_<EVENT>
的簡寫形式。
如果我們定義一個不存在的事件 / 狀態,fsm
表現如何呢?
"unknown": func(_ context.Context, e *fsm.Event) {
color.Red("unknown event\t | %s | %s |", e.Src, e.Dst)
},
這個示例結果就交給你自行去探索了。
項目實戰
以上我向你介紹了有限狀態機的概念,以及在 Go 中如何利用 fsm
包實現有限狀態機。如果你看後還覺得不過癮,想了解一下在真實的企業級項目中,是如何使用有限狀態機的,那麼你可以參考 OneX 項目 nightwatch
組件的源碼(https://github.com/onexstack/onex/tree/feature/onex-v2/internal/nightwatch/watcher/user),來學習如何在項目中落地 fsm
。
總結
本文以一個示例開始,我向你介紹了什麼是有限狀態機。接着我向你推薦了 Go 中 fsm
包,並使用它實現了一個 Door
有限狀態機。通過對比,我們能夠發現,使用 fsm
來實現有限狀態機好處是,可以將狀態轉換規則提前預置,然後在代碼邏輯中,只需關注何時該觸發某個事件即可,無需手動轉換狀態。我認爲這也是 fsm
的優勢所在,定義好了狀態流轉規則,狀態轉換就不會出現未知異常,如果將狀態轉換的代碼寫在複雜的業務邏輯中,則很容易出現 Bug,尤其在代碼多次迭代過程中,很容易漏掉某些 case。使用 fsm
則可以有效避免這些問題。
對於 fsm
的更多使用示例,可以參考官方 examples 代碼:https://github.com/looplab/fsm/tree/main/examples 。
此外,挖一個坑,如果後續有時間,我將對 fsm
源碼進行深度剖析與解讀,敬請期待!
本文示例源碼我都放在了 GitHub 中,歡迎點擊查看。
希望此文能對你有所啓發。
延伸閱讀
-
• 有限狀態機定義:https://zh.wikipedia.org/wiki / 有限狀態機
-
• JavaScript 與有限狀態機:https://www.ruanyifeng.com/blog/2013/09/finite-state_machine_for_javascript.html
-
• OneX 有限狀態機:https://github.com/onexstack/onex/tree/feature/onex-v2/internal/nightwatch/watcher/user
-
• fsm GitHub 源碼:https://github.com/looplab/fsm
-
• fsm Documentation:https://pkg.go.dev/github.com/looplab/fsm@v1.0.3
-
• 本文 GitHub 示例代碼:https://github.com/jianghushinian/blog-go-example/tree/main/fsm
-
• 本文永久地址:https://jianghushinian.cn/2025/05/25/fsm/
聯繫我
-
• 公衆號:Go 編程世界
-
• 微信:jianghushinian
-
• 郵箱:jianghushinian007@outlook.com
-
• 博客:https://jianghushinian.cn
-
• GitHub:https://github.com/jianghushinian
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/r8mqOUIoCu0713XcH3P1-Q