多想三分鐘,你可以少欠很多技術債

fcamel
fcamel的程式開發心得
9 min readDec 23, 2019

開發過程有技術債是天經地義的事,有經驗的工程師會拿捏欠債的比例,並在適當的時機還債。過於執著不欠債並非好事,早期最佳化會引入不必要的複雜,拖慢進度。

另一方面,有些債其實可以事先避免,這是本文討論重點。至於欠債後如何還債,可參考有名的《Working Effectively with Legacy Code》

早期發現,早期治療

圖片出處: 用 http://www.eainc.jp/dekamoji/ 生成後再編輯

軟體開發的過程包含釐清需求、設計、開發和測試。愈早發現問題,可以避免愈多債。先前討論過分析真正的問題,像是軟體反應太慢,不見得要改善系統,可以從減少使用者等待的感覺著手。「 沒功能沒債」不是幹話,有時候是正解。

系統設計

假設我們已釐清需求進入設計階段,此時進入如下圖 B1~ B3 的循環:

流程圖的概念: 和 FrogAnthony 討論先前文章時得出的
  1. 設計物件相依圖 (Object Dependency Graph): 分析有那些物件和它們如何互動。在有 first-class function 的語言裡,「物件」不見得是物件,也可以是函式
  2. 決定如何置換相依物件: 測試是確保功能正確的必要步驟。《人月神話》提到組件測試和單元測試占了 1/4 時間,透過置換相依物件,可減少測試成本。參見先前文章內的「置換 SUT 相依的物件」了解相關資訊。
  3. 評估測試成本。

評估測試成本後,若覺得成本太高,下一步可能是:

  • 認定設計有問題,回到 B1。
  • 回到 A 重新評估值得此時做此功能嗎?例如,原先認為只花一天就值得做,現在發現要一週,也許該延到下個版本再作,或是換個作法實現需求。

若評估測試成本OK,可以進入實作階段。

實作包含寫產品和測試用的程式碼。那麼,該先寫還是後寫測試呢?其實都可以,不過經過前面的分析流程後,此時先寫測試應該滿順的,可以減少手動測試時間。如果寫一寫發現比原本預想的難測,要退回 B1。

以下用一個例子討論為什麼這個流程可以減少不必要的技術債。

實例討論: 檔案同步

想像我們要實作一個檔案同步系統,可能是像 BT 的 P2P 系統,或是像 NAS 只和單一電腦或伺服器同步。

我們來試著走一次 B 的循環。首先,遵循 Keep It Simple and Stupid 原則,只用一個物件 Syncer 實現功能:

沒什麼好替換的,直接分析測試成本,然後發覺…

天啊,要準備多台機器才能測!

這樣太痛苦了,多數人會放棄測試,手動跑跑交差,然後甩鍋給 QA。結果 QA 發現簡單的 bug,工程師重製和除錯的成本都高,導致雙輸的局面。

解決之道是隔離外部 I/O: 引入新的物件 Storage 和 Connection:

為了方便測試時替換 Storage 和 Connection,用 interface 而非 class 表示兩者。於是:

  • 測試碼用假的 Connection 就不用準備和本機同步的機器。
  • 測試碼用假的 Storage 就不用讀寫檔案,簡化準備測資流程。

副作用: 切出 Connection 順便分開了應用層和網路層。日後需要修改網路協定,不用擔心影響到網路層以外的程式,「意外地」避開一筆技術債 (應用層和網路層的耦合)。

準備測試和比對結果很瑣碎

測試時要在 Storage 準備輸入,在 Connection 攔截輸出,比對結果是否符合預期。這樣還是頗累的,寫一兩個測資就會交差了事。然後 QA 測出簡單 bug,工程師重製和除錯的成本高,又落入雙輸的局面。

延續隔離的想法,這回要隔離核心演算法: 加入 DiffPatcher 專作 Diff 和 Patch:

測核心演算法只需準備 DiffPatcher,以及用字串表示的測資和答案。附帶一提,這裡自然地用了 Strategy Pattern。

副作用: DiffPatcher 的實作用 rsyncLCS 或其它作法不影響其它部份程式。由於測試容易,同樣開發時間下可以有更全面的測試,日後要強化核心演算法時,更有信心不會改壞,「意外地」避開一筆技術債 (核心演算法測試品質不足)。

例外狀況太複雜

