舌尖上的狀態機

題記

真的猛士,勇於不作設計,直接開始編碼——面對業務系統中最複雜的部分:狀態模型,有多少程序員就有多少種實現。聊聊狀態模型設計上常遇到的問題和解決的思路吧。html

正文

有時候咱們想作一個富含業務行爲,而又足夠通用的技術架構時,剛開始都是信心滿滿,採用各類設計方法,充分考慮將來的需求,畫出系統依賴、數據模型甚至核心類圖,上線時各類性能爆表或者擴展輕鬆;上線半年以後畫風一轉,代碼堆獲得處都是,哪怕再當心維護依然沒法逃離「一年一重構」的魔咒,兩年事後連測試同窗的TC都寫不出來,發生了什麼?程序員

寫到這裏又到晚上了,不湊巧零食都被清理乾淨,餓得天昏地暗(⊙o⊙)…就設想一下這樣一個場景吧(下面的討論只作場景討論,並不是真實業務系統的設計,請專業的同行們不要見怪。數據庫

接到一個炒菜機器人的項目,要求可以按照吃貨的設計作出各類菜式數組

一分鐘速成方案

你們應該比較熟悉需求或者領域驅動的套路吧,抄起自上而下設計的錘子開始敲釘子:首先,咱們的平臺中會有架構

  • 廚具:各類廚具的基本使用接口和參數規範函數

  • 菜譜:操做指導工具

先在腦海中預演一下這樣的設計是怎麼運做的:性能

首先系統應該可以認識各類不一樣的廚具,而且知道如何操做它們
案板:切菜程序
炒鍋:翻炒程序、油炸程序
燉鍋:水煮程序、焯水程序
攪拌機:攪拌程序
……
以及它們的清洗程序沒有一一列出測試

接下來是菜譜,就先來個番茄炒蛋吧網站

  1. 打蛋流程:使用攪拌機,調整參數使其可以打出均勻的蛋液

  2. 番茄流程:使用案板,調整參數使其可以切出合適的番茄塊

  3. 炒鍋流程:使用炒鍋,先放油,燒熱,放蛋,翻炒,放番茄,翻炒,加鹽

  4. 出鍋流程:使用盤子,出鍋

圖片描述

畢竟咱們花了1分鐘設計出來的廚具+菜譜架構,看看再作幾個需求會變成什麼樣子,需求方要求在能作番茄炒蛋的基礎上,作個辣椒絲炒蛋:

  1. 打蛋流程:使用攪拌機,調整參數使其可以打出均勻的蛋液

  2. 辣椒流程:使用案板,調整參數使其可以切出合適的辣椒絲

  3. 炒鍋流程:使用炒鍋,先放油,燒熱,放蛋,翻炒,放辣椒,翻炒,加鹽

  4. 出鍋流程:使用盤子,出鍋

圖片描述

作到這裏,一些同窗指出,多數雞蛋搭配的菜譜都是擁有四個標準流程節點:打蛋、切菜、翻炒、裝盤,而在切菜環節中,咱們只須要調整參數類型和數值,就能夠搭配出「*炒雞蛋」的菜色,至於翻炒環節相對麻煩一些,須要加入不少細粒度的操做才能作出適用於業務發展的擴展性來;接下來的工做重點,要放在翻炒流程的設計上,開放出儘量多的SPI,讓第三方在咱們這個平臺上共同實現翻炒市場。

系統上線半年,出現了各類業務分支:不只原先官方提供的的番茄炒蛋和辣椒炒蛋得到很好的市場反饋,微調參數就輕鬆支持了苦瓜炒蛋、木耳炒蛋甚至榴蓮炒蛋;業務方出現了:咱們要開闢湯類市場,先從番茄蛋湯開始吧:

  1. 打蛋流程:使用攪拌機,調整參數使其可以打出均勻的蛋液

  2. 切菜流程:使用案板,調整參數切出番茄丁和香蔥段

  3. 湯鍋流程:使用湯鍋,加水,燒熱,放蛋,加熱,放番茄,放蔥段,加鹽

  4. 出鍋流程:使用湯碗,出鍋

針對原來設計的四套流程,在切菜流程中加入了相似炒鍋流程的多操做支持,接下來又實現了一套全新的湯鍋流程,出鍋也作了些定製。

腦補一下接下來的紅燒肘子、魚香肉絲、清燉羊肉、回鍋肉該怎麼實現吧(唉,快餓死了,話說好多程序員作飯都是好手,是真的吧?)

圖片描述
圖片描述
圖片描述
圖片描述

回過頭看看以前的實現,核心流程節點不必定只有4個,每一個流程節點下面的子節點可能有多個,若是如今要針對業務方提出的這些葷菜作個重構,該怎麼作?

將菜譜系統作成一個多維數組,就像這個樣子:
菜譜ID[子流程ID],而後分別實現這些流程節點並將它們存儲在這個菜譜表格中

看起來應該比較完善了吧,可程序員的第六感仍是隱隱約約以爲有哪裏不對勁,譬如,在番茄炒蛋和辣椒絲炒蛋這兩個大致相同的流程中,「翻炒」、「加鹽」這兩個節點真的是可複用的嗎?

沒錯,現實架構中每每沒這麼簡單,由於

番茄炒蛋的湯多,鹽能夠在最後加,也能夠在打蛋的時候加,而辣椒炒蛋沒什麼湯,鹽要在打蛋的時候加進去

好吧,在不影響流程系統的狀況下,咱們硬着頭皮在打蛋和炒鍋流程節點上加了個IF判斷(is 番茄炒蛋),而後就……中招了。

圖片描述

從邏輯來說,這一個小小的IF,將咱們原先設計的三維數組變了個味,把流程圖畫出來多是這樣的:

圖片描述

IF...ELSE分支就像小說裏邊的二向箔,看起來像是沒有改變原有系統的邏輯,但但是可可是它但是混雜在源碼中而不是存在於配置中的邏輯,慢慢的,這種隨意的維護和簡單實現,開始模糊系統中的主子流程的邊界,接着模糊菜譜和主流程的邊界,將一個有層次的設計一步一步的煮成一鍋皮蛋瘦肉東北亂燉粥。寫代碼的時候很爽,作維護的時候罵娘

這是一個小小的開始,咱們能夠抱着取捨的心態說:咱們能夠經過編碼規範的方式要求開發人員在涉及主流程節點的邏輯上不容許使用IF分支來保護架構,只有細枝末節的流程可使用不規範的編碼方式。

可現實每每沒有那麼簡單,流程節點之間也不是徹底沒有上下文依賴的狀況,絕大多數採用狀態機架構的系統是不會用多維數組劃分狀態的(不信你能夠去review代碼),經過邏輯分支搭建的橋樑,整個系統變成一個巨大的狀態機,那麼一個高維的狀態機系統投影到單維的系統中會發生什麼?(不少視頻網站上有個很好的教學系列《Dimensions》,有一部份內容關於如何經過球極投影理解四維空間)狀態機爆炸了,囧

這種實現的問題還不止於此,因爲細粒度的流程是創建在廚具的維度上,而每種廚具對付不一樣的食材時,仍是須要作不少定製化的工做,譬如:

  • 打蛋器/打蛋碗是否可以加鹽

  • 鍋裏倒油/倒水/倒醬油

  • 菜板切圈/切片/切絲

就拿菜板舉例,胡蘿蔔切片的手法,和包心菜切片的手法必然是不一樣的;習慣上對待這種問題的解決手段一般有兩種,一種是把切片的代碼放在菜板上實現,另外一種是將複雜性下沉到各類食材上分別實現

若是在菜板上實現,咱們就將得到一個可以加工天下食材的「超級菜板」,要麼是個上帝類,要麼是個錯綜複雜的巨型Service;

若是在食材上實現,爲了能讓菜板接受各類不一樣類型食材做爲輸入參數,咱們極可能會在各類食材的上層抽象一個BaseEdible的基類好傳遞參數,而後要麼在菜板上作switch邏輯,要麼在BaseEdible中提供各類加工方法的實現,譬如切片/切絲,但粉條或者大米怎麼切片?它和胡蘿蔔除了都能吃之外還有什麼共性?

也許有細心的同窗開始考慮在食材上用Command方式來實現行爲,這可能也是一種很糾結的作法,Debug成本暫且不說,一個幾乎能夠發送全部命令的菜板加上一堆看起來什麼命令都能接受的食材實現,怎麼保證系統不會讓菜板去把麪粉切個絲,也要作很多工做。

一個看上去很美好的設計,在實施的過程當中極可能成爲下面三者兼備的糟糕實現

  • 狀態機爆炸

  • 上帝類

  • 過分繼承/無用代碼

這時候,比程序員先瘋掉的,大概是聽到程序員說「我作了個小改動,大家迴歸一下」這句話的測試同窗吧?

圖片描述

換個姿式

怎樣才能讓這個系統像親愛的母上大人同樣,什麼菜都會作呢?

回顧前面的設計,鍋碗瓢盆做爲容器,它們自己其實沒有發生過任何變化,只是在盛有不一樣的食材時,樣子看起來有些不一樣。按照加工的流程設計狀態機踩了坑,按照容器狀態設計狀態機子節點也踩了坑,那麼咱們是否是一開始的出發點就跑偏了?

咱們不妨換個角度來思考,《舌尖上的中國》教育咱們:食材很重要,那麼是否是能夠從食材入手來設計這個系統?

剛纔的設計都是以廚師的工做狀態爲出發點作的:咱們手上有各類工具,能夠經過各類手段來加工食材。但考慮「作菜」這件事情自己,輸入的是食材,輸出的也是食材,真正發生狀態轉換的元素是工具嗎?顯然不是,雞蛋到蛋液到炒蛋這個過程當中,鍋碗瓢盆沒有任何變化,從食材的角度入手,也是一個好玩的嘗試。

雞蛋蛋有不少加工方法,煮蛋,煎蛋,荷包蛋,蛋液
辣椒也有不少加工方法,辣椒絲,辣椒圈,辣椒片

根據每一種食材進行抽象,會有一個很好的附帶效果:每一個狀態之間的變遷不可逆並且轉移條件沒有多態行爲

  • 碗 -> 洗乾淨 -> 乾淨的碗

  • 雞蛋 -> 敲開,放入乾淨的碗並用筷子攪動 -> 蛋液

  • 雞蛋 -> 敲開,放入乾淨的碗並用筷子攪動,加鹽,繼續攪動 -> 鹹蛋液

  • 鍋 -> 洗乾淨 -> 乾淨的鍋

  • 乾淨的鍋 -> 放油,加熱 -> 熱油鍋

  • 蛋液 -> 放入熱油鍋,翻炒 -> 炒雞蛋

  • 番茄 -> 切碎 -> 碎番茄

  • 辣椒 -> 切絲 -> 辣椒絲

  • 辣椒 -> 切圈 -> 辣椒圈

荷包蛋很難變回生雞蛋的樣子,對吧?話說還真有人能作到,不過即便能變回來也不影響總體的設計

這樣就有了材料的狀態機和轉移函數,以前的架構中,加工工具變成了Services或者Utils,鍋具仍然維持細粒度狀態機,但再也不是接受各類食材的上帝組件,咱們能夠在這樣細粒度的狀態機模型上進行很細緻的加工

在不一樣菜色的加工過程當中,這些細粒度的狀態機節點和轉移函數其實都是能夠完整複用的,列一個番茄炒蛋的上游流程圖,是否是變得清晰一些?

剩下的部分就不劇透了,設計的樂趣不就在這裏嗎?真的餓到全身無力扯不動蛋啦!有機會你們直接討論下,由於相似的問題在交易、物流、工單系統中都有大量的實例,偶爾換個思路,收穫沒準不小滴。

炒蛋·交易核心實戰

在進入這個章節以前,咱們再回顧一下,你們是否已經經過腦補,消滅了炒蛋系統中的那些問題設計?

  • 上帝類

  • 過分繼承

  • 非原子的狀態機轉移函數

  • 狀態機爆炸

若是大致上沒什麼問題的話,我們繼續向交易核心系統的設計上折騰起來!

圖片描述

發現了嗎,炒蛋和交易核心系統的架構也有許多類似之處,複雜業務系統編碼中,最困難的工做是:知道在什麼狀況下作什麼事;咱們從炒蛋的思路入手,以兩個交易場景看看能不能跑通

按照前文的套路,交易系統的設計一樣也能夠例舉出兩種典型的設計路徑:
一、按照資金流、信息流、物流等人們直接感知到的交易元素,自上而下的設計
二、按照商品、資金、優惠券、紅包等交易物料,自下而上的設計

不少同窗對於前者的思路應該比較熟悉了,咱們在這裏重點看一看怎樣之後者的路徑進行交易核心的設計,是否可以找到一種能夠適用於更多業務場景、健壯的交易架構

首先須要明確,交易的本質是什麼:多個參與者按照約定,進行財物的轉移或在參與者之間發生服務行爲

接下來就是如何在系統中體現這些轉移或者服務行爲了,會計記帳法,在資產覈算、資金審計等領域都有普遍的應用,在咱們的交易系統設計中,參照會計手段,對每一個交易元素的狀態進行建模(主要體如今數據庫Schema上,本文篇幅所限就不展開介紹會計記帳在訂單存儲上的應用了,之後有機會再敘)

單獨抽出狀態機來看,用會計術語描述,能夠把財物轉移或服務行爲抽象成一個簡單的流程

  1. [A]簽定

  2. [D]借方已履行

  3. [C]貸方已確認

對於任何一種交易元素,不管是否採用了擔保交易或第三方介入服務的狀況(如對於商品而言,從賣家發貨,通過快遞,到達買家的過程當中:發貨及物流進行中的狀態爲D,買家確認收貨爲C)固然,徹底能夠引入更多複雜的細粒度狀態機。爲了敘事簡便,在後面的表格中,咱們都用A、D、C三種狀態來描述交易元素的轉移狀態。

在一個典型的一口價交易流程中,交易流程以下(默認所有都是擔保交易)

  1. 下單

  2. 買家已付款

  3. 賣家已發貨

  4. 買家確認收貨

  5. 交易成功

在最樸素的一口價交易流程中,就是買家賣家一手交錢一手交貨的過程,交易元素只有兩個

  1. 資金

  2. 商品

那麼,在交易過程當中,這兩種交易元素的狀態是如何變動的?

類型 下單 付款 發貨 收貨 成功
資金 A D D D C
商品 A A D C C

表格中,不難看出交易的各個環節中須要進行的操做

  1. 下單(錢A->D,引導買家付款)

  2. 買家已付款(貨A->D,引導賣家發貨)

  3. 賣家已發貨(貨D->C,引導買家確認收貨)

  4. 買家確認收貨(錢D->C,系統打款給賣家)

  5. 交易成功

就這樣,從最細粒度的狀態機入手,咱們得到了一個可以直接明確表示每一個State和Transition的設計原型

下面咱們再找一個更復雜一點的場景入手

某烘焙供應商接入在線交易,由其分銷商以代理的方式引導用戶選購,用戶在分銷商頁面在線選購商品後,付定金,均分給分銷商及供應商,等店鋪備貨完成以後,通知買家付尾款給供應商,而後供應商發貨,買家收貨,完成交易

首先列出交易流程列表

  1. 下單

  2. 買家付定金,供應商與分銷商均分

  3. 供應商完成備貨

  4. 買家付尾款給供應商

  5. 供應商發貨

  6. 買家確認收貨

  7. 交易成功

再列出交易元素

  1. 分銷商佣金

  2. 供應商定金

  3. 供應商尾款

  4. 供應商備貨

  5. 供應商商品

列出交易流程狀態表格

類型 下單 定金 備貨 尾款 發貨 收貨 成功
佣金 A D D D D D C
定金 A D D D D D C
備貨 A A C C C C C
尾款 A A A D D D C
商品 A A A A A D C

固然也能夠簡化一下

類型 下單 定金 備貨 尾款 發貨 收貨 成功
佣金 A D - - - - C
定金 A D - - - - C
備貨 A - DC - - - -
尾款 A - - D - - C
商品 A - - - - D C

若是須要支持使用優惠券的交易呢?再加一行就行

類型 下單 定金 備貨 尾款 發貨 收貨 成功
券(付款減) A D - - - - C
券(下單減) D - - - - - C

用這樣的細粒度表格,很輕鬆就能夠得到每一個State(下單、付定金),以及State之間的Transitions,表格中,不一樣的列表示交易環節,而不一樣的行,則表示不一樣的交易元素

至於逆向流程的支持,其實也很簡單,由於表格中已經清晰的描述了每種交易元素的狀態,將交易元素的發起人和接收人互換,進一步區分交易元素

須要區分的交易元素主要有

  • 平臺中轉或擔保類元素(如現金、紅包、優惠券)

  • 不可退換元素(優惠券、充值卡)

  • 實物(一般所說)

  • 服務

按照交易元素的逆向特徵來設計對應的逆向元素生成策略,就能夠不用考慮太多細節,簡便的支持逆向流程

至此,咱們得到了一個經過細粒度狀態機表示的交易核心模塊,你們也能夠再用其餘的交易場景試着套用一下,看看有沒有比較好或者不適合的場景

在線上應用的設計上,還要進一步考慮一些其餘的工程因素

  • 如何進行狀態機編碼

  • 如何藉助TCC實現最終事務一致性

  • 合約化的交易數據庫Schema設計

這些內容咱們在之後的篇幅中慢慢探討吧 :)

