🎯 什麼情境該想到我
當你「想改的邏輯跟一堆礙事的依賴(例如 UI 相關的函式與類別)攪在同一個類別裡,光是把它建起來就辦不到」的時候。
⚙️ 怎麼用(步驟)
書先界定適用邊界:有些類別裡面的問題依賴並不多;如果這些依賴被包含在少數幾個方法中的話,你可以採用子類化並重寫(314 頁)手法來將它們解決掉。但如果依賴猖獗,則這條路可能就行不通了,這時你可能就需要動用介面提取技術,重複運用介面提取來解除對某些特定型別的依賴。依賴下推則是另一個選擇——該手法能夠將目標類別其他部分的問題依賴分離出來,使你能夠更容易地在測試用具中將它實例化(p.307)。
做法概述(p.308):在使用依賴下推技術時,**首先把目標類別設為抽象類別,然後建立一個它的子類別,後者便是你新的產品類別了。接著將所有的問題依賴都「下推」到這個子類別中。**到這一步,你便可以透過子類化原類別來讓它的有關方法接受測試了。
書中的例子是一個 C++ 的交易驗證類別:想修改它裡面的驗證邏輯就會遇到麻煩,因為「我們可不希望將 UI 相關的函式和類別牽扯到測試用具中來」(p.308)。把 UI 相關的工作下推到一個新的子類別之後,就可以再建立另一個測試用的子類別,後者只需實作一個空的顯示訊息方法即可(p.309)。
書中列的步驟(p.310):
- 在測試用具中建構目標類別。
- 找出哪些依賴是導致建構問題的依賴。
- 建立目標類別的子類別,子類別的名字須反映上一步找出的依賴的特徵。
- 將目標類別中的依賴變數和方法全部複製到新建的子類別中,注意保持簽名;將目標類別中的相應方法設為受保護及抽象的;將目標類別設為抽象的。
- 建立目標類別的一個測試子類別,修改你的測試,實例化該測試子類別。
- 建立測試來驗證你的確能實例化這個新的測試子類別。
★★ 在 Go 裡:概念完全成立,只是機制不同
★ 原手法:★★ 把礙事的依賴「下推」到一個新的子類,★ 讓基類變得可測
→ ★ Go 沒有繼承,★★ 但「把依賴推到一個可替換的位置」這個想法完全適用
// ★ 原始問題:一個型別同時有純邏輯與外部依賴
type Reporter struct{ db *sql.DB; smtp *smtp.Client }
func (r *Reporter) Monthly(ctx context.Context, m Month) error {
rows, _ := r.db.Query(…) // ★★ 依賴一
report := buildReport(rows) // ★ 純邏輯(★ 你真正想測的)
return r.smtp.Send(report) // ★★ 依賴二
}
// ✅ ★ Go 的「下推」:把兩個依賴推成介面欄位
type Reporter struct {
data dataSource // ★ 未匯出介面,定義在這個 package
mailer mailer
}
// ★★ 而 buildReport 本來就是純函式 ⇒ ★ 它不需要任何解依賴★★ Go 的關鍵優勢:★ 「下推」不需要新建型別,只需要換一個欄位型別。
★ Feathers 的做法:★★ 新建一個子類、把依賴移進去、改所有 new 的地方
★ Go 的做法: ★★ 把 *sql.DB 換成一個 interface ⇒ ★ 零風險(隱式滿足)
→ ★ 真正的實作一行都不用改,★★ 它自動滿足新介面(見 [[介面提取]])
★★ 更徹底的做法:把依賴推到「呼叫端」
// ★★ 最乾淨的形態:★ 型別完全不持有依賴,由呼叫端傳入
func Monthly(ctx context.Context, rows []Row, m Month) (Report, error) // ★ 純函式
// → ★ 查資料與寄信由 [[服務層]] 負責
// → ★★ 這就是 [[拆分階段]] 的「純核心 + 命令式外殼」★ 判準:這個依賴「每次呼叫都一樣」還是「每次呼叫可能不同」?
每次都一樣(db、mailer)→ ★ struct 欄位。
每次可能不同(tx、request-scoped 的東西)→ ★★ 方法參數。
★★ 把
tx放 struct 欄位是一個常見錯誤:★ 那讓那個 struct 變成
「per-request 的」,而它通常被當成單一實例共用(見 表格資料閘道)。
★ 下推之後的檢查:依賴方向有沒有變乾淨
# ★ 領域層不該依賴任何基礎設施
go list -deps ./internal/domain/... | grep -E "database/sql|net/http" && echo "★★ 依賴方向錯了"
# ★ 或用 depguard 把它變成 CI 紅燈(見 [[分離介面]])🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
書在示範完之後給了明確的自我評價(p.310):
-
「如此一來我們便有了一個可測試但同時又不依賴於任何 UI 相關事物的類別。那麼,在這個例子中使用繼承是否為理想的方案呢?不是的,但它最大的好處就是能幫助我們將目標類別的一部分邏輯納入測試。」
-
它是起點而非終點:一旦有了對該類別的測試,便可以開始清理顯示訊息方法裡面的重試邏輯,並將其從那個 UI 子類別提升到基底類別中來;然後,當該 UI 子類別最終被掏空得只剩 UI 相關的呼叫時,我們便可以轉向委託式的設計了——即將 UI 相關呼叫委託給一個 UI 依賴的新類別(p.310)。
-
不是萬用起手式:依賴少而集中時,書要你先用子類化並重寫(314 頁);依賴猖獗到某些特定型別上時,也可以重複運用介面提取。依賴下推是這兩條路之外的另一個選擇(p.307)。
-
步驟裡藏著保命細節:搬動時要保持簽名(p.310 步驟 4),且第 6 步要求另外寫一個測試來驗證「你的確能實例化這個新的測試子類別」——這一步本身就是這個手法的成敗判準。
-
與《重構》的分界:外觀上像在整理繼承體系,但動機是把礙事的依賴掃進一個新子類別,好讓抽象基底類別能在測試用具裡被實例化。書明說用繼承在這裡並非理想方案,只是它能換到「把一部分邏輯納入測試」;設計上的正解(委託)要等測試就位以後才做。
-
★★ 概念完全成立,機制不同:Go 的「下推」是「把
*sql.DB換成 interface」,零風險(隱式滿足),真正的實作一行都不用改。 -
★★ 判準:這個依賴每次呼叫都一樣(→ struct 欄位)還是可能不同(→ 方法參數)?
-
★★ 把
tx放 struct 欄位是常見錯誤:那讓 struct 變成 per-request,而它通常被當成單一實例共用。 -
★ 最乾淨的形態是把依賴推到呼叫端,型別完全不持有依賴(純函式 + 服務層 負責 I/O)。
-
★ 下推後要檢查依賴方向:
go list -deps ./internal/domain/...不該出現基礎設施套件。
🔗 相關工具
- 工具-打破依賴以便測試 — 這條技術屬於的大類
- 特性提升 — 方向相反的姊妹技術:特性提升把「想測的東西」往上搬,依賴下推把「礙事的依賴」往下推
- 子類化並重寫方法 — 依賴少且集中在少數幾個方法時,書要你先走這條路(p.307)
- 介面提取 — 依賴猖獗時的另一選項:重複運用它來解除對某些特定型別的依賴(p.307)
- 樸素化參數 — 同樣是「暫時讓設計變差以換取可測性」的一族手法
- 回連 修改程式碼的藝術
- 特性提升 —— 相反方向的姊妹手法
- 介面提取 —— ★ Go 裡零風險的下推機制
- 參數化方法 —— 把依賴推到呼叫端
- 拆分階段 —— 純核心 + 外殼
- 分離介面 —— 用 depguard 檢查依賴方向
- 表格資料閘道 —— tx 該怎麼傳