iT邦幫忙

2025 iThome 鐵人賽

DAY 20
0

https://ithelp.ithome.com.tw/upload/images/20250903/20124462P1N8QjGguI.png

消除你程式碼的臭味 Day 20- 依賴注入:鬆開那個耦合

你的類別還在自己 new 東西嗎?
當一個類別手動自己建立它所依賴的物件時,它就像一個什麼都要自己來的控制狂,最後會和具體的實作綁死,發生強耦合的臭味。

High-level modules should not depend on low-level modules. Both should depend on abstractions.
高階模組不應依賴低階模組,兩者都應依賴於抽象。

依賴注入 (Dependency Injection, DI) 正是實現此原則的優雅手段,達成控制反轉 (Inversion of Control, IoC) 也就是把「建立依賴」的控制權,從類別內部反轉到外部。

經典案例:泡一杯茶,看出你的控制慾

用「泡茶」這個簡單的流程,來理解這一切。

泡茶的 SOP:1. 燒開水 -> 2. 準備茶葉 -> 3. 沖泡 -> 4. 加入調味料。

其中,「燒開水」和「沖泡」是固定的主要流程,但「準備什麼茶葉」和「加什麼調味料」應該是彈性多變的。

🔴 臭味:寫死的依賴

一位「控制狂」 TeaMaker 什麼都想自己來。
它自己決定要用什麼茶葉、加什麼調味料。

這會導致什麼後果?
想換個口味,唯一的辦法就是直接修改 TeaMaker 的程式碼。

class BlackTea { getName = () => "錫蘭紅茶"; }
class Sugar { getName = () => "蔗糖"; }

class TeaMaker {
    makeBlackTeaWithSugar() {
        this.boilWater();
        
        // 問題的根源:自己動手 new 依賴,將自己與「紅茶」、「糖」綁死
        const teaLeaves = new BlackTea();
        const condiment = new Sugar();
        
        this.brew(teaLeaves);
        this.addCondiment(condiment);
        console.log(`一杯香甜的${teaLeaves.getName()}加${condiment.getName()}完成了!`);
    }

    // 想換口味?只能複製貼上,不斷增加新方法...
    // makeGreenTea(), makeOolongWithMilk()... 程式碼開始腐壞
    
    boilWater() { ......}
    brew(teaLeaves) { ...... }
    addCondiment(condiment) { ....... }
}

上面每一次的 new,都是在把 TeaMaker 和特定的茶葉、特定的調味料綁得更緊,極度缺乏彈性。

想喝「綠茶加牛奶」?
你只能再去加一個 makeGreenTeaWithMilk 的函式,還必須修改 TeaMaker 的程式碼。
https://ithelp.ithome.com.tw/upload/images/20250922/20124462foM6k0Jxtz.png

🟢 好味道:依賴由外部注入,擁抱彈性

一個專業的 TeaMaker (吧台師傅),應該專注於「泡茶」的技藝,並依賴一個「抽象」的契約,而不是具體的「實作」

至於客人想用什麼茶葉、加什麼調味料,應該由「客人」(外部調用者)決定並提供給他。

我們會明確定義 ITeaLeaves 這樣的介面 (Interface)。
在 JavaScript 中,我們則依賴鴨子型別 (Duck Typing) —— 只要傳入的物件「看起來像茶葉」(也就是有 .getName() 方法),我們就視它為合法的依賴。

/**
 * interface ITeaLeaves { getName(): string; }
 * interface ICondiment { getName(): string; }
 */

// TeaMaker 不再控制依賴的產生,只專注於「泡茶」的核心流程
class TeaMaker {
    // 這個方法乾淨專注、好擴充
    // 它不在乎傳進來的是什麼茶葉、什麼調味料。
    // 只要它們符合我們期待的「抽象」(有 getName 方法),就能完美協作。
    makeTea(teaLeaves, condiment = null) {
        this.boilWater();
        this.brew(teaLeaves);
        if (condiment) {
            this.addCondiment(condiment);
        }
        
        const teaName = teaLeaves.getName();
        const condimentName = condiment ? `,佐以 ${condiment.getName()}` : "";
        console.log(`一杯為您客製的 ${teaName}${condimentName} 已經準備好了!`);
    }
    
    boilWater() { console.log("燒開水中..."); }
    brew(teaLeaves) { console.log(`使用 ${teaLeaves.getName()} 進行沖泡...`); }
    addCondiment(condiment) { console.log(`加入調味料:${condiment.getName()}...`); }
}

在外部,可以像樂高一樣自由組合,準備各種具體的「零件」。

// 「零件們」
class BlackTea { getName = () => "錫蘭紅茶"; }
class GreenTea { getName = () => "龍井綠茶"; }
class OolongTea { getName = () => "阿里山烏龍"; }
class Sugar { getName = () => "蔗糖"; }
class Milk { getName = () => "香醇牛奶"; }

const teaMaker = new TeaMaker();

// 客人 1: 我要紅茶加糖
teaMaker.makeTea(new BlackTea(), new Sugar());
// > 一杯為您客製的 錫蘭紅茶,佐以 蔗糖 已經準備好了!

// 客人 2: 我要綠茶加牛奶
teaMaker.makeTea(new GreenTea(), new Milk());
// > 一杯為您客製的 龍井綠茶,佐以 香醇牛奶 已經準備好了!

// 客人 3: 我只要一杯烏龍茶,謝謝
teaMaker.makeTea(new OolongTea());
// > 一杯為您客製的 阿里山烏龍 已經準備好了!

TeaMaker 類別本身完全不用修改,我們卻能創造出無限多種組合。

