這是一份為你快速掌握 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();
}
}
- 缺點:
- 難以測試:如果想寫單元測試驗證
Car,被迫一定要用到真正的V8Engine。 - 缺乏彈性:今天如果想換成
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. 三種主要的注入方式
在實際開發中,將依賴傳遞進去的方法主要有三種:
- 建構子注入 (Constructor Injection) ⭐ (最推薦、最常用)
- 作法:透過類別的建構子傳遞依賴。
- 優點:物件建立時就必須具備所有必要依賴(保證物件狀態完整),且通常配合
readonly可以做到不可變 (Immutable)。
- 屬性/setter 注入 (Property / Setter Injection)
- 作法:透過公開的屬性或 Setter 方法指派依賴。
- 優點:依賴項是選填的,可以在物件建立後隨時替換。
- 方法注入 (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% 情境下的最佳首選(因為它能保證物件建立時所有必要元件都準備完畢)。
但當遇到以下特定情境時,重構成「屬性注入」或「方法注入」會是更好的選擇:
💡 情況一:適合重構成「屬性注入」的情境
- 依賴項是「可有可無」的(Optional Dependencies)
- 情境:例如一個日誌記錄器 (
ILogger) 或快取模組 (ICache)。如果外部沒傳進來,程式依然可以正常運作(使用內建預設的 NullLogger 或不做快取),不需要強迫使用者在new的時候一定要傳入。
- 舊程式碼物件龐大、建構子極度臃腫(Legacy Code 重構)
- 情境:遇到數千行的上帝類別 (God Object),建構子已經帶了 10 幾個參數。如果直接改建構子,會導致全專案呼叫到該類別的地方通通報錯。
- 重構策略:先透過「屬性注入」逐步替換掉內部硬寫死的
new,避免大範圍破壞現有的調用代碼,待未來徹底重構時再收攏至建構子。
- 依賴項需要在物件生命週期中「動態更換」
- 情境:例如遊戲中的玩家角色或車輛,在執行過程中可能會從「燃油模式」動態切換成「純電模式」或「無人駕駛模式」,這時透過 Property 來進行動態替換非常直覺。
- 框架限制或不支援建構子注入
- 情境:在某些舊型 Web 框架(如舊版 ASP.NET Web Forms、WPF XAML 頁面或某些 Mobile UI 頁面)中,UI 視窗/頁面的物件是由框架自動創立且強制要求「無參數建構子」,此時只能藉由屬性注入將服務帶入。
💡 情況二:適合重構成「方法注入」的情境
- 整個類別中「只有一個方法」需要這個依賴項
- 情境:假設
Car類別有 20 個方法都在做日常車務運作,只有 1 個PerformDiagnostic()(車輛檢測)方法需要用到巨大的IDiagnosticTool。 - 為什麼不用建構子注入:如果放在建構子注入,代表每一個
Car物件從建立到銷毀,都要永久持有這個只用一次的巨大診斷工具,白白浪費記憶體與生命週期資源。
- 每次呼叫方法時,傳入的依賴項狀態/上下文(Context)都不同
- 情境:例如訂單處理器的
ProcessOrder(Order order, IPaymentGateway paymentGateway)方法。每次呼叫該方法時,傳進來的支付網關(信用卡、LINE Pay、Apple Pay)可能根據顧客選擇而完全不同。 - 優點:保持
Car或服務物件本身無狀態 (Stateless),提高 Thread-safety(執行緒安全)。
- 防止循環依賴 (Circular Dependency) 的臨時重構方案
- 情境:當
ServiceA依賴ServiceB,而ServiceB又需要ServiceA時,建構子注入會造成死結無法建立物件。 - 重構策略:將其中一方的特定呼叫改成方法注入(例如
serviceB.DoSomething(this)),即可立刻解開死結。
3. 三種注入方式快速對照表
| 注入方式 | 依賴的性質 | 適用時機 | 建議使用頻率 |
| 建構子注入 | 必要 (Mandatory) | 物件缺少該依賴就無法運作;追求物件不可變性與安全性。 | ⭐⭐⭐⭐⭐ (預設首選) |
| 屬性注入 | 可選 (Optional) | 有預設實作、舊碼過渡重構、框架受限無參數建構子、需動態替換。 | ⭐⭐ (特定情境使用) |
| 方法注入 | 單次/暫時 (Transient) | 僅單一方法需要使用、傳入參數具上下文差異、避免長期佔用資源。 | ⭐⭐⭐ (視方法情境使用) |
沒有留言:
張貼留言