天天看點

有限狀态機問題程式設計實踐

在日常開發工作中, 我們在模組化時會經常遇到實體在多個狀态間進行變遷的問題, 比如:

一個訂單的狀态可能是 “已下單” , “已支付”, “取消”, “完成” 等,并且在滿足一定的條件的情況下觸發特定動作會發生實體的狀态遷移。一般來說,實體的可能狀态是有限的, 對于這類問題,我們一般稱為fsm(finite state machine), 即有限狀态機。舉個以前項目的例子:

某種裝置通過gprs連接配接到控制中心, 并且通過接受控制中心的控制指令來改變自身的運作狀态,為了達到這個目的, 裝置的底層系統提供了控制裝置的api:

有限狀态機問題程式設計實踐

現在要求上層編寫裝置管理程式, 其狀态圖如下:

有限狀态機問題程式設計實踐

這是一個典型的狀态機實作的問題, 裝置擁有多個可能的狀态, 并且在特定狀态下接受正确的指令後可以遷移到指令的目标狀态,直覺的看,狀态機是一個有向圖,狀态為端點,操作為邊, 一個狀态端點在特定操作的驅動下遷移到另一個狀态端點。

我們用僞代碼來描述如下:

define state = shutdown; do function start(); set state = started; done;

同時,一個完整的狀态變遷圖告訴我們如下事實:

一個狀态端點可以由哪些狀态端點變遷而來

一個狀态端點可以變遷到哪些狀态端點而去

那麼針對這個問題我們可以快速的給出如下的一份實作:

//擷取接受到的指令類型 final eventtypeenum eventtype = event.eventtype(); //擷取接受到的指令内容 final byte[] cmd = event.getinstruction(); switch (eventtype) { //接收到裝置啟動指令 case boot: switch (currentdevicestatus) { //目前狀态為關閉狀态, 允許啟動 case status_poweroff: //擷取裝置底層api并調用 deviceruntime.getdevice().boot(cmd); currentdevicestatus = devicestatus.status_booted; return true; default: return false; } //接受到裝置關閉指令 case poweroff: //目前裝置狀态為已配置, 允許執行 case status_confiured: //目前裝置狀态為已啟動, 允許執行 case status_booted: //目前裝置狀态為已待機, 允許執行 case status_standby: //目前裝置狀态為在網待機,允許執行 case status_linked_standby: deviceruntime.getdevice().shutdown(cmd); devicestatus.status_poweroff; //接收到裝置激活指令 case active: //... case configure: case link: case offline: case standby:

我們用2層的switch來限制特定源狀态,特定操作,特定目标狀态, 如果不考慮狀态機的修改,這應該是一個不錯的實作,正确,簡潔,易讀. 但是在應付變化這方面就顯得比較無能為力, 原因在于,這個版本的實作缺少結構化的抽象,缺乏職責劃分導緻無法做到變化的隔離。

考慮一下, 如果我們新增加一個狀态, 按照目前的實作, 我們需要做的事情是這樣的:

了解新端點是那些邊的目标端點

在外層switch中為每條指向新端點的邊構造一個switch分支,在内層switch中為每個源端點建構一個狀态變遷實作

了解新增端點是那些原有端點的源

在外層switch中為每條指向原有端點的邊構造一個switch分支,在每個内層switch中為新增端點建立一個狀态變遷實作

很明顯,正确的完成這件事情并不容易,試想如果一個狀态機包含幾十個狀态,上百個動作,一次要新增或删除數個節點,改動和測試的複雜度将非常高。

下面我們來改進上面的實作, 前面講過, 一個狀态端點在特定操作的驅動下遷移到另一個狀态端點, 這是狀态機的唯一行為模式,是穩定的, 不穩定的是新增狀态時的前置限制,指令的新增以及狀态的變化, 是以從大體上我們需要把穩定的行為和不穩定的行為進行隔離和抽象。

首先, 我們再來對一次狀态變遷進行分析:

有限狀态機問題程式設計實踐

一次狀态遷移必然包含如下要素:

遷移的事件類型是什麼? 比如啟動, 激活, 關閉等

遷移的原狀态時什麼? 比如 啟動類型的遷移, 其原狀态必須是已關閉

遷移結束後的狀态是什麼? 比如 啟動類型的遷移, 其目标狀态時已啟動

遷移指令如何執行

如果我們從這個角度來進行抽象, 我們需要一個操作模型來封閉起始狀态和指令的執行,那麼我們首先來定義一個裝置的操作:

public interface deviceoperation { /** * 傳回此操作代表的類型 * @return 操作代表的類型 */ public eventtypeenum operationtype(); * 聲明裝置在什麼狀态下允許執行此操作 * @return 操作可執行的裝置狀态 public set<devicestatus> avaliablestatus(); * 聲明如果此指令操作成功, 裝置應該處于的下一個狀态 * @return 裝置的新狀态 public devicestatus nextstatus(); * 執行指令操作 * @param cmd 指令資料 public void dooperation(final byte[] cmd);

一個操作(邊)有它自己的類型, 有執行指令的實作,有目标端點和源端點。所有的要素都有了, 我們可以這樣描述一個具體實作

操作x -> "操作x的類型為 operationtype() , 在目前狀态為 avaliablestatus()之一時允許執行指令操作dooperation(#cmd#), 成功執行後裝置狀态更改為nextstatus()"

由于指令模型再執行指令時向上表現為一緻的行為方式, 而底層裝置為不同指令提供了不同的api, 是以在真正執行指令操作的地方, 我們也需要進行抽象和适配(下圖僅列出部分操作):

有限狀态機問題程式設計實踐

現在我們需要另外的一個模型來負責狀态變遷的過程控制, 它需要足夠的通用和穩定, 整個狀态機的運作模式應該是這樣:

//定義初始狀态為shutdown //定義狀态變遷的執行個體映射關系 define operations={啟動:啟動操作執行個體, 關閉:關閉操作執行個體...} //接收到了消息 while receive x->{type,cmd} //根據類型檢索消息 deviceoperation = operations.get(x.type) //強制狀态檢查 if deviceoperation.avaliablestatus() contains state; then //執行指令 deviceoperation.dooperation(x.instruction) //更新狀态 state=deviceoperation.nextstatus() endif done

在能夠按照我們想象的方式執行之前, 我們還需要一點靈活性, 将狀态機的狀态變遷圖映射到操作模型中來, 也就是回答一個給定遷移動作的原狀态和目标狀态分别是什麼以及如何執行的問題, 這一點可以通過配置的方式來完成, 比如:

<statemanagement> <operation> <eventtype>boot</eventtype> <sourcestatus> <value>poweroff</value> </sourcestatus> <targetstatus>started</targetstatus> <actionexecutor>a.b.bootexecutor</actionexecutor> </operation> <eventtype>shutdown</eventtype> <value>started</value> <value>linked</value> <value>standby</value> <value>linkedstandby</value> <targetstatus>poweroff</targetstatus> <actionexecutor>a.b.shutdownexecutor</actionexecutor> .... </statemanagement>

通過解析這樣的配置檔案, 我們可以很容易的将操作類型與操作執行個體映射起來:

public class configureddeviceoperation implements deviceoperation { /** 操作類型 */ private final eventtypeenum operationtype; /** 目标狀态 */ private final devicestatus nextstatus; /** 指令執行器 */ private final executor executor; /** 支援此操作的源狀态集合 */ private final set<devicestatus> avaliablestatus = new hashset<devicestatus>(); * 構造函數, 根據給定的配置服務對象構造一個裝置操作對象 * @param operationtype 操作類型 * @param nextstatus 下一個狀态 * @param avaliablestatus 可執行操作的目标狀态 * @param executor 執行器 public configureddeviceoperation(final eventtypeenum operationtype, final devicestatus nextstatus, final set<devicestatus> avaliablestatus, final executor executor) { this.operationtype = operationtype; this.nextstatus = nextstatus; this.executor = executor; this.avaliablestatus.addall(avaliablestatus); ...

現在整個狀态機每一條類型不同的邊對應了一條配置,我們把易變的部分從狀态遷移邏輯中抽離出來了,現在控制模型的代碼隻需要表達一個通用的執行流程,顯著的增加了結構的穩定性:

//從加載的配置中擷取裝置操作執行個體 final deviceoperation deviceoperation = config.get(eventtype); //判斷目前狀态是否可以執行操作 if(deviceoperation.avaliablestatus().contains(currentdevicestatus)) { deviceoperation.dooperation(cmd); currentdevicestatus = deviceoperation.nextstatus();

現在我們再回答要新增一個狀态端點要做什麼的問題:

在狀态圖中新增端點和邊

對新指令添加新的指令執行器。

在配置中寫出新增邊的描述,對于已存在的邊,在源狀态清單中添加新的狀态端點值。

歸納起來,我們需要新增指令執行器,增加和修改配置項。通過簡單的改動,較大程度的消除了代碼的更改,符合開閉原則,同時,對于配置項的修改,我們甚至可以更進一步,在背景中增加新的功能來輔助完成。從代碼的複雜度上來看,消除了大量的分支判斷,讓代碼更有層次,更加簡潔了,讀起來不再燒腦,從維護的角度看,減少代碼的修改也就減少了出錯的可能,從mock的角度看,我們抽象出了邊的概念,使用mockito或你所熟悉的任意mock方式來修改其行為都是非常友善的。

前面我們使用特定的java文法來實作了一個較為靈活的狀态機,引入了一段xml配置檔案來簡化我們的實作, 但是對于描述像狀态機這樣有着顯著領域特征的問題, 這種方式還是太程式化以及依賴程式設計技巧, 如果我們想要清晰的, 通用的表達我們所要解決的問題,或者想要提高開發效率,抽象構模組化型,抽取公共的代碼,減少重複的勞動,亦或想要靈活應對環境或平台的改變,脫離特定語言的捆綁, 那麼我們可以考慮使用dsl來解決問題:

<statemanagement initial="poweroff"> <events> <event id="deviceshutdown" type="shutdown" /> <event id="deviceboot" type="boot" /> <event id="deviceactive" type="active" /> <event id="devicestandby" type="standby" /> </events> <states> <state name="poweroff"> <transition event="deviceboot" target="started"/> <state/> <state name="started"> <transition event="deviceshutdown" target="poweroff"/> <transition event="devicestandby" target="standby"/> <state name="standby"> <transition event="deviceactive" target="started"/> <states/>

這裡是一段狀态機的外部dsl代碼, 本質上就是一段xml, 我們抽取了這類問題的慣有模式,用聲明的方式提供了事件類型, 狀态以及此狀态下可能的轉換行為。這種dsl描述的控制結構可以很容易的與各種特定程式設計語言進行綁定,甚至可以定制化的進行代碼生成。此外,從表述能力來看, 它明顯會好過java實作的版本, 一個團隊中的業務專家,開發,測試人員可以很容易的去閱讀和了解,降低溝通和了解的成本。

狀态機是我們日常工作中非常常見的一種問題場景, 在這篇文章中我們對一個簡單的例子進行分析并運用常見的工程手段來得出一個靈活的實作,文章中沒有去談論任何設計模式相關的東西, 而是着眼于更加本質的需求: 靈活, 簡潔, 符合開閉原則 去不斷的分析和改進, 最終獲得一個滿意的結果, 在此之外, 我們也嘗試着使用外部dsl來抽取了狀态機問題的慣有模式,差別于java語言版本的是, 這種方式更加注重通用化能力和資訊交流的友善程度, 提供了更加可視化的,便攜的解決方式。、

分享者:鄭淇公