在 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)是一個數學計算模型,其特徵如下:

滿足以上三個特徵的對象,我們都可以稱其爲有限狀態機。

對於 Door來說,其狀態只有兩種,分別爲openclosed;任意一個時刻,門只會處在openclosed中的一種狀態;如果門處於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 類,分別是 beforeleaveenter 以及 after,所以每兩個挨着的同色系輸出屬於同一類回調函數。

我們通過回調函數執行時機,將這 8 個回調函數分爲了 4 大類。如果站在狀態事件的角度,則可以分爲兩類,有些回調函數是在事件觸發時執行的,如 before_xxxafter_xxx,另外一些回調函數則是在狀態發生轉換時執行的,如 leave_xxxenter_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 中,歡迎點擊查看。

希望此文能對你有所啓發。

延伸閱讀

聯繫我

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/r8mqOUIoCu0713XcH3P1-Q