Preface
在軟體開發中,許多 Model 會有 state 這個屬性,state 除了說明 Model 的狀態以外,還有可能改變 Model 的行為,例如 訂單 就會有 待付款、已付款、送貨中、已取貨 這幾個 state,然而狀態的切換與管理並不是一個簡單的任務,例如你必須確保 待付款 的狀態不能直接切換到 已取貨,已及 送貨中 的狀態 訂單不能被執行 取消 這個行為,而 State Pattern 就是一個幫助 軟體工程師 設計狀態管理流程的一種設計方法,而 State Machine 則是透過 State Pattern 所實做出來的程式。
Table of Contents
- 為何需要 State Pattern
- State Pattern 的實做與優缺點
- 使用 Transition Table 實做 State Machine
- 使用 AASM 結合 State Machine 與 ActiveRecord
Contents
一、為何需要 State Pattern
使用情境 : 當物件本身有不同的狀態,且其內部狀態會改變物件的行為。
舉例:電燈本身有 暗 跟 亮 這兩個狀態,而 按開關 這個行為會依照其目前狀態產生不同的結果。
實做範例 (不使用 State Pattern):
class Light
def initialize
@state = 'off'
end
def press_btn
if @state == 'off'
print 'dark'
@state = 'on'
elsif @state == 'on'
print 'bright'
@state = 'on'
end
end
end
然而上述程式法的寫法有幾個缺點:
- 違反 開放封閉 原則:如果當 Light 這個類別需要新增新功能時,必須修改其 內部
press_btn
這個行為,這違反了 封閉原則 (i.e 進行擴展時,不須修改既有的程式碼)。
def press_btn
if @state == 'off'
print 'weak bright'
@state = 'weak'
elsif @state == 'weak'
print 'strong bright'
@state = 'strong'
elsif @state == 'strong'
print 'dark'
@state = 'off'
end
end
- 難以維護與閱讀:所有狀態管理的被封裝到
press_btn
這個函數中,這導致press_btn
函數會變得龐大,且 條件分支 變多時 會提高閱讀的難度。
二、State Pattern 的實做與優缺點
解決方法 (State Pattern):將物件中的狀態獨立成的物件(i.e State Object),並透過 Context Object 來切換物件的狀態。
實做範例 ( 使用 State Pattern ):
# 首先先設計好 Context Object 的結構
class LightContext
attr_reader :state def initialize)
@state = OffState.new(self)
end def press_btn
@state.press_btn
end def set_state(new_state)
@state = new_state.new(self)
end
end# 獨立 State Object 並設計其結構
class State
def initialize(light)
@light = light
end def press_btn
raise Error
end
def next_state
raise Error
end
end# 實做不同 State 的物件class OffState < State
def press_btn
print 'weak'
next_state
end def next_state
@light.set_state(WeakState)
end
endclass WeakState < State
def press_btn
print 'strong'
next_state
end def next_state
@light.set_state(StrongState)
end
endclass StrongState < State
def press_btn
print 'off'
next_state
end def next_state
@light.set_state(OffState)
end
end
State Pattern 帶來的好處:
- 符合 開放封閉原則:LightContext 這個類別在擴展新功能時,完全不需要修改其內部程式,只需要 建立一個新的 State 物件,並修改 State 物件之間串連的模式即可。
# 新增新的狀態class SuperStrongState < State
def press_btn
print 'super strong'
next_state
end def next_state
@light.set_state(OffState)
end
end# 修改狀態之間的關係class StrongState < State
...
def next_state
@light.set_state(SuperStrongState)
end
end
- 可維護性高:State 與 Context 分離後,開發者 若想新增 狀態只需要 增加 State 物件,不需要更改 Context 的程式,避免影響到其他 State ,此外擺脫了條件分支,提高程式的可讀性,也避免 Context 物件過於龐大。
然而 State Pattern 也有其缺點:
- 難以觀察 State 之間的關係:使用 State Pattern 時,會建立許多分離的 State Object,分散的 State 架構同時也代表 State 串連的邏輯分散,因此我們很難在一個單一的地方看出整個狀態轉換的邏輯。
為了改善這個缺點,我們可以使用 Transition Table 來實做 State Machine。
三、使用 Transition Table 實做 State Machine
何謂 State Machine:State Machine 顧名思義是管理狀態切換的機器,其管理狀態的邏輯是透過給予機器不同的 input 機器會依造目前的 state 進行轉移並產生對應的 output。
我們可以使用狀態圖來說明 State Machine 如何管理狀態的切換
當 Light 皆收到 press button 這個 input 時,state machine 就會依照此狀態圖以及目前的狀態發生改變,並在改變後產生對應的 output。
上述說明了 State Machine 的運作,而透過 State Pattern 即可實現 State Machine,然而真正要解決的問題是如何不違背 開放封閉 原則的情況下,讓 State 轉移的邏輯不被分離到各個 State Object。
此時就要提到 Transition Table:Transition Table 是將上述的狀態圖用 Table 的形式呈現。
使用 Transition Table 實做 State Machine,我們仍會將 State 從 Object 中獨立出來,只是會將 State 轉換成 Transition Table 並將 State 的轉換邏輯封裝在 Transition Table 物件中,此時轉換邏輯將不會分散到不同的 State Object。
實做過程:
# Transition Table 負責定義狀態切換的邏輯
class TransitionTable
class TransitionError < RuntimeError
def initialize(state, input)
super "No transition from state #{state.inspect} for input #{input.inspect}"
end
end def initialize(transitions)
@transitions = transitions
end def call(state, input)
@transition.fetch([state, input])
rescue KeyError
raise TransitionError.new(state, input)
end
end# State Machine 負責管理狀態的切換
class StateMachine
def initialize(transition_table, init_state)
@transition_table = TransitionTable.new(transition_table)
@state = init_state
end attr_reader :state
def send_input(input)
@state, output = @transition_table.call(@state ,input)
output
end
end# LightContext 負責實做狀態切換的 Output
class LightContext
STATE_TRANSITIONS = {
#state, input, next_state, output
[:off, :button_press] => [:on, :bright],
[:on, :button_press] => [:off, :dark]
} attr_reader :state_machine def initialize
@state_machine = StateMachine.new(STATE_TRANSITIONS, :off)
end
def state
state_machine.state
end def handle_event(event)
action = @state_machine.send_input(event)
send(action) unless action.nil?
end def bright
print 'bright'
end def dark
print 'dark'
end
end
使用 Transition Table 去驅動 State Machine 仍符合開放封閉原則,LightContext 可以透過修改 transitions 邏輯擴展功能,且不用修改 handle_event
函數,缺點在於需要新增對應的 output 函數在 LightContext 中可能會導致 Context Object 變得過於龐大。
四、使用 AASM 結合 State Machine 與 ActiveRecord
AASM 是一個有許多功能的 State Machine 同時也是一個 Gem ( i.e 套件 ),AASM 也是 Transition Table 驅動的 State Machine,他提供 DSL 去撰寫 Transition Rule,以及 callbacks 功能來管理 event 的 lifecycle。
範例( 定義 Transition Rules )
class Light
include AASM aasm do
state :off, initial: true
state :weak, :strong event :press_button do
transition from: :off, to: :weak
transition from: :weak, to: :strong
transition from: :strong, to: :off
end
end
end
上述範例所定義的 transition table 為
若要在每個 transitions 中加入對應的 output,可以使用 callback 功能
class Light
include AASM aasm do
state :off, initial: true
state :weak, :strong
event :press_button, after: :bright do
...
end
end def bright
put "#{assm.current_state}"
end
end
上述程式的 transition table 為
如果想知道更多 callbacks 的使用可以到這裡。
然而在實做中,狀態管理會跟資料庫中的資料有關,AASM 可以直接跟 ORM 結合透過 event 去修改 Table 中的 state field。
class Light < ActiveRecord::Base
include AASM aasm do # default column: aasm_state
state :off, initial: true
state :on event :button_press do
transition from: :off, to: :on
transition from: :on, to: :off
end
end
endLight.new.button_press! # save record
使用 AASM 在沒特別指定欄位的情況下,AASM 會預設欄位為 assm_state,若要指定欄位且欄位為 enum type 可以使用下列語法
class Light < ActiveRecord::Base
include AASM
enum state: [:off, :on]
aasm column: :state, enum: true do
...
end
end
AASM 除了上述的用法以外,還有許多強大的功能,例如 Scope 可以 query 不同 state 的 record,用 Namespace 定義不同的 transition rules 等等,如果想更了解 AASM 的實做可以到這裡。