iT邦幫忙

2026 iThome 鐵人賽

DAY 20
0
Software Development

諸神也搖頭的 Legacy Code: 30天 .NET 工程師生存之道系列 第 20

Day 20 -「阿爾戈號」識破 God Class 的真面目(下):搬走了,怎麼還在?

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260819/20182564MqEjaGjqlK.png

傳說中伊阿宋召集了一群英雄,搭上阿爾戈號出發,這群人後來也因此被稱為阿爾戈英雄,他們的任務是去遙遠的科爾基斯,把金羊毛給帶回來。

而如果想要前往科爾基斯,就得繼續進入黑海,傳說中是一個幾乎沒有人能活著通過的航道。

黑海入口有兩塊不斷相撞的巨岩,只要有船從中間穿過,岩石就會向彼此撞去,把船撞爛。

預言家在他們出發前沒有送他們什麼能撐住岩石的神器,只告訴他們一個方法:

  1. 先放出一隻鴿子,讓牠飛過岩石。
  2. 如果鴿子成功飛過去,就代表還有機會。
  3. 等岩石重新分開後,所有人立刻全力划槳,衝過去。
  4. 但如果連鴿子都死在岩石之間,就不要硬闖,直接回頭。

英雄們抵達岩石前,把帶來的鴿子放了出去。

鴿子飛進兩塊巨岩之間,岩石隨即轟然撞在一起,只削掉了牠尾巴末端的羽毛,鴿子成功飛到了另一邊。

一看到這個景象,眾人馬上察覺這就是訊號。

當岩石再次分開,所有人全力划槳,阿爾戈號衝進縫隙,過程相當驚險,最後!終於成功穿過兩塊巨岩,只讓船尾末端被削掉一部分。

要說這個故事最大功臣,應該是那隻鴿子吧!

昨天我們替 抽卡服務 做了 Method Grouping 和 Feature Sketch,從一大堆 field 與 method 裡圈出保底、機率、庫存、歷史、通知和報表等責任聚落,地圖畫完之後,今天終於要開始搬家。

不過 Extract Class 並不是萬靈丹,如果只是把程式碼剪下來貼到另一個檔案,那 IDE 已經很會做這種事情了,我們真正要解決的是:

程式碼搬走之後,原本 class 是否真的少知道了一件事?

今天我們就先挑保底責任下手,把它搬出 GachaService,搬完以後,我們會遇到一種很有意思的問題,兩個物件明明已經分開,卻還是像黏在原地一樣,需要知道的還是太多了。


為什麼先選保底?

昨天我們得到了六個責任群的地圖,但這不是拆除清單:

Michael Feathers 在《Working Effectively with Legacy Code》中提供了一個務實的作法:

Focus on the Current Work

他建議我們先關注手上正在進行的工作,在實務上,通常是某個 Issue 告訴我們哪個區域必須先改,我們不需要為了追求完美,順手重建整個系統。

不過今天是教學範例,手上沒有一張真實的需求單可以替我們做決定,所以今天,我們會用以下四種特徵挑第一個下手的對象:

  1. 這群程式是否同時擁有自己的狀態與操作狀態的行為?
  2. 群組內部的連線是否密集?
  3. 它和其他責任之間的連線是否夠少?
  4. 搬出去之後,是否能得到明確的測試與設計收益?

保底剛好四個條件都符合。_pullCount_pityThreshold_isGuaranteed 是一組會一起變動的狀態,CheckPityCondition()IncrementPityCounter()ResetPityCounter() 又都圍繞著它們工作,群組內部的連線也相對集中。

更重要的是,搬出去之後,我們可以不必同時準備庫存、歷史和通知等依賴,就直接測試保底狀態如何前進與重設,這不是因為其他責任永遠不用拆,而是現在動它能換到的設計與收益也最明確。

動手前照慣例先加上 Characterization Tests,完整測試會放在 GitHub 的 Refactoring Branch,礙於篇幅,正文就不展示測試程式碼。


首先只搬家,不重新設計

測試綠燈以後,我們可以先透過重構工具的 Extract Class 把保底機制相關的 field 和 method 原封不動搬出去。

https://ithelp.ithome.com.tw/upload/images/20260819/201825640QFpm7pSAx.png

我們可以看到它們不只負責計算,還持有會隨每次抽卡改變的進度,因此這一版可以先叫它 PityProgress

public class PityProgress
{
	public int  PullCount { get; private set; }
	private readonly int _pityThreshold;
	private bool _isGuaranteed;

	public PityProgress(int pityThreshold)
	{
		_pityThreshold = pityThreshold;
	}

	public bool CheckPityCondition()
		=> PullCount >= _pityThreshold || _isGuaranteed;

