2026年10月13日 星期二

用最簡單的範例說明,什麼是 Dependency Injection?

 這是一份為你快速掌握 Dependency Injection (DI,依賴注入) 核心精髓的重點整理。不囉嗦、不搬大部頭理論,我們直接從「為什麼需要它」到「怎麼實作」一次看懂。


1. 什麼是 Dependency Injection (DI)?

簡單來說,DI 是一種設計模式,用來解除物件之間的緊密耦合 (Tight Coupling)。

  • 白話文解釋:
    當一個類別(例如 Car)需要另一個類別(例如 Engine)才能運作時,傳統作法是讓 Car 自己去 new 一個 Engine。這就像是一輛車把引擎直接焊接死在車體裡,壞了很難換,也沒辦法測試。
    而 DI 的概念是:引擎在外部組裝好,然後「注入」到車子裡。車子只需要一個介面,不管你是V8引擎、電動馬達還是腳踏車鍊條,只要能裝進去就能跑。

2. 傳統寫法 vs. DI 寫法(為什麼需要它?)


❌ 傳統寫法(緊密耦合)

C#
public class Car {
    private V8Engine _engine;

    public Car() {
        // Car 自己負責建立相依物件,綁死在 V8Engine 上
        _engine = new V8Engine(); 
    }

    public void Drive() {
        _engine.Start();
    }
}

  • 缺點:

    1. 難以測試:如果想寫單元測試驗證 Car,被迫一定要用到真正的 V8Engine。

    2. 缺乏彈性:今天如果想換成 ElectricEngine,就必須修改 Car 原始碼。

✔️ DI 寫法(鬆散耦合 + 依賴反轉)

透過 介面 (Interface) 和 建構子注入 (Constructor Injection) 來達成:

C#
// 1. 定義介面
public interface IEngine {
    void Start();
}

// 2. 具體實作
public class V8Engine : IEngine {
    public void Start() { Console.WriteLine("V8 引擎發動轟隆隆!"); }
}

public class ElectricEngine : IEngine {
    public void Start() { Console.WriteLine("電動馬達安靜啟動。"); }
}

// 3. 透過建構子注入
public class Car {
    private readonly IEngine _engine;

    // 依賴抽象的 IEngine,而不是具體的引擎實作
    public Car(IEngine engine) {
        _engine = engine; 
    }

    public void Drive() {
        _engine.Start();
    }
}

  • 使用方式:

    C#
    // 在外部(呼叫端)決定要注入哪一種實作
    IEngine engine = new ElectricEngine(); 
    Car myCar = new Car(engine); // 注入!
    myCar.Drive();
    


3. 三種主要的注入方式


在實際開發中,將依賴傳遞進去的方法主要有三種:

  1. 建構子注入 (Constructor Injection) ⭐ (最推薦、最常用)

    • 作法:透過類別的建構子傳遞依賴。

    • 優點:物件建立時就必須具備所有必要依賴(保證物件狀態完整),且通常配合 readonly 可以做到不可變 (Immutable)。

  2. 屬性/setter 注入 (Property / Setter Injection)

    • 作法:透過公開的屬性或 Setter 方法指派依賴。

    • 優點:依賴項是選填的,可以在物件建立後隨時替換。

  3. 方法注入 (Method Injection)

    • 作法:直接在方法的參數中傳入需要的依賴。

    • 優點:只在特定方法的執行瞬間才需要該依賴時使用。


4. 搭配 DI Container (IoC 容器) 自動化


當專案變大,手動 new 這些物件並層層組裝會變得很麻煩(例如 A 依賴 B,B 依賴 C...)。這時現代框架提供的 IoC (Inversion of Control) 容器 就派上用場了。

  • 核心概念:你只需要在程式啟動時告訴容器「介面該對應到哪個實作」,框架就會在需要時自動幫你 new 並注入。

  • 生命週期管理 (常見的三種設定):

    • Transient (短暫/每次建立):每次要求都給一個全新的實例。

    • Scoped (範圍):在同一個請求 (Request) 週期內共用同一個實例。

    • Singleton (單例):整個應用程式生命週期只會建立一次。


5. DI 的核心好處總結


  • 極佳的可測試性 (Testability):單元測試時可以輕易傳入 Mock / Stub 物件(例如測試 Car 時傳入一個假的 MockEngine)。

  • 高可維護與擴充性 (Maintainability):抽換底層實作(例如把 MySQL換成 PostgreSQL,或是串接不同的 API)時,只需改動注入的對象,原有業務邏輯程式碼完全不用動。

  • 遵循 SOLID 原則:完美實踐「依賴反轉原則 (Dependency Inversion Principle, DIP)」。



這裡補上 屬性注入 (Property / Setter Injection) 與 方法注入 (Method Injection) 的程式碼範例,並為你分析在什麼樣的實務情境或重構狀況下,會選擇這兩種注入方式。

1. 程式碼範例

我們繼續沿用剛才的 IEngine / Car 情境:

🔹 屬性注入 (Property Injection)

屬性注入是透過公開的 Property (或 Setter 方法) 來指派依賴項。

C#
public class Car {
    // 依賴項提供預設值(非必要),或者允許外部隨時設定/替換
    public IEngine Engine { get; set; }

