淺入淺出 Dependency Injection

DI 有什麼好?如何實作 DI?DI 到底是什麼?

Wenchin
Wenchin Rolls Around
16 min readMay 8, 2021

--

相信很多人對 Dependency Injection(DI,依賴注入)的觀念有很多疑惑。從最根本的本質(到底它是個技術?工具?設計模式?還是信仰?)、到目的(注入一堆東西是為了單元測試好寫嗎?)、到引入的手段(是不是要學會用 DI container 才能實現 DI?)等,都很容易讓人寫著寫著就黑人問號了。

因為我也很問號(尤其一開始就碰 MS DI 有夠不解),所以有次在一個 Backend 隨意聊的分享場合就決定講這主題,逼自己好好研讀大神同事 J 借我的書—「依賴注入:原理、實作與設計模式 by Steven van Deursen, Mark Seemann」這本經典,好好坐下來認識 DI,並跟大家討論我的理解,假日順手整理成文章。目的是學好 DI 架構、一起寫出更舒服的程式碼。

大綱

  1. Why: 為什麼使用介面 (interface) 去設計程式、為什麼需要 DI
  2. How: 如何實作 DI
  3. What: DI 定義、跟 IoC(控制反轉)和 DIP(依賴反轉原則)的關係、優缺點
  4. 常見 DI 設計模式

本文僅討論概念、基本實作 pure DI 架構,不討論使用 DI 容器等依照不同語言有不同工具的情境。

Disclaimer

DI 架構本身要解決的問題不限 OO(物件導向)領域,但以下會聊到的解決方案都屬於 OOP 語言的領域,範例也是用 C# 寫的。

DI 不適用於程序式程式設計;DI 對函數式程式設計、動態程式語言也無法發揮全力。雖然如此,DI 架構與很多軟體設計中的實務原則、設計模式也有相關。

Why use DI?

如何向一個五歲孩子解釋何謂 DI?

依賴注入:原理、實作與設計模式書中提到 Stack Overflow 上面 John Munsch 舉的例子,DI 就是:

When you go and get things out of the refrigerator for yourself, you can cause problems. You might leave the door open, you might get something Mommy or Daddy doesn’t want you to have. You might even be looking for something we don’t even have or which has expired.

What you should be doing is stating a need, “I need something to drink with lunch,” and then we will make sure you have something when you sit down to eat.

如果你跟我一樣還沒有小孩,體驗可能沒那麼深刻,那我們看下一個例子。

如何稱呼男/女友?你都叫他他的名字還是「寶貝」?

如果叫的是「寶貝」,那恭喜你,你是 DI 大師 👍

因為你使用了介面(「寶貝」稱呼)而非實作(不同的名字),即使換了男/女友(抽換不同的實作)也都不用改稱謂。

插座(介面)允許不同電器的插頭(實作)

  • 緊耦合:就像廉價旅館直接用電線連進牆壁上一個洞的吹風機,無法替換電器、難以修改
  • 鬆耦合:就像插座,可以替換吹風機、筆電插頭等,符合 SOLID 裡面的里氏替換原則 (Liskov Substitution Principle)
Photo by Kelly Sikkema on Unsplash

As long as the plug (the implementation) fits into the socket (implements the interface), and it can handle the amount of volts and hertz (obeys the interface contract), we can combine appliances in a variety of ways.

插頭怎麼實現 SOLID 的一些原則?

  • 里氏替換原則 (Liskov Substitution Principle):插座在電腦出現數十年之前就誕生了,但插座最早的設計者不可能預見未來個人電腦的盛行。在插座發明後,即可在不需要對插座更動的情況下,切換各種連接插頭的電器。里氏替換原則就是對介面的實作要能替換,而且不須破壞客戶端介面或實作。
  • 開放封閉原則 (Open Close Principle):只要個人電腦的插頭遵循插座要求的規格設計它們的插頭,就可以在不用修改插座設計(既有系統 code base)的情況下使用這個它。鬆耦合架構對「開放(擴展)封閉(修改)原則」有幫助!

從插座的故事我們學到了…

Program to an interface, not an implementation.

既然介面這麼棒,我們就寫個介面初始化來用吧?

因為介面不包含實作,無法這樣直接成立一個介面的物件,需要透過別的管道建立。而本文的主角 DI 可以解決這個問題!

How do I use DI?

簡單,就是學會用 DI 容器對吧?

DI containers 是種函式庫,讓你可以在構成一個應用程式的時候更容易的實作 DI、組織類別,但絕對不是必要的選擇。

應該學習的方式是先學會實作 pure DI、再按照需求使用 DI containers。

寫個 Socket 類別,實作插頭介面!

說了這麼多,總是要寫點程式碼,不然沒感覺。