	public void IncrementPityCounter() => PullCount++;

	public void ResetPityCounter()
	{
		PullCount     = 0;
		_isGuaranteed  = false;
	}
}

GachaService 程式碼就會變成如下:

public class GachaService
{
	private readonly Dictionary<string, decimal> _rateTable;

	private readonly IInventoryRepository   _inventory;
	private readonly IPullHistoryRepository _historyRepo;
	private readonly INotificationService   _notification;
	private readonly PityProgress _pityProgress;

	public GachaService(
		Dictionary<string, decimal> rateTable,
		int pityThreshold,
		IInventoryRepository inventory,
		IPullHistoryRepository historyRepo,
		INotificationService notification)
	{
		_rateTable    = rateTable;
		_pityProgress = new PityProgress(pityThreshold);
		_inventory    = inventory;
		_historyRepo  = historyRepo;
		_notification = notification;
	}

	public PullResult Pull(string playerId)
	{
		var isPity = _pityProgress.CheckPityCondition();
		var item   = isPity ? GetGuaranteedItem() : SelectItemByRate();
		var result = new PullResult(item, isPity);

		_pityProgress.IncrementPityCounter();
		if (isPity) _pityProgress.ResetPityCounter();

		DeductInventory(item.Id);
		RecordHistory(playerId, result);
		if (item.IsRare) NotifyRareItem(playerId, item.Id);

		return result;
	}

	public int GetPityProgress(string playerId) => _pityProgress.PullCount;

	// ....略
}

搬家做到這裡,我們再稍微整理一下名稱,既然 class 已經叫做 PityProgress,method 就不需要每次重複 Pity,保留真正描述動作的部分就好:

private readonly int _threshold;
public bool CheckCondition()
public void Increment()
public void Reset()

那是不是就完成了?


搬走了,怎麼還是黏著?

我們先假裝自己是第一次使用 PityProgress 的人,只看它公開的介面,接著試著回答一個問題:

「我要完成一次抽卡的保底狀態轉換,應該怎麼去使用?」

我們會發現其實不太容易知道答案。

我們得先閱讀 GachaService,才會發現正確順序是:

CheckCondition → Increment → 如果 Check 是 true,再 Reset

順序只要換掉,行為就會改變,例如先 Increment()Check(),保底可能提早一抽,如果忘記 Reset(),接下來每一抽都可能繼續保底,這時候就出現了一個有趣的現象,保底的資料和 method 明明都已經離開 GachaService,但「怎麼正確操作保底」 的知識還留在原地。


Temporal Coupling

這種「每個 method 單獨呼叫都很合理,合在一起卻非得照特定順序」的現象,常被稱為 Temporal Coupling(時序耦合)

Temporal 不是指程式必須在幾秒內完成,而是呼叫之間存在時間上的先後契約,當多個操作必須依照特定順序執行,系統才能得到正確結果,那它們之間就產生了時序耦合。

Temporal Coupling 不一定能完全消除,有些操作本來就有合理的生命週期,但真正值得我們警覺的是,有多少人必須知道這個順序、這些程式離得有多遠,以及這套生命週期是否只存在文件或呼叫端的記憶裡。

常見的例子有很多:

  • 先開啟連線,才能執行指令。
  • 先開始 Transaction,才能 Commit。
  • 先取得鎖,完成操作後才能釋放鎖。
  • 先檢查保底,再更新計數,符合條件時才重設。

這不表示只要程式有執行順序就是壞設計,任何流程都會有先後關係,問題在於擁有狀態的物件是否也保護了正確的操作順序,還是把這份責任洩漏給外面的呼叫端。

換句話說,GachaService 變得必須知道 PityProgress 的內部生命週期後,原本不明顯的耦合問題就會變得特別明顯,讓我們更意識到「這個知識到底應該屬於誰?」。


Tell, Don't Ask

既然 _pullCount_pityThreshold_isGuaranteed 都已經由 PityProgress 擁有,最知道如何轉換這些狀態的人,也就應該是 PityProgress 自己。

Tell, Don't Ask:指的是當一個物件已經擁有完成決策所需的狀態時,呼叫端不必先把狀態問出來,再替那個物件決定下一步,比起詢問一堆內部資訊,呼叫端可以直接說出自己的意圖,讓真正擁有資料的物件自行完成判斷。

目前 GachaService 先問 Check(),拿到 isPity 以後,又替它決定何時 Increment()、什麼情況要 Reset(),所以我們可以把三個公開步驟收斂成一次完整操作:

public class PityProgress
{
	public int PullCount { get; private set; }
	private readonly int _threshold;
	private bool _isGuaranteed;

	public PityProgress(int threshold)
	{
		_threshold = threshold;
	}