    public void Drive() {
        if (Engine == null) {
            throw new InvalidOperationException("未裝載引擎,無法發動!");
        }
        Engine.Start();
    }
}

// 使用方式:
Car myCar = new Car();
myCar.Engine = new ElectricEngine(); // 透過 Property 注入
myCar.Drive();


🔹 方法注入 (Method Injection)

方法注入只在「特定的方法呼叫時」,將依賴項作為方法參數傳進去。

C#
public class Car {
    // Car 類別本身不儲存 Engine 的狀態/欄位
    
    // 只在執行特定動作時傳入所需的依賴
    public void Drive(IEngine engine) {
        if (engine == null) {
            throw new ArgumentNullException(nameof(engine));
        }
        engine.Start();
    }
}

// 使用方式:
Car myCar = new Car();
IEngine engine = new ElectricEngine();
myCar.Drive(engine); // 透過方法參數注入


2. 什麼情況下該把緊密耦合的程式碼重構成這兩種模式?


在實際重構中,建構子注入(Constructor Injection) 是 80%~90% 情境下的最佳首選(因為它能保證物件建立時所有必要元件都準備完畢)。

但當遇到以下特定情境時,重構成「屬性注入」或「方法注入」會是更好的選擇:

💡 情況一:適合重構成「屬性注入」的情境


  1. 依賴項是「可有可無」的(Optional Dependencies)

    • 情境:例如一個日誌記錄器 (ILogger) 或快取模組 (ICache)。如果外部沒傳進來,程式依然可以正常運作(使用內建預設的 NullLogger 或不做快取),不需要強迫使用者在 new 的時候一定要傳入。

  2. 舊程式碼物件龐大、建構子極度臃腫(Legacy Code 重構)

    • 情境:遇到數千行的上帝類別 (God Object),建構子已經帶了 10 幾個參數。如果直接改建構子,會導致全專案呼叫到該類別的地方通通報錯。

    • 重構策略:先透過「屬性注入」逐步替換掉內部硬寫死的 new,避免大範圍破壞現有的調用代碼,待未來徹底重構時再收攏至建構子。

  3. 依賴項需要在物件生命週期中「動態更換」

    • 情境:例如遊戲中的玩家角色或車輛,在執行過程中可能會從「燃油模式」動態切換成「純電模式」或「無人駕駛模式」,這時透過 Property 來進行動態替換非常直覺。

  4. 框架限制或不支援建構子注入

    • 情境:在某些舊型 Web 框架(如舊版 ASP.NET Web Forms、WPF XAML 頁面或某些 Mobile UI 頁面)中,UI 視窗/頁面的物件是由框架自動創立且強制要求「無參數建構子」,此時只能藉由屬性注入將服務帶入。

💡 情況二:適合重構成「方法注入」的情境


  1. 整個類別中「只有一個方法」需要這個依賴項

    • 情境:假設 Car 類別有 20 個方法都在做日常車務運作,只有 1 個 PerformDiagnostic()(車輛檢測)方法需要用到巨大的 IDiagnosticTool。

    • 為什麼不用建構子注入:如果放在建構子注入,代表每一個 Car 物件從建立到銷毀,都要永久持有這個只用一次的巨大診斷工具,白白浪費記憶體與生命週期資源。

  2. 每次呼叫方法時,傳入的依賴項狀態/上下文(Context)都不同

    • 情境:例如訂單處理器的 ProcessOrder(Order order, IPaymentGateway paymentGateway) 方法。每次呼叫該方法時,傳進來的支付網關(信用卡、LINE Pay、Apple Pay)可能根據顧客選擇而完全不同。

    • 優點:保持 Car 或服務物件本身無狀態 (Stateless),提高 Thread-safety(執行緒安全)。

  3. 防止循環依賴 (Circular Dependency) 的臨時重構方案

    • 情境:當 ServiceA 依賴 ServiceB,而 ServiceB 又需要 ServiceA 時,建構子注入會造成死結無法建立物件。

    • 重構策略:將其中一方的特定呼叫改成方法注入(例如 serviceB.DoSomething(this)),即可立刻解開死結。


3. 三種注入方式快速對照表


注入方式依賴的性質適用時機建議使用頻率
建構子注入必要 (Mandatory)物件缺少該依賴就無法運作;追求物件不可變性與安全性。⭐⭐⭐⭐⭐ (預設首選)
屬性注入可選 (Optional)有預設實作、舊碼過渡重構、框架受限無參數建構子、需動態替換。⭐⭐ (特定情境使用)
方法注入單次/暫時 (Transient)僅單一方法需要使用、傳入參數具上下文差異、避免長期佔用資源。⭐⭐⭐ (視方法情境使用)

沒有留言:

張貼留言

有 Creational Patterns 的原因,是否是有些狀況不適合直接用 new 建造出一個物件來用?

  沒錯!您的直覺非常準確,這正是整個創建型模式(Creational Patterns)核心要解決的根本問題。 在軟體開發中,直接使用 new 關鍵字來建立物件(例如 MyClass obj = new MyClass(); )雖然直覺又方便,但在中大型系統或需要高度擴充性的...