我把 300 行 if-else 拆成狀態機,然後後悔了一半
後端工程師,主力 Go,寫過好的抽象也寫過自我感動的抽象,還在學怎麼分辨這兩者。
這篇沒有什麼驚心動魄的線上事故,就是一個關於「過度設計」的自我檢討。我把一段醜到不行的訂單狀態判斷——大概三百行的巢狀 if-else——重構成了一台漂亮的狀態機。同事都說好看。半年後我回頭看,一半覺得值得,一半覺得我當初想太多。這篇講我為什麼重構、為什麼後悔了一半,以及我現在怎麼判斷「這個東西到底該不該上狀態機」。
一開始那段程式碼有多糟
訂單有十幾種狀態:待付款、已付款、備貨中、已出貨、已完成、退款中、已退款、取消……然後每個「事件」(付款成功、使用者取消、倉庫出貨、金流退款回調)都可能讓訂單從一個狀態跳到另一個。
原本的寫法就是一大坨:進來一個事件,先 if 判斷現在是什麼狀態,再 if 判斷這個事件合不合法,合法就改狀態、做副作用。巢狀三四層,三百多行,改一個規則要看老半天,深怕漏掉某個分支。它會動,但沒人敢碰。
我受不了,決定重構成狀態機。
重構成狀態機之後,確實變好的地方
我把它拆成一張明確的「狀態轉移表」:目前狀態 + 事件 → 下一個狀態 + 要做的動作。非法的組合直接查表就知道,不用再靠一層層 if 去擋。
有幾個好處是真的:
- 非法轉移被擋在門口。以前「已完成的訂單又收到付款成功事件」這種怪事要靠人腦記得去防,現在表裡沒這個組合,直接被拒,很乾淨。
- 看得懂全貌。整個訂單生命週期攤在一張表上,新人來看轉移表就懂業務流程,不用讀三百行 if。
- 好測試。每一條合法/非法轉移都能寫成一個獨立的測試案例,覆蓋率很好衝。
到這裡都很美好。
然後我後悔的那一半
問題出在,我不只做了「狀態轉移表」,我還很興奮地把它做成了一個通用的、可設定的狀態機框架——想說以後別的模組(例如退款流程、審核流程)也能複用。
這一步是多的。
- 抽象的代價超過收益。為了通用,我加了一堆設定、註冊機制、泛型。結果別的模組根本沒有複雜到需要狀態機,硬要套反而更難懂。那個「以後會複用」的未來,半年了沒有來。
- 除錯變難了。以前 if-else 雖然醜,但你單步 debug 一路跟下去,邏輯是直的。變成通用框架後,實際的轉移邏輯藏在框架的間接呼叫裡,堆疊變深,新人要理解「這個事件到底怎麼被處理的」反而要先搞懂我的框架。
- 我把簡單問題包裝成了需要文件的問題。原本大家對訂單流程的心智模型很直接,現在多了一層「你要先懂這個狀態機框架怎麼用」。
換句話說,狀態轉移表是對的,通用狀態機框架是我自我感動。
我現在的判斷標準
這件事之後,我對「要不要上某個模式/抽象」有了比較務實的準則:
- 狀態數 × 事件數大到人腦記不住、非法組合會出事,就值得把邏輯變成明確的轉移表。訂單這種十幾個狀態的,值得。
- 但先做「這個具體問題的轉移表」就好,不要順手做「通用框架」。通用化的成本是實實在在的,收益卻是「假設未來會有第二個使用者」——而那個使用者常常不會出現。
- YAGNI 不是叫你不要抽象,是叫你不要為了還不存在的需求抽象。 等真的出現第二個、第三個相似的需求,那時候再把共通的部分抽出來,你反而更知道該抽什麼。
如果重來我會怎麼做
我會保留那張訂單狀態轉移表——那部分我到現在都覺得對。但我會把它就寫成訂單模組裡一段直白的、專屬於訂單的程式碼,不搞泛型、不搞註冊、不搞「框架」。醜一點的重複,好過早一步的錯誤抽象。
Sandi Metz 有句話我很認同,大意是:重複遠比錯誤的抽象便宜。當年的我,用一個看起來很聰明的抽象,換掉了一段雖然醜但很誠實的重複,結果是聰明反被聰明誤。
小結
重構不是越抽象越好,「好看」也不等於「好維護」。我那次重構,對的部分是把混亂的條件判斷收斂成一張清楚的表,錯的部分是為了想像中的複用做了過度的通用化。現在只要我發現自己在寫「這個以後應該可以複用」的框架,我都會先停一下,問自己:現在到底有幾個使用者?如果答案是一,那我大概又在自我感動了。
留言討論
有想法、有不同經驗、或想糾正我?歡迎在下面留言,免註冊,填個暱稱就能留。