很多舊系統其實還能改,只是大家已經不敢輕易動它。
網站、會員系統、課程平台、WordPress 外掛、內部後台,只要運作時間夠久,就可能累積大量條件判斷、第三方服務、特殊例外與前人留下來的做法。表面上功能還在跑,但每次要修一個小問題,都擔心另外三個地方一起壞掉。
Michael Feathers 在《Working Effectively with Legacy Code》處理的,就是這種情況:系統正在營運、沒有足夠測試、又不能停下來全部重寫時,怎麼安全地繼續改。
Legacy Code 不一定是老程式,也不一定寫得很差
很多人聽到 legacy code,第一反應是「十年前的老系統」或「程式很亂」。Feathers 給了一個更容易採取行動的定義:沒有測試保護的程式碼。
這個定義的價值,在於把焦點從「程式看起來漂不漂亮」轉到「改動時有沒有快速回饋」,不需要先爭論程式到底算不算寫得差。
一段程式就算寫得很整齊,如果改完只能靠人工到處點、靠記憶確認,很難快速知道自己有沒有破壞既有行為。反過來,一段很老的程式如果有可靠測試保護,修改風險反而可能比較低。
最麻煩的是 Legacy Code Dilemma:要測試,卻得先改程式
遺留系統常遇到一個矛盾:
- 要安全修改,最好先有測試。
- 但程式現在耦合太深,根本放不進測試環境。
- 要讓它能測,通常又得先動到原本程式。
這就是 Feathers 所說的 legacy code dilemma。
比較安全的解法,是把前置修改壓到最小,只做足以建立測試入口的依賴拆解;一旦有安全網,再開始功能修改與重構。
安全修改的核心流程:先取得回饋,再改行為
可以把方法整理成五個步驟:
- 找到 Change Point:你這次要修改哪一段程式。
- 找到 Test Point:在哪裡最容易觀察這個改動造成的效果。
- Break Dependencies:只拆掉阻礙測試的必要依賴。
- Characterization Tests:先把目前實際行為固定下來。
- Make the Change:最後才改功能,再逐步重構。
這個順序很重要。沒有測試時直接重構,等於一邊換引擎、一邊猜車子還能不能開。
Characterization Test:先記錄現在真的怎麼跑
一般單元測試常從需求出發,問「這段程式應該怎麼做」。Characterization Test(特徵化測試)先問另一個問題:
這段舊程式現在實際上到底怎麼做?
例如一個舊訂單函式,理論上你覺得退款後應該回傳 0,但實際執行卻回傳 -1。做特徵化測試時,不會立刻把 -1 改成你認為正確的答案,而是先把目前行為記錄下來。
因為其他模組可能早就依賴這個「看起來不合理」的結果。先固定現況,再把 bug 修正當成另一個明確變更,風險會低很多。
特徵化測試的任務,是建立變更警報器
這也是特徵化測試和「補一套完美測試」最大的差別。
你不需要第一天就替整個舊系統補齊所有測試。比較實際的策略是:每次要改哪裡,就先保護那一條路徑。
今天修改付款,就先建立付款路徑的測試;下次修改會員權限,再補會員權限的安全網。
這樣測試會沿著真實變更慢慢長出來,比等一個「以後專門花三個月補測試」的計畫更有機會真的完成。
先分清楚 Sensing 和 Separation,才知道依賴要拆到哪裡
Feathers 把依賴問題拆成兩種。Separation 是「程式根本沒辦法被放進測試環境」,例如建構物件時一定會連正式資料庫;Sensing 則是「程式可以跑,但測試看不到結果」,例如結果只被寫進某個外部系統。
這兩種問題需要的處理不同。Separation 要先讓危險依賴可以被替換;Sensing 則要找到能觀察結果的接點。先判斷自己卡在哪一種,才能避免為了測一個方法,把整個系統拆得太深。
Seam:找到不必大改就能替換行為的位置
Feathers 很重要的一個概念是 Seam(接縫)。簡單說,就是你可以在不改那段核心程式的情況下,從別處替換它依賴的行為。
例如一段付款程式直接自己建立資料庫連線,測試時就很難控制;如果資料庫可以從建構子傳入,正式環境傳真實資料庫,測試環境則傳假的資料來源,這裡就形成了一個可測試的 seam。
常見可以形成 seam 的位置包括:
- 建構子參數。
- 方法參數。
- 介面。
- 可替換的服務物件。
- 模組匯入。
- 時間、設定與外部 API 的抽象層。
重點是找到最小修改就能讓測試接進來的位置,不需要為了測試到處加入抽象。
Dependency Breaking:先拆測試阻礙,不要順便大整理
舊程式常見測不起來的原因包括:
- 建構物件時就直接連資料庫。
- 到處使用 static 或 singleton。
- 方法裡直接呼叫第三方 API。
- 一個方法同時做查詢、計算、寫入與寄信。
- 框架物件一路滲進核心邏輯。
Feathers 提供很多 dependency-breaking techniques(依賴拆解技巧),例如 Parameterize Constructor、Extract Interface、Subclass and Override Method。
這些技巧的共同精神,是只做足以建立測試環境的最小改動;這時候還不適合順便大幅整理結構。因為這時候安全網還沒建立,改得越多,未知風險越大。
行為修改和結構整理,要分兩步做
最危險的做法之一,是在同一個修改裡同時改變商業規則、重新命名、拆方法、搬檔案、換資料存取方式。
如果最後測試失敗,你很難知道問題是功能改錯,還是結構調整時不小心改到行為。
更安全的順序是:
結構性調整 → 測試仍通過 → 行為修改 → 測試通過。
一次只做一件事,會讓問題更容易定位。
真的完全測不起來時,可以用 Sprout 或 Wrap
有些舊系統短期內就是無法放進測試環境。這時 Feathers 提出 Sprout(萌芽)與 Wrap(包覆)技巧。
Sprout Method / Sprout Class:不要把新邏輯塞進那個巨大、無法測試的舊方法,而是把新功能寫成一個新的、可測試的方法或類別,再讓舊程式只多一行呼叫。
Wrap Method / Wrap Class:如果你要在舊行為前後加入通知、紀錄、驗證等功能,可以包在外層,而不是深入舊邏輯裡大改。
這些做法屬於戰術:先把這次變更的風險控制住,再逐步改善周圍程式,不必把它們當成終極架構。
Golden Master:面對複雜輸出時,先把整體結果固定
如果舊程式會產生很長的報表、JSON、PDF 資料、匯出檔或複雜 HTML,一開始很難逐欄寫測試。
這時可以使用 Golden Master(黃金樣本)的概念:先保存目前一組代表性輸出的完整結果,修改後再比較差異。
它先建立一個低成本警報器,讓你知道「這次改動是不是連不該動的地方都改了」;之後仍可以逐步補成更細緻的測試。
Scratch Refactoring:先用重構理解舊程式,再全部還原
有些程式不是立刻要修改,而是你根本看不懂。Feathers 提到一種很實用的理解方法:先在暫時分支裡大膽 Extract Method、重新命名、拆類別,把原本結構整理到自己看得懂。
這一輪修改的目的只是學習,不一定要提交。理解完之後把變更全部還原,再根據剛剛得到的結構認知設計正式、安全的修改路徑。
這比一邊讀、一邊猜、一邊直接改正式程式更安全,因為你可以把「理解系統」和「修改系統」分成兩件事。
Effect Sketch:先畫出改動會影響哪裡
有時最大的風險,是你根本不知道這段程式會影響哪裡。
Feathers 建議用 Effect Sketch(影響草圖)追蹤:這個方法改變什麼資料、哪些地方會讀這些資料、結果最後會出現在畫面、資料庫、Email 還是 API,以及哪一個位置可以一次觀察很多上游行為。
如果能找到一個 Pinch Point(收斂點),少數幾個測試就可能覆蓋很大一片行為。這比一開始從第一行讀到最後一行更有效率。
時間真的很趕時,先做最小安全版本
真實專案常常不會給你兩週先整理測試。這時候不必把整套方法做滿,至少守住三件事:
- 把這次變更範圍縮到最小,不順便整理旁邊程式。
- 在最接近變更效果的位置留下一個可重複的驗證。
- 修改完成後,讓下一次再碰同一區域時比這次更容易測。
如果某個區域第一次修改時只能用 sprout 或手動黃金樣本保護,第二次再碰到它,就值得把這裡正式放進測試環境。這樣技術債會沿著實際需求逐步下降,不必等待一次大型專案才開始處理。
為什麼 Big-Bang Rewrite 常常不是第一選擇?
遇到很亂的舊系統,很自然會想:「乾脆重寫比較快。」
問題是老系統裡通常藏著很多沒有被文件記錄的規則,例如特殊客戶例外、歷史資料格式、第三方服務怪異限制、退款與權限邊界,以及多年來修過的極端案例。
全部重寫時,這些知識很容易一起丟掉。
所以 Feathers 的方法更偏向「cover and modify」:沿著實際要改的地方補安全網,一點一點把可測試範圍擴大。
如果你的系統已經同時有速度、耦合與可讀性問題,也可以延伸看系統愈做愈慢、愈改愈亂?架構最佳化應該先從哪裡開始。
把方法放到 WordPress、外掛與 AI 生成程式也一樣適用
現在很多網站與內部工具是快速拼出來的:WordPress 外掛、Code Snippet、API 串接、AI 協助產生的 PHP、JavaScript、Python。
程式能跑,不代表之後就能安全改。
如果某個片段已經處理訂單、會員、付款或內容權限,下一次再改之前,可以先確認目前哪些輸入會得到哪些結果、建立最小測試或可重複驗證方式、把最難測的外部依賴隔開,再只修改一個行為。
這比每次都叫 AI「整段重寫得更漂亮」安全得多。
一套可以直接採用的 Legacy Code 修改流程
| 步驟 | 要做什麼 |
|---|---|
| 1. 定義變更 | 只寫清楚這次要改哪一個行為 |
| 2. 找 Change Point | 定位這次要改的程式 |
| 3. 找 Test Point | 找最容易觀察結果的位置 |
| 4. 最小拆依賴 | 只做到能放進測試環境 |
| 5. Characterize | 把目前行為固定下來 |
| 6. 修改 | 只改這次需要的行為 |
| 7. Refactor | 測試綠燈後再整理結構 |
| 8. 留下安全網 | 讓下次改同一區域成本更低 |
什麼情況不用把整套流程做得很重?
不是每次改字串、CSS 或完全獨立的小功能,都需要做完整 legacy-code 儀式。
這套方法特別適合:
- 修改會影響付款、會員、權限與資料的程式。
- 大家都不敢碰的舊模組。
- 沒有測試、又曾經改一處壞三處的程式。
- 要加入新功能,但不確定現有行為的區域。
- 無法直接大改或停機重寫的營運系統。
風險低、可快速回復的修改,可以使用更輕量的驗證;風險越高,越值得先取得回饋再改。
常見問題
Legacy Code 就是老舊程式嗎?
Feathers 的工作定義更重視是否有測試保護,而不是程式年齡。新寫的程式如果完全沒有測試,也可能很快成為難以安全修改的 legacy code。
Characterization Test 會不會把 bug 也固定下來?
有可能,這正是它的用途之一:先確認目前實際行為,再把 bug 修正當成獨立變更。否則你可能同時改掉其他人已依賴的行為。
是不是要先把整個舊系統補完測試才能開發?
不用。比較實際的做法是沿著這次要修改的路徑補測試,讓覆蓋率隨真實變更逐步成長。
Seam 和 Dependency Injection 是同一件事嗎?
不完全相同。Dependency Injection 是建立 seam 的常見方法之一;seam 的概念更廣,重點是存在一個可從外部替換行為的接點。
舊系統太亂,直接重寫是不是比較快?
有時重寫有必要,但不能只因程式看起來亂就假設重寫風險更低。原系統可能累積大量隱性規則,最好先盤點行為與替換風險。
AI 可以直接幫我把 Legacy Code 重構掉嗎?
AI 可以協助理解、產生測試、提出重構方向與執行局部修改,但安全順序仍然重要:先固定既有行為、縮小變更範圍,再讓 AI 幫你改,會比直接整段重寫可靠。
安全修改 Legacy Code 的關鍵,是讓每次變更都比上一次更容易
《Working Effectively with Legacy Code》最有價值的地方,是提供一套能在真實營運系統裡逐步前進的方法,不必先把所有舊程式一次洗乾淨。
先找變更點,再找測試點;必要時只拆最小依賴,用 characterization test 固定目前行為,最後才修改功能。每次動一個區域,就替那個區域多留下一點安全網。
當安全網慢慢增加,原本「誰都不敢碰」的程式,就會逐步變成可以理解、可以驗證,也可以繼續演進的系統。
參考資料
Michael C. Feathers:Working Effectively with Legacy Code,Pearson/Prentice Hall,2004。
Martin Fowler:Refactoring: Improving the Design of Existing Code。