就用前面的插座來寫吧(本來我用別的例子,但感謝學妹 V 的建議換了插座呼應舉的例 👆)。以下的插座範例程式碼可以在這個 repo 找到:

首先我們定義插頭的介面 IElectricalPlug,讓他有個方法 Connect(),意即插插座連接到電源:

然後定義使用插頭的「插座」類別 Socket,透過「建構子注入」把 IElectricalPlug 注進這個類別。所以在做 SendPower() 這件事的時候,可以呼叫插頭的實作去做 Connect(),之所以可以呼叫是因為實作 IElectricalPlug 介面的類別一定要先實作 Connect() 這方法!

廢話不多說,我們馬上就來實作一個吹風機插座類別 HairDryerPlug,這邊簡化為印一行字代表它連接上電源:

現在可以明顯感受到 Socket 依賴介面 IElectricalPlug,不會知道實作是哪位。

呼叫一下:

Console 結果為:

今天我們寫的是 HairDryerPlug ,改天有了別的需求可以寫 LaptopPlugPhonePlug 等等不同的插頭類別,只要確保有實作 IElectricalPlug 介面。

而注入不同的 IElectricalPlug 類別實作就會有不同的 Connect() 方法實作細節,在不需要動到 Socket 程式碼的情況下,即可抽換實際連結的插頭物件。

What’s DI?

所謂的 DI 架構,指的是一整套設計模式與實務原則。這是一種程式設計面上的思維訓練,而不是特指某種技術。DI 架構的最後目標,是在物件導向程式的領域中,打造出具備高可維護性的軟體。

DI 其實是個手段,不是目標。

為什麼這麼說呢?因為透過 DI,我們希望做到鬆耦合的架構,提高程式的可維護性。一旦程式的可維護性高,效率就會提升,開發的產出也會提升,而達到我們的目的,也就是金錢和快樂。

DI isn’t an end goal — it’s a means to an end. DI enables loose coupling, and loose coupling makes code more maintainable.

「依賴」是啥?「注入」又是啥?

四月上了 Bill 的輕鬆學會物件導向課程,他拆解「依賴注入」這個神祕名詞的方法和例子滿有趣的:

  • 依賴 = 耦合 → 兩個物件是否認識彼此
  • 注入 = 這個認識的關係怎麼建立的,也就是認識的途徑

以愛情為例:兩人的關係就是依賴,注入方式可能有路上搭訕注入、交友網站注入、相親注入…等。

DI 的 3 個主要職責

  1. 創建物件(最重要!)
  2. 知道哪些類別需要被創建的那些物件
  3. 提供類別所有它需要才能正常運作的物件

也就是說,DI 分散了物件「創建」和「使用」的職責,於是依賴的類別可以被改變,而被依賴的類別不會知道或在意(因為被依賴類別的物件 IElectricalPlug 不是 Socket 創建的,是被注入的),只要知道它可以怎麼被用(以上面的例子就是呼叫 Connect() 方法)就好了。

The goal of dependency injection is to separate the usage an object from it’s creation. This removes a class’s direct dependency on another class. Dependent classes can be changed out without the depending class knowing or caring.

請問你跟控制反轉 (IoC) 的關係是……?

Inversion of Control (控制反轉)是實現低耦合的最佳設計方式之一,讓通用的程式碼來控制應用特定的程式碼,相依於抽象而不倚賴實作。

實現 IoC 的做法有:DI(例如我需要車子類別不直接用實作,而是注入 ICar 介面)、工廠模式(拉個 CarFactory 類別把控制產生執行個體如 Benz, BMW 類別的權力移轉給工廠,再由工廠拿車)……等。

結論:IoC 的範疇包含 DI,但不僅限於 DI。

請問你跟依賴反轉原則 (DIP) 的關係是……?

SOLID 原則之一的 Dependency Inversion Principle(依賴反轉原則)強調用抽象解耦依賴關係:

High-level modules should not depend on low-level modules. Both should depend on abstractions (e.g., interfaces).

Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.

DIP 是個使用抽象時依賴關係的準則或概念,IoC 說明了依賴關係的控制方向,而 DI 是一種處理依賴關係的模式。

有興趣的人可以參考 Martin Fowler 的 DIP in the Wild 的 You mean Dependency Inversion, Right? 段落。這裡劇透一下結論:

DI is about how one object acquires a dependency. When a dependency is provided externally, then the system is using DI. IoC is about who initiates the call. If your code initiates a call, it is not IoC, if the container/system/library calls back into code that you provided it, is it IoC.

DIP, on the other hand, is about the level of the abstraction in the messages sent from your code to the thing it is calling. To be sure, using DI or IoC with DIP tends to be more expressive, powerful and domain-aligned, but they are about different dimensions, or forces, in an overall problem. DI is about wiring, IoC is about direction, and DIP is about shape.