	public bool ResolvePull()
	{
		var isPity = CheckCondition();
		Increment();
		if (isPity) Reset();

		return isPity;
	}

	private bool CheckCondition()
		=> PullCount >= _threshold || _isGuaranteed;

	private void Increment() => PullCount++;

	private void Reset()
	{
		PullCount     = 0;
		_isGuaranteed  = false;
	}
}

功能並沒有消失,只是不再要求外部記住呼叫它們的順序,GachaService 只需要提出一個完整意圖:「我要處理下一抽的保底狀態」,改完之後程式碼如下:

public PullResult Pull(string playerId)
{
	var isPity = _pityProgress.ResolvePull();
	var item   = isPity ? GetGuaranteedItem() : SelectItemByRate();
	var result = new PullResult(item, isPity);

	DeductInventory(item.Id);
	RecordHistory(playerId, result);
	if (item.IsRare) NotifyRareItem(playerId, item.Id);

	return result;
}

ResolveNextPull() 還是會回傳 bool,這並不違背 Tell, Don't Ask,GachaService 的確需要知道這一抽是否保底,才能決定接下來選普通道具還是保底道具,真正被封裝起來的,不是所有查詢,而是「保底狀態該如何更新」這原本就不該由呼叫端控制的流程。

最後別忘了要執行測試,看看有沒有被我們改壞了,之後 PityProgress 如果有新功能也就補上範圍更小、更快的單元測試了。

好的封裝不只把資料藏起來,也把操作資料的正確順序藏起來。


架空 GachaService 後還剩下什麼?什麼時候該停手?

可以看到 Feature Sketch 中還有很多職責群,是不是應該趁手感正好,今天一次全部拆光?

Michael Feathers 在分析大型 class 時,提出了一個很好用的判斷方式:

Primary Responsibility(主要職責) 是什麼?」

我們不妨先把問題假設到 Pull(),如果它依賴的保底、機率、庫存、歷史和通知都各自回到專門的物件,這個 method 還會剩下什麼?

  1. 取得這一抽的保底結果。
  2. 選出道具。
  3. 建立抽卡結果。
  4. 要求庫存、歷史與通知依序完成各自的工作。
  5. 回傳結果。

Pull() 不會消失,留下來的是把「抽一次卡」從頭帶到尾的流程協調好。它不必親自計算每一條規則,只要知道這次操作需要哪些物件合作、先後怎麼安排,以及最後該回傳什麼。

DDD (Domain-Driven Design) 的分層架構中,類似這種「協調一個 Use Case」的角色,通常稱為 Application Service(應用服務),它接住呼叫端提出的操作,協調領域物件、Repository 和外部服務完成一個使用案例,它可以安排流程與交易邊界,但不該替領域物件判斷保底或機率規則,專業問題就要交給專業的來。

那我們最好把 God Class 都變成類似像 Application Service 的角色嗎?什麼時候該停手?要做到什麼程度?

不一定!Application Service 並不是某種標準,重點還是要**找出 Primary Responsibility。

在 Legacy System 中,我們出手通常是因為要完成一項任務,不是因為今天忽然想把架構升級就升級,就像客人只是點了一碗牛肉麵,廚師做到一半突然覺得廚房動線不合理,於是開始搬冰箱、換瓦斯管線、重新設計出餐流程,心情舒服終於要開始煮麵了,客人也走光了 XD

一上來就大規模重構往往適得其反,改動範圍越大,需要驗證的地方越多,引入新問題的機會也越高,原本要解決的事情反而更難聚焦,所以 Focus on the Current Work 真的是很實際的心法:

這次搬動的每一段程式碼,能不能用目前任務的目的來解釋?

能解釋的,才納入這次改動,解釋不了的,即使目前看起來不夠漂亮,也先留給下一個有更明確的動機與驗證範圍再做,我們幾乎沒辦法一步登天,所以真正要做到扎實的,是盡量預防它又偷偷地變回我們不願意看到的樣子。


總結

今天我們透過 Feature Sketch 切分出了職責,但這也並不保證我們自動就得到好的封裝,如果呼叫端仍得知道一份「先做 A、再做 B、符合條件才做 C」的說明書,那麼這些物件即使在檔案夾中物理性的被分開了,在設計上可能還存在很高的耦合性。

就像阿爾戈號穿越岩石,步驟有很多,而真正決定能不能通過的,是順序有沒有做對。

接下來我們把觀察範圍再縮小:如果所有責任不是散落在一個大 class,而是全部在同一個 method 裡,該怎麼辦?

Reference


上一篇
Day 19 -「擎天之神阿特拉斯」識破 God Class 的真面目(上):觀察
系列文
諸神也搖頭的 Legacy Code: 30天 .NET 工程師生存之道20
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言