依賴注入新手指南:白話解釋依賴注入、控制反轉與依賴反轉原則背後的核心思想
前情提要:
當我剛開始學習 DI、IoC 和 DIP 時,查閱了許多文章,但大多數文章一開始就充斥著「依賴」、「耦合」、「抽象」、「高低階模組」、「容器」和「注入」等 OOP 專業術語。對於剛接觸 OOP 或對 OOP 不太熟悉的人(比如我)來說,這並太不友善。這些詞彙的意思都還沒搞清楚,更別提要用它們來理解 DI、IoC 和 DIP 了。本篇文章的目標是讓對 OOP 與 Design Pattern 不熟悉的人也能對以上三個觀念建立清楚的理解。因此,在這篇文章的開頭,我決定用最簡單易懂的方式帶大家先建立對 DI、IoC 和 DIP 的基本概念,讓大家掌握它們背後的核心思想,而不被繁雜的專業術語困擾。接下來的章節中,我們會逐步深入,探討這些術語和技術細節,以便完整理解這些設計原則。
快速認識依賴注入 (DI)、控制反轉 (IoC)、依賴反轉原則 (DIP)
S.O.L.I.D 軟體架構中的 D 代表就是依賴反轉原則 (Dependency Inversion Principle, DIP) ,在物件導向設計 (OOD) 中是個非常重要的設計原則。這個原則與我們經常提到的控制反轉 (Inversion of Control, IoC) 和 依賴注入 (Dependency Injection, DI) 緊密相關,這些概念共同構成了一個強大且靈活的架構基礎。
-
DIP - Dependency Inversion Principle (依賴反轉原則)
想像你是一位經營餐廳的主廚,而食材的供應非常重要。如果你依賴某一家固定的農場供應食材,那麼如果這個農場關閉或供應中斷,你的餐廳運營就會受到影響。依賴反轉原則告訴你,不要依賴某一家具體的農場,而是應該依賴於“供應商的概念”。也就是說,你應該和一個供應系統合作,這樣無論哪家供應商提供食材,你都能正常運營。
DIP 是一種軟體架構的設計原則與思想,強調高階模組不應該依賴具體的低階模組,而是依賴抽象的接口或契約。這樣可以輕鬆替換具體的實作,系統的可擴展性和穩定性也會更好。
-
IoC - Inversion of Control (控制反轉)
接著看控制反轉,想像如果你是一個廚師,傳統上你可能自己去菜市場選購食材,然後每天回來準備料理。但控制反轉的概念告訴你,讓“供應商 ”替你負責這件事。他們知道你每天需要什麼,會自動送來。這樣,你的控制權不再在你手中,而是交給了外部的供應商,讓他們負責供應流程。你只需要專心做菜。
IoC 是一種 DIP 的具體實踐方式,它的核心在於,將控制物件的創建和依賴的管理交給外部容器或框架,讓框架來管理這些過程,讓程式模組更專注於各自的功能。
-
DI - Dependency Injection (依賴注入)
最後是依賴注入,它是控制反轉的一種具體實現方式。假設你每天煮飯時,食材供應商會把所有食材直接送到你的廚房。你不需要去尋找或挑選供應商,供應商會自動送來食材。這就是依賴注入,供應商在你需要時自動提供所需的資源(食材),讓你專心做自己的事。
DI 則是 IoC 的一種具體實現方式,它的重點在於,當一個模組需要某些依賴(例如服務或資源)由外部注入,而不是自己去建立。這樣可以讓程式更加模組化,易於測試和維護。
OOP 中的核心概念
依賴
「依賴」正是本篇文章要討論的主角,在我們開始討論依賴反轉、依賴注入之前,首先讓我們理解一下他的含義。
在 OOP 中,依賴是指一個物件需要外部的另一個物件或服務來完成某些功能。舉例來說:假 設你經營一家咖啡店,你需要一台咖啡機來煮咖啡。你的咖啡店不能運作沒有咖啡機,這時候可以說咖啡店依賴咖啡機來運行。
// 定義 CoffeeMachine 類別
class CoffeeMachine {
makeCoffee(): string {
return 'Here is your coffee!';
}
}
// CoffeeShop 類別依賴於 CoffeeMachine
class CoffeeShop {
private coffeeMachine: CoffeeMachine;
constructor() {
this.coffeeMachine = new CoffeeMachine();
}
serveCustomer(): void {
const coffee = this.coffeeMachine.makeCoffee();
console.log(coffee);
}
}
耦合
所謂的耦合是指兩個或多個物件、模組或系統之間的依賴程度。當兩個系統或物件之間高度依賴時,一個物件或系統的變動會嚴重影響另一個物件或系統的運作,這就表示它們的耦合度很高。
這裡我們一樣舉個通俗易懂的例子:假設你有兩家合作的餐廳:一家專門供應食材(供應商),另一家專門烹飪食物(餐廳)。如果這家餐廳只能使用特定一家供應商的食材(高度耦合),那麼當供應商無法供應時,餐廳也會無法運作。如果餐廳能靈活地選擇不同的供應商(低耦合),即使一家供應商無法提供食材,餐廳依然可以正常運營。
當系統的耦合度過高時,系統變得難以維護,因為一個小小的變動可能會對多個部分造成連鎖反應。而 DIP 的主要概念就是盡量降低模組間的耦合度,讓程式更具可擴展性。
高階模組與低階模組
-
高階模組:
在軟體設計中,高階模組通常是那些負責應用程式的核心邏輯或業務邏輯的模組。它們通常與使用者需求或應用程式的整體行為相關,通常更抽象,負責較高層次的決策。舉例來說:假設你有一個訂單系統,OrderService 可以被視為一個高階模組,它處理訂單的創建、驗證和付款等業務邏輯,但它不應該直接關心具體的付款方法(例如信用卡、PayPal 等)。
-
低階模組:
低階模組是那些負責實作具體功能的模組,它們通常處理更基礎的操作或系統細節,像是資料存取、支付處理、日誌記錄等。這些模組具體處理應用程式需要的某些功能,但它們不應該包含業務邏輯。舉例來說,在同一個訂單系統中,具體的 CreditCardPaymentProcessor 和 PayPalPaymentProcessor 就是低階模組,它們分別處理不同的付款方法,但它們不關心整個訂單的業務邏輯。
抽象 x 介面 x 實作
-
抽象:
抽象是指將物件的細節隱藏起來,僅保留最核心、最關鍵的行為或屬性。抽象讓我們專注於物件“應該做什麼”,而不是“怎麼做”。抽象可以是類別、方法或屬性,代表一個通用的概念,而具體的細節由實作來提供。
abstract class CoffeeMachine {
// 抽象方法,只有定義,沒有實作
abstract makeCoffee(): string;
} -
介面:
介面是一種更純粹的抽象,專門用來定義物件應該具備哪些方法和屬性,但不包含任何實作。它像是一個合約,強制實作的類別必須提供這些方法的具體行為
interface CoffeeMaker {
makeCoffee(): string;
} -
實作:
實作是指具體提供方法或行為的細節,它是抽象的具體化。當你 定義了一個抽象或介面後,具體的實作會告訴程式如何實現這些行為。
// CoffeeMachine 實作了 CoffeeMaker 介面
class EspressoMachine implements CoffeeMaker {
makeCoffee(): string {
return 'Espresso is ready!';
}
}
class FrenchPressMachine implements CoffeeMaker {
makeCoffee(): string {
return 'French Press coffee is ready!';
}
}
// 使用實作
const espressoMachine = new EspressoMachine();
console.log(espressoMachine.makeCoffee()); // Output: Espresso is ready!
const frenchPressMachine = new FrenchPressMachine();
console.log(frenchPressMachine.makeCoffee()); // Output: French Press coffee is ready!
依賴反轉原則(Dependency Inversion Principle, DIP)
什麼是依賴反轉原則(DIP)?
大致理解這些 OOP 中常見的術語後,現在我們來談談什麼是依賴反轉原則 (DIP)吧,它是 OOP 中一個非常重要的設計思維。SOLID Principles of Object-Oriented Design and Architecture 這本書提到,DIP 有兩個關鍵原則:
- 高階模組不應依賴於低 階模組,兩者都應依賴抽象。
- 抽象不應依賴於細節,細節應依賴於抽象。
雖然這句話本身看起來就很抽象,但實際上其實很好理解。用白話來說,就是說你不應該讓重要的業務邏輯(高階模組)去緊密依賴具體的細節(低階模組),而是要通過一個抽象層來解耦。這樣,不管細節變動如何,你的高階邏輯仍然能保持不變。
舉一個具體的生活情境為例子:想像你是一個遠程工作者,平常用筆電工作,但突然有一天你只能用手機辦公了,結果你發現每次換工作設備時,都得修改你自己的工作流程。那這樣可不行啊!這就是高階模組依賴低階模組的典型例子,因為你的工作流程直接依賴於具體的設備。
為什麼不該讓高階模組直接依賴低階模組?
以一個電商平台的訂單處理系統為例,處理訂單的 OrderService 就是負責核心業務邏輯的高階模組,處理支付具體操作細節的 CreditCardPaymentProcessor 則是低階模組。我們寫程式的時候常常會直接這樣寫:
class CreditCardPaymentProcessor {
processPayment(amount: number): void {
console.log(`Processing credit card payment of ${amount}`);
}
}
class OrderService {
private paymentProcessor: CreditCardPaymentProcessor;
constructor() {
this.paymentProcessor = new CreditCardPaymentProcessor();
}
placeOrder(amount: number): void {
console.log('Placing order...');
this.paymentProcessor.processPayment(amount);
}
}
// 使用 OrderService 處理訂單
const orderService = new OrderService();
orderService.placeOrder(100);
來看看依賴關係:
問題在哪裡?
- OrderService 依賴於具體的 CreditCardPaymentProcessor,這就像你只能用特定的工具(筆電)工作,任何改變都會讓你手忙腳亂。想像一下,如果你想加入 PayPal 支付方式,你會發現得改一堆程式碼。
如何遵循依賴反轉原則?
現在來看怎麼用抽象來解決這個問題。首先,我們定義一個抽象的 PaymentProcessor 介面,讓高階模組 OrderService 依賴這個介面,而不是具體的支付方式。
interface PaymentProcessor {
process(amount: number): void;
}
class CreditCardPaymentProcessor implements PaymentProcessor {
process(amount: number): void {
console.log(`Processing credit card payment of ${amount}`);
}
}
class PayPalPaymentProcessor implements PaymentProcessor {
process(amount: number): void {
console.log(`Processing PayPal payment of ${amount}`);
}
}
class OrderService {
private paymentProcessor: PaymentProcessor;
constructor() {
// 現在可以在這裡更換支付方式
// const paypalProcessor = new PayPalPaymentProcessor();
this.paymentProcessor = new CreditCardPaymentProcessor();
}
processOrder(amount: number): void {
console.log('Order is being processed...');
this.paymentProcessor.process(amount);
}
}
// 使用 OrderService 處理訂單
const orderService1 = new OrderService();
orderService1.processOrder(100);
來看看依賴關係:
從依賴低階模組的實作轉向依賴於抽象的改善
- OrderService 現在依賴於抽象的 PaymentProcessor 介面,而不是具體的支付實作。這樣一來,如果你想使用 PayPal 支付方式,只需要在 PayPalPaymentProcessor(低階模組) 中實作 PaymentProcessor(介面),而不需要改動 OrderService(高階模組) 使用支付方式的邏輯。
還存在什麼問題?
- 雖然現在高階模組已經依賴於抽象,但是我們可以發現, new instance 這個動作還是在 OrderService 裡面進行的。也就是說,如果今天我們想要換用另一種支付方式時,我們還是得手動更改要在 OrderService 裡面 new 哪一種 instance。這依然讓我們的高階模組對具體的實作有所依賴。
控制反轉(Inversion of Control, IoC)
在前一節,我們提到 OrderService 雖然已經依賴於抽象的 PaymentProcessor 介面,而不再緊密依賴具體的支付方式,但問題在於,我們依然需要在 OrderService 內部手動決定使用哪一種支付方式。這裡出現的關鍵問題就是——誰負責創建具體的支付實例?
這時候,我們需要引入 控制反轉 (Inversion of Control, IoC)。
什麼是控制反轉 (IoC)?
控制反轉(Inversion of Control, IoC) 是一種設計模式,核心思想是將模組的控制權(例如,具體實作的生成)從模組本身轉移到外部的框架或容器中。這樣,高階模組不需要關心如何創建和管理它依賴的低階模組。其最大的作用就是幫助高階模組解耦(Decouple)。
在我們的例子中,OrderService 不再需要自己決定使用哪個 PaymentProcessor,這個責任將交給 IoC 容器來處理。
IoC 容器(IoC Container)
那麼,IoC 容器又是什麼呢?
IoC 容器是一個負責管理和注入依賴的工具。當高階模組需要某個具體的 實作時,IoC 容器會負責創建這個實作並將它注入到高階模組中,而高階模組不需要自己去 new 出來。
使用 IoC 容器後,我們的 OrderService 可以不再直接決定使用哪種支付處理器,IoC 容器會自動將我們需要的具體實作注入進來。
使用 IoC 重構
// 模擬 IoC 容器
class IoCContainer {
private services: Map<string, any> = new Map();
// 註冊服務
register(serviceName: string, service: any) {
this.services.set(serviceName, service);
}
// 取得服務
resolve(serviceName: string) {
return this.services.get(serviceName);
}
}
// 定義抽象介面和具體實作
interface PaymentProcessor {
process(amount: number): void;
}
class CreditCardPaymentProcessor implements PaymentProcessor {
process(amount: number): void {
console.log(`Processing credit card payment of ${amount}`);
}
}
class PayPalPaymentProcessor implements PaymentProcessor {
process(amount: number): void {
console.log(`Processing PayPal payment of ${amount}`);
}
}
// 高階模組 OrderService 依賴於抽象介面
class OrderService {
private paymentProcessor: PaymentProcessor;
constructor(paymentProcessor: PaymentProcessor) {
this.paymentProcessor = paymentProcessor;
}
processOrder(amount: number): void {
console.log('Order is being processed...');
this.paymentProcessor.process(amount);
}
}
// 註冊依賴到 IoC 容器中
const container = new IoCContainer();
container.register('PaymentProcessor', PayPalPaymentProcessor); // 可以更換為 CreditCardPaymentProcessor
// 從容器中解決依賴
const paymentProcessor = container.resolve('PaymentProcessor');
const orderService = new OrderService(paymentProcessor);
orderService.processOrder(100);
使用 IoC 後的改善
- 透過這樣的方式,我們可以輕鬆地更換不同的 PaymentProcessor,而不需要改動 OrderService 的任何程式碼。IoC 容器自動處理了依賴的註冊和解決。