小結

在不少複雜業務系統的設計中,每每由於建模角度的選取形成後續維護中的困難

在狀態機/流程引擎的設計上,建議考慮

  • 節點之間的轉移函數是否多態?

  • 節點自己是否多態?

  • 節點是否清晰的映射了需求場景中那些真正發生改變的對象?

  • 新增流程是否須要修改原有代碼?

在類層次設計上,建議考慮

  • 是否存在無用代碼?

  • 是否存在上帝類?

若是存在這些狀況,就像蛋糕上的黴斑,看起來只有一星半點,但你敢吃長黴的食物嗎

附錄

開閉原則

還記得「開閉原則」嗎,就一句話:系統(或者理解爲系統中的類、模塊、函數)對於「擴展」應該開放,而對於「修改」應該是封閉的。

這裏首先須要界定「擴展」的含義:在不改動原有系統代碼的狀況下,新增一個類算不算?新增一個方法呢?若是將它們套用「修改」的語義,對於模塊而言新增一個類是修改,對於類而言新增方法也是修改。開閉原則的邊界彷佛沒有那麼清晰。

咱們能夠用一個更簡單的思路來界定開閉原則:是否違背了原有的設計初衷

繼承的問題

《重構》書中花了很大的篇幅向讀者介紹「代碼的壞味道」,有一個「Unnecessary Code」的說法,大體的意思是繼承體系的基類中存在下游子類不須要的行爲,或者必須被糾正的狀況,工做個三五年的朋友們都或多或少的遇到過某個類的全部子類都在複寫基類方法的狀況吧。