我們把「控制權」從 TeaMaker 內部反轉到了外部,這就是控制反轉 (Inversion of Control, IoC),而依賴注入 (Dependency Injection, DI) 是實現它的方法。
https://ithelp.ithome.com.tw/upload/images/20250922/20124462keJxpelRdy.png

深入探討:DI 旅程中的新問題

複雜性的轉移:誰來負責組裝?

當應用變得複雜時,TeaMaker 變乾淨了,但我們把組裝的複雜性轉移到了「外部調用者」,這正是DI 容器 (DI Container) 存在的理由。
在大型框架中,你只需要宣告一個類別需要哪些依賴,DI 容器就會在幕後自動分析依賴樹,並為你完成所有 new 和組裝工作。

https://ithelp.ithome.com.tw/upload/images/20250922/20124462uaC77Q4TNw.png

高度彈性的代價

DI 容器雖然強大,但也引入了新的挑戰。
依賴關係由容器在背後串連,降低了程式碼的直接追蹤

看著 Car 的建構子,不一定能立刻知道它會被注入哪個引擎實作。
這是一種用「可追蹤性」換取「高度彈性」的交易。

我們可以透過以下方式應對:

  1. 依賴現代工具:TypeScript 的靜態分析和現代 IDE 的輔助,大程度上彌補追蹤性的不足。
  2. 遵守團隊約定:建立清晰的模組化和命名約定,讓依賴關係變得可預測。
  3. 遵守單一組裝根 (Composition Root):將所有依賴的組裝邏輯,集中在應用程式啟動的單一位置,避免組裝邏輯散落各處。

2. 注入的時機:方法注入 vs. 建構子

DI 不只有一種方法,注入的「時機」決定了依賴的性質。
最常見的兩種是「方法注入」與「建構子注入」。

TeaMaker 範例使用的是「方法注入」,它適合「執行某個任務時才需要」的依賴。
那如果某個依賴是類別「存在就必須擁有」的呢?
這時,建構子注入 (Constructor Injection) 是更好的選擇。
它能確保物件在被建立的當下,就是一個完整的、有效的狀態。

class Car {
    // 一輛車被製造出來時,就必須要有引擎
    constructor(engine) {
        if (!engine) throw new Error("Car requires an engine to be created.");
        this.engine = engine;
    }
    
    start() {
        this.engine.ignite();
    }
}

// 確保了 myCar 從誕生起就是一個合法的物件
const myCar = new Car(new V8Engine());

選擇原則

  • 建構子注入:定義了「我是誰」(物件的身份與存在擁有條件)。
  • 方法注入:定義了「我做什麼」(執行某任務時需要的材料)。
    https://ithelp.ithome.com.tw/upload/images/20250922/20124462lD1JUBHqWD.png

3. DI :不是消滅 new,而是在對的地方 new

DI 的目標不是消滅 new,而是在正確的地方使用 new

問自己:「這個依賴在『測試環境』或『不同部署環境』下,有沒有可能需要被替換?」

應該「被」注入的依賴 (Inject):

  • 服務 (Services)PaymentService (因為測試時想換成假的支付服務)。

  • 儲存庫 (Repositories)UserRepository (因為測試時想換成記憶體資料庫)。

  • 外部依賴:資料庫連線、API 客戶端、Logger (因為測試時不想真的寫入 Log)。

  • 任何你想在測試中可以被替換 (Mock) 的東西

可以安全 new 的對象 (new):

  • 值物件 (Value Objects)new Date()new Money(100, 'USD') (它們穩定、內聚,除非你需要測試控制時間)。

  • 資料傳輸物件 (DTOs)new UserDTO(data) (它們只是單純的資料容器)。

  • 例外物件new Error('Something went wrong')

  • 不包含外部依賴、穩定且內部的工具類

擴展視野:DI 的邊界與替代方案

情境需求決定架構:何時不該用 DI?

對於生命週期短、需求極度穩定、或一次性的腳本,直接 new 的確更簡單高效。
DI 的威力體現在「應對變化」上,它的價值與專案的生命週期長度需求變更頻率成正比。
引入 DI 是一種對「未來可維護性」的投資,請確保你的專案需要這份投資回報。

DI 不是唯一解:與工廠模式的比較

你可能會想,我用工廠模式 (Factory Pattern) 也能解決耦合問題。
沒錯!工廠模式主要解決「如何建立物件」的複雜性,而 DI 更關注「如何將已建立的物件串連起來」。

DI(特別是建構子注入)的核心優勢在於明確性 (Explicitness)
它讓一個類別的依賴在其「合約 (contract)」(即建構函式) 中一目了然。
你一看就知道,這個類別沒有這些依賴就無法正常工作,這對程式碼的清晰度和可測試性是巨大的勝利。

今日重點

  • 停止在類別內部 new 外部依賴,改由外部傳入。

  • 依賴注入的本質是 控制反轉 (IoC),將建立物件的控制權交給外部。

  • 根據依賴的性質選擇注入方式:用「建構子注入」保證物件完整性,用「方法注入」提供任務彈性。

  • 依賴注入能大幅降低類別之間的耦合度,也轉移了複雜性:在大型應用中,需要「DI 容器」來自動管理依賴的組裝。

  • 解耦合能帶來三大好處:高彈性、高擴展性、高可測性。

寫死的依賴像強力膠,一體成形但無法拆換;而依賴注入則像樂高,結構穩固又能隨時拆解、替換與升級。

放開你的控制慾吧!

讓你的類別只專注於自己的主要職責,把依賴的控制權交出去。


上一篇
Day 19- 參數:少即是多,避免傳布林參數
系列文
消除你程式碼的臭味20
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言