
阿特拉斯是泰坦一方的巨神,後來宙斯帶著兄弟姊妹與泰坦陣營打了一場長達十年的戰爭,最後由宙斯一方獲勝。
輸了就要受罰,而阿特拉斯的懲罰特別重,宙斯罰他站在大地的西邊,用頭和雙手頂住整片天空,一刻都不能放。
這份差事沒有辦法換人做,也沒有下班時間。
後來,海克力士有一趟任務要完成,十二項試煉其中一項,是要帶回生長在世界邊緣、赫斯珀里得斯花園裡的黃金蘋果,而那座花園偏偏就在阿特拉斯附近,海克力士決定去找他幫忙。
條件談成了,阿特拉斯去拿金蘋果,海克力士在這邊暫時頂住天空。
這個交換聽起來很合理,阿特拉斯也真的去了,蘋果也拿到了,但他拿著蘋果走回來的時候,忽然想到一件事,跟海克力士說:
「我可以幫你把蘋果送到目的地,你繼續幫我撐著天空吧。」
海克力士一聽就知道麻煩大了,阿特拉斯這下是不打算把天空接回去了,於是想了一下,靈機一動,跟阿特拉斯說:
「好啊!沒問題,不過你先幫我頂一下,我在頭上墊個墊子,這樣之後扛起來比較不痛。」
阿特拉斯答應了,當接過手後,海克力士就拿著蘋果走了......
好不容易把天空交出去,最後還是回到原地。
聽起來這個故事超荒謬的,但仔細想想,阿特拉斯真正麻煩的地方,可能不是「遇到一個很會騙人的海克力士」,而是宙斯的懲罰本來就是要懲罰泰坦神的,就算有人願意幫忙接手,只要這個懲罰一直都在,最後阿特拉斯還是得回到原本的位置嘛! 逃不了的。
在 Day07、Day08 中,Sprout 跟 Wrap 技術雖然很適合先暫時把改動隔離出去,但如果我們每一次都只是在旁邊蓋小屋,卻從來沒有回頭處理老房子的責任邊界,久了還是會遇到另一個問題,雜草越來越多,而且長得更快、更冒密,維護的時候就會很痛苦,跟阿特拉斯一樣,逃的了一時逃不了一世。
而今天我們要正視這個問題,class 太大了不是因為某次大改動,是因為每一次「就這裡加幾行」的決定累積下來的,今天我們就先建立識別 God Class 責任群的能力,試著識破它的真面目 。
我們很容易把「大」當成 God Class 的判斷標準,一個 class 超過行數就開始不對勁,但行數只是症狀,真正病因其實是「這個 class 被改動的理由不只一個」。
SRP(Single Responsibility Principle,單一職責原則) 是 Robert C. Martin 提出的物件導向設計原則,如果放在 class 來看,說的是:
一個 class 應該只有一個理由會使它改變
聽起來抽象,但實際上一個 class 如果同時負責計算、儲存、通知、記錄歷史,那任何一個需求變化都會讓這個 class 被改動到。
而且 God Class 像焦油坑一樣,它會讓周圍的人繼續往裡面加邏輯,因為「反正這裡什麼都有,就放這裡吧」,形成惡性循環,新邏輯不斷湧入,也不會有人願意第一個動它,這種動態在大型系統裡特別常見,改一個功能要先搞懂整個 class,改完還要確認沒有波及其他責任,是很巨大的時間黑洞。
今日範例 是一個手遊的抽卡系統,最早只有「依機率抽出道具」,很單純,class 大概三個 method,後來加了保底計算(連抽 N 次必出稀有),再後來加了庫存扣減、歷史記錄、稀有道具推播,最後又加了月消費報表,每個功能加進去的當下都「只是幾個 method」,但半年後 GachaService 變成了這樣:
public class GachaService
{
// 機率設定
private readonly Dictionary<string, decimal> _rateTable;
// 達到幾次必觸發保底
private readonly int _pityThreshold;
// 保底計數
private int _pullCount;
private bool _isGuaranteed;
private readonly IInventoryRepository _inventory;
private readonly IPullHistoryRepository _historyRepo;
private readonly INotificationService _notification;
public PullResult Pull(string playerId) { ... }
private Item SelectItemByRate() { ... }
private Item GetGuaranteedItem() { ... }
private bool CheckPityCondition() { ... }
private void IncrementPityCounter() { ... }
private void ResetPityCounter() { ... }
public int GetPityProgress(string playerId) { ... }
private void DeductInventory(string itemId) { ... }
public bool HasStock(string itemId) { ... }
private void RecordHistory(string playerId, PullResult result) { ... }
public List<PullHistory> GetHistory(string playerId) { ... }
private void NotifyRareItem(string playerId, string itemId) { ... }
public GachaReport GenerateReport(string playerId) { ... }
private decimal SumSpending(List<PullHistory> history) { ... }
}
保底閾值改了要動它,庫存邏輯換了要動它,通知格式改了也要動它,完全不相干的事情卻都在同一個 class 裡相遇,這不是「大不大」的問題,而是每次有需求變更,都會讓我們被迫要進來這個是非之地。
更麻煩的是,這個 class 同時被多種不同工項的 member 一起做修改,所有人的 commit 都集中修改同一個 class,merge conflict 幾乎每個 sprint 都會出現,也進一步暴露出責任過度集中在同一個地方的問題。
有一天我們實在是受不了了,嚴重影響到我們的開發品質,那麼我們就勢必要對他進行瘦身的動作,不過該從何下手呢?
答案是先做最輕量的觀察,不要一上來就試著拆開他們,以下透過幾種 Michael Feathers 在《Working Effectively with Legacy Code》裡提到的方式幫助我們去找出職責。
方法分組(Method Grouping) 可以算是從既有程式碼辨識責任的方式,把一個 Class 裡所有 Method 列出來,觀察哪些 Method 看起來是在做同一類事情,再把它們分組。
把GachaService 的 method 列出來:
| 方法名稱 | 在負責 |
|---|---|
Pull, SelectItemByRate, GetGuaranteedItem |
抽卡機率核心 |
CheckPityCondition, IncrementPityCounter, ResetPityCounter, GetPityProgress |
保底計算 |
DeductInventory, HasStock |
庫存操作 |
RecordHistory, GetHistory |
歷史記錄 |
NotifyRareItem |
通知推播 |
GenerateReport, SumSpending |
消費報表 |
我們不需要讀懂每個 method 的實作,光看名字就能初步看出六個責任群,命名是責任的指紋,通常方法名稱帶有明確動詞加特定名詞的,最容易劃出界線,像是 Pity 這組、History 這組,所以說命名真的是開發中很重要的一環。
GachaService 裡有不少 private helper,大量的私有成員有可能是另一個 class 在裡面等著被抽取出來的訊號,但目前只是探勘線索,並不是看到 private 就馬上要做拆除,例如 CheckPityCondition 如果連同保底狀態一起抽出去,就能透過新 class 的公開行為獨立測試,不需要經過整個 GachaService 才能確認保底邏輯。
特徵草圖(Feature Sketch),它是用來分析大型 Class 內部結構的一種視覺圖,把 class 內部「誰用了誰」畫出來,具體說就是哪個 method 呼叫了哪個 method、哪個 method 存取了哪個 field,最後形成一張節點與箭頭組成的連接圖。
Feature Sketch 和 Day14 學到的 Effect Sketch 看起來很像,但關注角度不同:
做法也很簡單,紙和筆幾分鐘就能搞定:
我們試著先把 GachaService 的連接關係畫出來:

連接圖畫完之後,下一步是在圖上圈出職責聚落,也就是找出那些「只和彼此相連,和其他群組幾乎沒有交集」的節點群。
在 GachaService 的圖裡,_pullCount、_isGuaranteed、_pityThreshold 這三個 field 和 CheckPityCondition、IncrementPityCounter、ResetPityCounter、GetPityProgress 這四個 method 之間的連線又密又集中,和 _inventory、_historyRepo、_notification 幾乎沒有往來,於是第一個聚落我們就找到了,可以先整個圈起來,接著再一個一個把聚落圈出來:

這張圖有可能不是我們最後的拆解計畫,而是先當作一張地圖,地圖畫好了才知道要動哪一塊、以什麼順序。
這裡要特別注意一件事,識別出責任群不代表全部都要立刻拆,現在先給這些群組一個暫定名字,真正搬出去的時候再來看看這個名字是否符合它的責任,庫存和歷史記錄分成兩群,因為 _inventory 和 _historyRepo 本來就是注入的介面,現在其實就是在 Service 注入它們,再拆出去的增益有限,也許可以先留著,等有明確的理由再說,有時候需要在改動之前先判斷成本和效益,不該把它當成極限運動 XD
以前看到不好看的 Code,我們總是想要一步就把它們全部消滅,結果常常把自己弄得很狼狽,後來才發現事前探勘的重要性,因為我們要重點關注的不是這個 class 的體積行數,而是找這個 class 有幾個改變的理由。
當我們習慣做這件事情時,縮小範圍到 method 中也是一樣的道理,所以我們來統整一下今天觀察 God Class 可以採取什麼方式:
兩種方式各有適合的場景,用以下表格來比較:
| Method Grouping | Feature Sketch | |
|---|---|---|
| 花費時間 | 很低,列出 method 清單即可開始 | 較高,視 class 複雜度而定 |
| 門檻 | 低,不需要讀實作,看名字就夠 | 中,需要追蹤每個 method 的呼叫和 field 存取 |
| 準確度 | 依賴命名品質,命名如果不明確就容易誤判 | 不受命名影響,看的是實際的連線關係 |
| 最適合的時機 | 能快速掃描,建立初步地圖 | 命名品質差,或需要更精確的結構分析時 |
實務上也可以先做 Method Grouping 建立第一張地圖,如果地圖看起來可疑,或者有幾個方法怎麼看都不知道該放哪一群,再補一張 Feature Sketch 去確認實際的連線關係,兩個工具互補,不需要每次都全做。
那麼責任群我們找到了,接下來我們就依照地圖來一個一個來分離它們。
接下來我們繼續看:責任群框出來了,怎麼把它們分開?