DI 的優點

  1. Late binding:

例如可以較容易的替換資料庫(SQL Server 要換成 PostgreSQL 等)。

2. Extensibility:

假設我們現在插頭的例子有了新需求:希望吹風機插頭有個驗證身分的過程,確認是媽媽才能用,以免小孩亂玩。

根據上面插頭的例子,我們可以用先前定義的 IElectricalPlug 介面新開一個 SecureHairDryerPlug 類別:

以上用裝飾者模式把本來的吹風機插頭傳進來,驗證無誤才呼叫它 Connect() ,輕鬆寫意。

呼叫一下哪次不呼叫:

Console 結果:

3. Parallel development

多人開發時,把你的模組 DI 到我的應用,定義好簽章就可以各自開發、驗證,一切順利。

4. Maintainability

實現 SOLID 裡面的 SRP 原則,幫類別區分職責。

5. Testablility

寫程式一定要有測試,讓我們為剛剛辛苦寫的插座寫個單元測試吧!

因為有 DI 插頭,所以我們首先寫個假的 IElectricalPlug 實作,讓我們叫它 TestSpyPlug 好了。它就簡單的寫成監看 Connect() 方法有沒有被呼叫。

Test Spy 型的實體類別:驗證 sut 對其他元件的間接輸出呼叫行為

然後在 SocketTests 寫個測試替身(SUT, system under test),取代原本物件。

最後用 TestSpyPlug 注入 Socket class 寫插座的單元測試:

乾淨俐落,不被插頭影響(當然插頭應該另外有單元測試的),優秀!

DI 的缺點

  1. 系統架構的複雜度增加,追扣較難:由於程式不再和實作的類別相關,因此開發者追程式碼時會比較困難。這需要花時間來熟悉整體系統的運作、還有熟悉 IDE(VSCode 就要常常按 F12 —切換定義和實作的快捷鍵)才會上手。
  2. 所需寫的程式碼變多,開發速度變長:為了這個架構,得要多寫一些程式;這會直接的影響到開發的速度。
  3. 往往某種程度上耦合了你用的 DI 框架,或你決定實現 DI 的方式。

誰適合注入?誰不適合注入?

首先要了解依賴性的穩定程度:

  1. Stable dependencies(穩定依賴性):不用另外準備、安裝、版本更新會向下相容、有可預期的演算法/行為模式、你不會想替換/包裝/裝飾/中介攔截這些類別或模組。例如:大部分 .NET 基礎類別庫 (BCL) 的型別。
  2. Volatile dependencies(不穩定依賴性):應用程式需要準備或 configure runtime 環境、還沒開發完、不是所有設備都安裝這份依賴性關係對象(可能會很貴)、依賴性關係對象有無法被預期的行為模式(需要單元測試保護)。例如:BCL 函式庫的 DB 設定相關功能 → 隔離才有機會改用其他資料庫、需要依賴外部資源才能完成操作的 MQ、經過網路的服務、對檔案系統的操作等。

不穩定的更需要採用 DI 架構:

Volatile Dependencies are the focal point of DI. It’s for Volatile Dependencies rather than Stable Dependencies that you introduce Seams into your application. Again, this obligates you to compose them using DI.

物件的生命週期 (Object Lifetime)

當類別喪失對依賴性關係的控制權後,同時失去決定要使用哪份實體物件、何時建立物件、何時讓這物件失效的管理能力。

比較以下兩段的差異:

在 DI 裡,管理物件的生命週期 (lifetime management) 很重要!

物件的攔截 (Interception)

當我們把依賴性關係的控制權交給第三方,便可在物件交給使用方的類別之前先攔截、做更動。

例如剛剛寫的 SecureHairDryerPlug 就是對 HairDryerPlug 加以攔截。

DI 設計模式

DI 的設計模式包含並不限於以下幾種。

想必插座插頭的例子你看到這裡也膩了,以下讓我們以司機和車子的類別為例。

建構子注入

確保類別所需要的不穩定依賴對象即使不直接依賴、還是可以被滿足。是最通用的注入方式,也是我們在插座範例用的注入方式。

方法注入

在同一類別、不同方法上注入不同的依賴對象。

屬性注入

當已經有適合的內建預設可用,但還是希望提供呼叫方一份彈性。要注意可能使用方忘記把屬性賦值就會出問題,適用於設計函式庫。

DI 真的博大精深,還有很多還沒鑽研的延伸(例如物件生命週期實作、AOP 等等),待我研究有心得的話有機會寫個 part 2 繼續跟大家分享。

在 AppWorks School - Backend 隨意聊分享的簡報

喜歡這篇文章的話歡迎打賞 🙌

Reference

--

--