Working Effectively with Legacy Code 讀後心得: 解相依性技巧篇
前篇提到基本觀念,這篇描述一些解相依性的技巧。廣意來說也是重構技巧,差別是:
- 在沒有測試的保護下,以最小風險的前提進行。
- 目標是現有限制下最安全最省時的改善,不是一步到位改成理想的結構。這有點抽象,後面會舉些例子。
系統設計是一連串的取捨。在描述解相依性技巧前,先談兩個設計取捨,較好明白為什麼這些技巧要這麼作。
該為了可測性改變介面嗎?
初學 TDD 時我對此有疑問,愈後期我的答案愈明確。本書作者對此的信仰比我更堅定: 答案是肯定的。
現在我甚至會以此來判斷其他人對軟體開發理解的階段。試著回答下列問題:
- 軟體開發最常作也最重要的事是維護嗎?
- 難以維護的主因是難以測試嗎?
- 為了方便測試願意改變產品碼 (software under test, SUT) 介面嗎?
這裡的介面泛指 end-to-end 到內部模組、類別、函式。有時可兼顧好用的介面和可測性,只是會多一點程式碼。例如 ConfigLoader 的 Load() 接收一個參數,回傳 Config 物件。自然地,我們會這樣定義 Load (用 Go 語法表示):
func (c *ConfigLoader) Load(path string) (Config, error)
讓 Load 接收檔名,用起來比較方便。但是,這樣要準備檔案才能測試,並不方便。
為了可測性,改用 Reader 比較方便,這樣可以用字串準備測試,轉為 Reader 傳入測試。:
type Reader interface {
Read(p []byte) (n int, err error)
}func (c *ConfigLoader) load(r Reader) (Config, error)
為了方便在產品碼中使用 ConfigLoader,再補上另一個方法呼叫 load:
func (c *ConfigLoader) LoadConfig(path string) Config {
var r Reader
// read data from the file to r
...
return c.load(r)
}
LoadConfig() 夠簡單,以致於只要 load() 是正確的,我們不用測試它,也有信心它是正確的。或是配合一個簡單的 end-to-end test 即可。
測試碼和產品碼一樣重要嗎?該用一樣標準看待它們嗎?
明白可測性的重要性後,這問題還是困擾我許久,感覺要更重視測試碼,但比照產品碼的規格嘗試,作起來就是不太對。本書作者給我新的啟發:測試碼雖然重要,看待它們的方式卻和產品碼不同。只要沒影響到產品碼,可以用不同的標準看待測試碼。
比方說:
- 產品碼不該生成物件後立即用 setter 改變內部狀態。但是應急時,測試碼可以這樣作,藉此降低相依性方便測試。畢竟,我們不擔心測試碼會用錯 (多呼叫到/少呼叫到 setter),或是生成物件函式和 setter 之間有其它 threads 存取物件,因為測試碼是在控制的環境下執行。
- 產品碼應該少用繼承,避免複雜化程式流程。但 Java 測試碼用繼承然後覆寫 methods 避開不想發生的 side effect,許多時候比其它作法方便。同樣的,在控制的環境下,這種繼承沒有用錯的風險。
- 不該傳入 null 到產品碼的 constructor。但為了簡化測試碼,有時可以接受。
之前有意識到某些產品碼的準則在測試碼不通用,比方說為了 Don’t Repeat Yourself 而讓程式碼之間有相依性,測試碼反而要更重視 Keep It Simple and Stupid。容許一些重覆碼,降低因為引入複雜邏輯消除重覆碼而出錯的機會。看了本書後,才算真的突破此迷思。
了解這兩個取捨的標準後,接下來看一些書中的技巧。
保持函式輸出入和實作特徵測試
盡可能保持函式原本的輸出入 (signature),維持單一目標,這樣作起來比較簡單,風險較低。如果原有的程式已方便加測試,可以針對測試範圍補上特徵測試。
例如要修改 class Cache,先補上測試正常情況、邊界條件等測試案例。此時不在乎「正確的行為」是什麼,而是關心「原本的行為」。原本輸出什麼,就在測試碼裡驗證什麼。有些工具會提供錄制這類測試的方法,例如 React 的 snapshot testing 會錄下 render 的結果。用 snapshot/golden testing/files 搜尋,可以找到對應語言工具或技巧,例如 Go golden files。
這有助於理解原本的行為,配合偵測 test coverage 的工具,確定涵蓋足夠範圍後,就能開始修改了。待更好維護時,再來關心如何往「正確的行為」演進。
暫時用的感測物件
為了確定程式碼有執行到修改相關的部份,可以在類別裡放入暫時的 member field X,用以記錄內部狀態,然後在測試碼讀出 X 的值,確認修改正確。改完後再移除測試碼和 X。
初看會覺得這作法很怪,加 member field 觀察內部很髒,加完測試又移除很浪費。其實這和修改時加 log 然後看 log 類似,只是自動化確認 log 的行為。
Extract Interface 和 Extract Implementer
許多情況太難準備測試碼,就需要接下來提的數個方法輔助,才有辦法準備深試碼。Extract Interface/Implementer 是最簡單也是最常見的作法。
若 class A 使用到 class B,不方便生成 B 或是 B 內部有複雜操作 (例如連資料庫)。我們想測 A,該怎麼辦?
理想的狀況是改成:
- A 相依 interface I
- B 實作 I
假設 B 有方法 b1、b2、b3,而我們測試的情境只需要 b1,宣告 I 實作 b1 即可。另外,不一定要將產品碼內全部 A 用到 B 的地方都改用 I。修改想測試的部份即可。測試時改用新的 class TestingB 實作 I。
若覺得很難想到適合的介面名稱,作者建議可改成將 A 主要實作移到新的subclass:
- 將 B 改成 absract class。
- 新增 class SubB 繼承 B,將主要實作移到 SubB (選個適合的名字)。
測試時可用 TestingB 繼承 B,讓 A 使用 TestingB,比較好準備測試碼。
使用的程式語言會影響這裡的取捨,像使用 Go 時,幾乎都會用 Extract Interface,一來 implicit interface 很方便,二來 Go 沒有繼承,只有一個湊合用的 embedded struct。
Subclass and Override Method
這是使用 Java 時,最常用的方法。
假設 A 用到 B, 測試 A 的過程裡,會間接呼叫到 B.foo(),而我們不希望執行它原本的行為。可以這麼作:
- 新增 class TestingB 繼承 B。
- 覆寫 foo() 讓它不要作事。
作者的觀點是,如果 foo 因為被宣告成 private 或 final 而不能覆寫,值得將它改成能覆寫狀態。對此有疑慮的話,可回頭看本文前面討論的兩個問題。
另外要留意的是,我們的目的不是一步到位作出理想的結構。這只是暫時的,日後也許會有新的修改將 foo() 拆到別的類別,然後用 interface 隔離。到時就沒有「硬把 private 變 package-private 或 protected」的良心不安感了。
此招有許多變型,例如 Extract and Override Call:
public class PageLayout {
...
protected void rebindStyles() {
styles = StyleMaster.formStyles(...)
...
}
...
}
假設我們想替換掉 styles,可以這麼改寫:
public class PageLayout {
...
protected void rebindStyles() {
styles = formStyles(...)
...
}
protected List formStyles(...) {
return StyleMaster.formStyles(...)
}
...
}
測試時可用 TestingPageLayout 繼承 PageLayout,然後覆寫 formStyles()。
其它還有 Extract and Override Factory Method,用來改變內部生成的物件。
原本我很討厭繼承,但在 Java 裡面這麼作很省事,不用修改使用到 PageLayout 的程式。過於偏執不用繼承不是好事,應該要了解每個工具的特性,在適合的場合使用。
使用 interface / abstract class 有些小麻煩,第一次作的時候要改許多呼叫的地方,之後介面有變 (像是加個參數),要改介面、改產品碼實作、改測試碼實作,改久了也是挺煩的,會降低團隊成員寫測試的意願。
Pull Up Feature 和 Push Down Dependency
這也是用繼承的技巧。若我們想測 A.foo(),但 A 相依太多東西,不方便測試。可以將 A 的功能分類 (參閱前篇的「職責識別」),然後將我們關心的東西留在 abstract class,不關心的東西放到繼承的 class SubA;或是反過來。
舉例來說,class TradeValidator 有個方法 isValid(),用來驗證邏輯。不幸的是 TradeValidator 會將過程中的變化顯示到 GUI 上,測試時我們不想和 GUI 扯上關係。解法是:
- 宣告 TradeValidator 為 abstract class。
- GUI 相關的方法和變數移到 sub class WindowsTradeValidator。
測試時使用繼承 TradeValidator 的 TestingTradeValidator,然後 GUI 相關的部份都是空實作。
剛開始可能無法分得很乾淨,只有分到剛好可以測 isValid()。等有一天清到 WindowsTradeValidator 完全只含 GUI 操作時,這時可以解開繼承關係,改用組合的方式拆成 class TradeValidatorController (原 TradeValidator) 和 class WindoTradeValidatorView (原 WindowsTradeValidator)。
Adapt Parameter
假設我們要測 class A 的 foo(),foo() 用到一個物件 B,不容易生成。實際上我們只需要 B 的部份內容,此時可用 Adapter Pattern 引入簡化的實作取代 B,例如:
public void populate(HttpServerletRequest request) {
String[] values = request.getParameterValues(...);
if (values != null && values.length > 0) {
...
}
...
}
測試時不方便生出 HttpServerletRequest。然而,我們只需要一個能傳回第一個參數的實作:
public void populate(ParameterSource source) {
String value = source.getParameter(...);
if (value != null) {
...
}
...
}
ParameterSource 是個 interface 只有一個方法 String getParameter(…)。測試碼要實作 ParameterSource 相當容易。
Primitivize Parameter
假設我們需要為 class Sequencer 加入新的邏輯,對兩個 Sequncer a、b 來說, a 和 b 是否符合某種特徵,例如 b 是 a 的子序列、兩者有共通子序列等。
聽起來在 Sequencer 加個方法就能搞定。遺慽的是,Sequencer 相依一堆類別,很難測完想涵蓋的例子。幸運的是,判斷特徵時,只需要 Sequencer 部份狀態,且該狀態可以用整數陣列表示。
於是我們可以這麼作:
- 實作和測試
boolean check(List<Integer> a, List<Integer> b)
。測試是 List<Integer>,很好準備。 - 實作 Sequencer 的方法 check,signature 和函式 check() 一模一樣。內容像是這樣:
class Sequencer {
...
boolean check(Sequencer other) {
List<Integer> a = this.getXXXCopy()
List<Integer> b= other.getXXXCopy()
return check(a, b);
}
...
}
只要 getXXXCopy() 容易實作,在函數 check() 有良好測試的情況,我們有信心 Sequencer 的方法 check() 有一樣的正確性。
Break Out Method Object
這招和前個情況類似,將複雜的邏輯外包出去。
假設 class A 有個很長很複雜的方法 foo(),接收三個參數 X、Y、Z 回傳 W。該如何測 foo?
解法是建新類別 B:
- B 的 constructor 有四個參數 (A, X, Y, Z)。
- B 只有一個方法 foo(),回傳值和 A.foo() 一樣。
- A.foo() 的實作變成如下:
B b(this, X, Y, Z)
return b.foo()
接著編譯,讓 compiler 告訴我們 foo() 用到那些 A 的其它方法。假設有 bar()。再來要作的事是:
- 建立 interface I,只有一個方法 bar()。
- 讓 A 實作 I。
- 讓 B 改成擁有 I 而非 A。
雖然物件關係圖看起來很奇怪:「A 生出一個型別為 B 的暫時物件,然後將自己部份方法公開到只有 B 用的介面 I 裡」。但是,這個作法的好處是 B.foo() 變得很好測試,方便置換 I,減少相依性。
書上舉的例子是處理 class GDIBrush 的 draw,用 C++ 程式碼表示如下:
void GDIBrush::draw(
vector<pint& renderingRoots,
ColorMatrix& colors,
vector<point>& selection)
{
for (auto it = renderingRoots.begin(); ...) {
point p = *it;
...
drawPoint(p.x, p.y, colors[n];
}
...
}
drawPoint() 是 GDIBrush 另一個 method。
使用 Break Out Method Object 後的中間碼如下:
class Renderer
{
public:
Renderer(
GDIBrush *brush,
vector<pint& renderingRoots,
ColorMatrix& colors,
vector<point>& selection);
void draw();
}
Renderer() 的實作是存下四個變數到 member fields。draw() 的實作內容和 GDIBrush::draw() 一模一樣,除了呼叫 GDIBrush 方法時,要用 constructor 存下的 brush 呼叫。這件事可讓 compiler 幫我們找出要改的部份。所以整段操作很單純,改錯風險很低。
GDIBrush 的 draw 變成如下:
void GDIBrush::draw(...) {
Renderer renderer(this, ...); // ... 是 draw() 的參數
renderer.draw();
}
再來要作的是:
- 加入 abstract class PointRenderer,內容有 Renderer 用到 GDIBrush 的方法,在這例子裡只有 drawPoint()。
- Renderer 改接受 PointRenderer 而不是 GDIBrush。
於是,我們在沒有加上測試碼的情況下,完成重構。再來可以為 Renderer::draw() 加上測試碼,然後修改 Renderer::draw()。
結語
看待測試碼+產品碼的品味,和單看產品碼的品味是不同的。若能接受這個觀點,將為測試碼開啟許多活路,讓我們和 legacy code 能更好地相處。
進一步來說,即使不熟先進的測試工具,只要能接受為測試碼降低對產品碼「漂亮」的期望,有很多方法可行。
像是:
- 在 Java 裡無法取出 private method,又需要測試的時候,就將存取改為 package-private (default access level) 吧!
- Java 無法覆寫 final 就拿掉 final!
- C++ 無法覆寫 non-virtual method,就加上 virtual!
移掉改變產品介面的一些「枷鎖」後,可以找出許多方法改善可測性,補上 unit test。看完本篇截錄的部份技巧,應該能有所啟發,發展出自己的技巧。記得修改後留下註解,提醒後來的人,在時機成熟、能作更侵略性的重構時,再修正這些對產品碼來說暫時性的 code smell。