在很多實用主義的架構文章中,都提過「使用組合來代替繼承」的觀點,其中流傳最廣的一個段子(抱歉我也不知道是否是真事)是:James Gosling 的某次演講會後Q&A環節中,有人問他,若是從新設計Java語言,你會作什麼?JG回答說,我會幹掉「類」,倒不是由於「類」自己有問題,而是會用實現(implements)來取代繼承(extends)

就平時項目的經歷而言,基類,尤爲是業務系統中的各類基類,是很是難設計的。由於很難在業務剛開始的時候就預想到它最終(結束維護下線時)的樣子,也經常由於這樣,咱們看到的大多數Base*命名的類時,除了限定參數類型,它的代碼行爲和Object基本無異(譬如BaseCommand,BaseItem,BaseAction我去太多了),讓後續維護的同事邊罵邊寫代碼。

上帝類

又是來自《重構》的點子:「One Class to rule them all, and in the darkness bind them.」,聽起來有點像《魔戒》的臺詞哈,這個理解起來就不像爲何避免繼承那麼糾結了,畢竟誰都不肯意維護一段三五千行並且看起來什麼事情都能作的代碼吧?哦,有人用一個彙編文件寫了個操做系統,我們的平臺看起來最多須要兩三個類就夠了。

相關文章
相關標籤/搜索