即使現在沒察覺例外狀況,之後 QA 也會回報測試同步機器斷線再重連線的 bug,又落入雙輸的局面。雖然引入 Connection 後,不用拔線重製 bug,可以用測試碼關閉和重連假的 Connection ,但 Syncer 和假 Connection 互動過程還是頗複雜的。硬寫下去也是寫一兩個就草草交差,而且很難維護,測試碼也成為技術債。

讓我們仔細分析一下 Object Dependency Graph: 因為斷線重連的邏輯寫在 Syncer 裡,所以測試需要準備 DiffPatcher、Storage 和 Connection。能否像處理核心演算法一樣,隔離例外處理呢?

於是有了 Scheduler:

Scheduler 是個狀態機 (state machine),Syncer 和它說連線機器檔案樹的狀態、之前同步的資訊 (例如上次要求同步的時間) 還有連線狀態等。Scheduler 收到新資訊後回應要做什麼事,例如要向哪台機器抓什麼檔案。有了 Scheduler 後,「斷線後重新同步」的問題變成「Scheduler 排程」的問題。

若不易理解 Scheduler 的角色和實作方式,可以想像 Scheduler 是顧問,它不知道事情怎麼發生,只關心簡報內容; 它不動手執行,只打嘴炮說要怎麼做。看起來好像很廢,但是它的內部邏輯可能會很複雜。

那麼,測試時要準備的物件有那些呢?答案是: 只有 Scheduler!

副作用: 網路例外的狀況層出不窮,上線後即使看 log 明白前因後果,也不易重製驗證解法 (例如: 要怎麼模擬 timeout?)。引入 Scheduler 後,可以將 log 的結果轉成 Scheduler 輸入資訊,模擬多則事件的方式只是呼叫幾個 Scheduler 的 setter。「意外地」避開一筆技術債 (複雜的例外處理和其測試碼)。

測試執行時間太久

Scheduler 會依時間作不同決定,雖然單元測試簡單,但執行結果緩慢。解法和前面思路一樣: 隔離時間,也就是引入 Clock 回答現在時間,而非用系統的時間函式。如此一來,想測十秒後會要求重作操作,只需改 Clock 內部的值,不用真的等十秒。

那 Clock 應該放那裡好呢?第一個想法如下圖所示: 既然是 Scheduler 用的,將 Clock 封裝在 Scheduler 內:

用 interface 表示 Clock,測試時可置換假的 Clock。然後評估測試成本: 測試時除 Scheduler 外,多準備一個 Clock 即可,看起來不錯。

然而,若用下圖的結構:

Syncer 告知 Scheduler 資訊時,會從 Clock 取得目前時間一併傳給 Scheduler。測試 Scheduler 的成本和以前一樣,連 Clock 都不用準備。只有作 integration test 時才需準備假的 Clock。附帶一提,這裡自然地用了 Mediator Pattern。

副作用: 若有其它物件也需使用時間,可用同一個 Clock。減輕日後重構測試碼的成本。「意外地」避開一筆技術債。

說好的三分鐘呢?這樣分析完都三小時了!

實際上搞不好是三天!因為開發的時候常發覺沒考慮的事,然後退回設計和評估測試成本的階段。

經驗累積後,會提早在分析循環時導出適當的 dependency graph。像是隔離 I/O 已是常識,看到傳入檔名到函式就會知道有問題,反射性修正成 Reader / Writer 之類的介面。之後每個問題能很快找到修正 dependency graph 的模式。

再者,依《人月神話》提到組件測試和單元測試占了 1/4 時間,開發只占 1/6。可以想成將後面測試除錯的時間提前用在設計開發階段,整體開發時間其實沒變更久。

不用太在意一開始要完全作對,重要的是明白「在設計階段考慮如何測試」是對的方向,實踐困難是目前功力不足。持續學習,能作多少算多少。

結語

了解測試是產品價值的一部份,在設計階段思考測試成本 (可測性),可以避開一些技術債,對工程師和 QA 是雙贏。

物件相依圖 (Object Dependency Graph) 有助於分析測試成本。相較於一些分析或設計的方法論,「如何測試」提供明確回饋,知道每個改變解決了什麼問題。每個設計決定都是取捨,空泛地為設計而設計,不易確定帶來的影響,有可能 over design 而增加技術債。

相關文章

--

--