[.NET][C#][SOLID] - DI & IoC (依賴注入與控制反轉) 全面講解
前言
IoC (Inversion of Control) 控制反轉,與OOP SOLID原則中的其中一種設計原則有關,也就是其中的DIP(Dependency Inversion Principle),是OOP一個非常重要的程式設計思想,對於軟體開發來說十分重要,下面我將十分詳細的介紹何為DIP、IoC以及DI、為何要使用它們以及如何實作,相信大家閱讀完會對這個重要的思想了解的更加透徹。
定義
DIP以簡單的一句話說明就是
DIP - Dependency Inversion Principle (依賴倒轉原則)
- 一種原則、思想
- 高層次的模組不應該依賴低層的模組,低層次的模組也不應該依賴高層次的模組
兩者都應該依賴抽象
IoC - Inversion of Control (控制反轉)
- 一種思想
- 把對於某個物件的控制權移轉給第三方容器 (IoC Container)
DI - Dependency Injection (依賴注入)
- 一種設計模式
- 將依賴通過注入的方式提供給需要的模組,是 IoC 與 DIP 的具體表現
- 把被依賴物件注入被動接收物件中
DIP的定義非常重要,請大家牢記在心。
也就是說,程式應該依賴抽象,而不是實作的實體,這可以幫助我們對程式之間解耦,能夠被更好地維護。
DI是為了實現DIP和IoC而誕生的實現方式,因此大家在使用物件導向的技巧時,務必清楚自己在做什麼,和為何這樣做。
好處/為什麼要使用?
在針對各個名詞解釋與實作之前,我想先讓各位了解DIP以及IoC帶來的好處。
- 可維護性 (maintainability)
- 寬鬆耦合 (loose coupling)
- 所謂的可維護性,就是你在日後需要修改或更新程式的時候,所花費的時間和精力,如果修改起來很費時費力,那我們就說他可維護性低。
- 所謂的耦合度,就是物件與物件間的依賴、相關程度,如果在A類內去new B,B類內又去new C,彼此相互直接依賴,這樣類別之間相互呼叫令彼此有所牽連,便是耦合(coupling),物件關係越緊密,耦合度越高,耦合度高的程式碼,一旦有任何變動,容易發生連鎖反應,牽一髮動全身,因此龐大的軟體更應該考慮低耦合高內聚的設計方式。
而DIP, IoC和DI可以讓程式之間解耦,提高程式的可維護性。
基本上「可維護性」和「寬鬆耦合」就是我們學習DIP, IoC和DI的原因
下面我會一一介紹DIP, IoC以及DI,最後附上實際應用,即最後組合出來的結果。
DIP
DIP - Dependency Inversion Principle(依賴倒轉原則)
- 一種原則、思想
- 高層次的模組不應該依賴低層的模組,低層次的模組也不應該依賴高層次的模組
兩者都應該依賴抽象
什麼意思呢?讓我們看看下面的範例:
簡單範例
1 | public class Database |
上面的程式碼違反了DIP,因為DataAccess直接依賴了’Database’類別,如果Database做了任何變動,DataAccess也需要跟著變動,所有有用到關於Database的class也都需要跟著變動,因此,’DataAccess’ class應該依賴抽象的Interface,而不是具體的實作。
1 | public interface IDatabase |
由上面的範例可知,我們定義了一個IDatabase,規範所有資料庫應該有的action,讓不同的實體Database去實作,而Program本身(DataAccess),只需要去依賴、使用IDatabase,這樣一來就算未來我從SQL-Server DB遷移到Oracle DB,我只需要抽換IDatabase的實作實體(_database指標指向的實際記憶體),DataAccess內部使用到IDatabase的程式碼一行都不用更改。
但是,可以發現到,雖然透過依賴倒轉,可以改變為依賴抽象,但程式(DataAccess)本身還是需要new 出instance,也就是說程式本身(呼叫者),對於依賴的控制流程具有主導權,這時就需要控制反轉。
IoC
- 把對於某個物件的控制權移轉給第三方容器 (IoC Container)
IoC 是一種設計原則或思想,它建議我們反轉物件導向設計中的各種控制,以達到各個類別間的解耦。這裡的 “控制”指的是除了一個類別本身的職責之外的其它所有工作,如整個軟體的流程控制,物件的依賴或創建等等。
- 其實意思就是,一個類別本身除了本身的職責外,不應該擁有太多其他的工作 (SRP)
- 所以建議將這些對於物件的控制權(創建、實作實體抽換等等),交給第三方容器 (Framework or Library)。
- 獲取資源的行為由”主動”轉為”被動”
- 程式(Application) 依賴物件的「控制流程 (Control Flow)」,由「主動」變成「被動」。就是「控制反轉」
下面這兩張圖簡單解釋了使用IoC後的依賴關係
這是還沒使用IoC前,我們的應用程式直接依賴於實體類別
這個則是使用IoC後,透過IoC Container,將依賴實體注入至程式中,程式由原來主動的依賴變成被動的接收
好萊塢原則也很貼切的說明了控制反轉的情境
Don’t call me, I’ll call you.
IoC Container
廣義上來說, IoC 容器,就是有進行「依賴注入」的地方,
你隨便寫一個類別,透過它將所需元件注入給高階模組,便可說是容器。
但現在所說的容器通常泛指那些強大的IoC框架所提供的容器。
各位可以把IoC容器想像成是儲存一堆使用者註冊的依賴實體,IoC Container透過這些使用者註冊的資訊,知道程式需要這個instance並賦予給他,達到不需要修改高階模組的目的。
程式在執行的期間(Runtime),需要依賴物件的實體,需要透過IoC Container注入給程式,使用的是反射原理(Reflection)-也就是透過程式碼的中間編譯檔,去讀取程式碼內部的資訊。
下面這兩張圖則解釋了IoC如何利用IoC Container做到控制反轉的示意圖
這個是沒有使用IoC框架時,高階模組主動的去建立所需要的低階模組 (資源)
這個則是使用了IoC框架,透過 「註冊 (Register)」 所需要的模組進IoC Container,藉由容器主動注入依賴實體進入高階模組
簡單範例
這邊提供的簡單範例中,IoC Container用簡單的方式實作,實際上這些工作會交給第三方套件或框架完成,這邊使用簡單的方式實作給大家理解
1 | public interface ILogger |
在這裡,Program就是我們的IoC Container,UserService依賴ILogger抽象,而透過註冊在Program中的資訊,讓Program主動將實際的依賴實體(ConsoleLogger物件),注入到UserService的建構元中,也就是建構元注入,後面講到DI會再提到。
之後如果有需要新的Logger,只需要創建並實作ILogger,再透過Program注入給UserService,UserService內部程式碼一行都不用更改。
IoC與DIP的差別
控制反轉(IoC)與依賴倒轉(DIP)兩者不相等!
依賴倒轉,倒轉的是「依賴關係」
控制反轉,反轉的是程式依賴物件的「控制流程」
DI
將依賴通過注入的方式提供給需要的模組,是 IoC 與 DIP 的具體表現
把被依賴物件注入被動接收物件中
程式或者開發者不必理會物件是如何產生、保持、至銷毀的生命週期
在.NET的DI框架中,生命週期有三種,Transient、Scoped、Singleton,後面講解實作時會再談到。
DI的背後思想主要是:
- 為了保證DIP,一個類別應該只依賴抽象
- 於是具體的實現必須透過某種方式”注入”到這個類別
- 那麼依據IoC原則,最好透過第三方容器來做到這件事
而DI又有主要的三種形式:
- 建構元注入 (Constructor Injection)
- 設值方法注入 (Setter Injection)
- 介面注入 (Interface Injection)
下面舉個簡單的範例
簡單範例
- 建構元注入 (Constructor Injection)
屬於最常見的注入方式,IoC Container將實體DI到呼叫者的建構元中,當呼叫者被new(建立)時,就會自動注入相關實體進入建構元。在這裡,當有人去new UserService時,IoC Container就會自動注入當初註冊的實體進入UserService的建構元。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27public interface ILogger
{
void Log(string message);
}
public class Logger : ILogger
{
public void Log(string message)
{
Console.WriteLine("Log: " + message);
}
}
public class UserService
{
private readonly ILogger _logger;
public UserService(ILogger logger)
{
_logger = logger;
}
public void AddUser(string userName)
{
_logger.Log("User Added: " + userName);
}
}
設值方法注入 (Setter Injection)
透過setter method注入實體,他允許我們在呼叫者實體被創建後(instantiated),才注入相關依賴實體。1
2
3
4
5
6
7
8
9
10
11
12
13
14public class UserService
{
private ILogger _logger;
public ILogger Logger
{
set { _logger = value; }
}
public void AddUser(string userName)
{
_logger.Log("User Added: " + userName);
}
}介面注入 (Interface Injection)
依賴透過Interface注入近實例中,這個Interface必須定義一個方法來注入依賴,再藉由實例去實作此介面,來實現具體的DI1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20public interface IUserService
{
void AddUser(string userName);
void SetLogger(ILogger logger); // 定義注入依賴的方法
}
public class UserService : IUserService
{
private ILogger _logger;
public void AddUser(string userName)
{
_logger.Log("User Added: " + userName);
}
public void SetLogger(ILogger logger) // 實際注入依賴
{
_logger = logger;
}
}
DIP、IoC與DI的結合 - 實際應用
為大家總結一下上面講的各種名詞,用這張圖簡單概括
先讓大家釐清,DIP, IoC, DI, IoC Container之間的關係
生活範例
讓我們用在「餐廳煮東西」來舉例
DIP: High-level modules should not depend on low-level modules. Both should depend on abstractions.
在我們的例子中,廚師就是高階模組,而食材是低階模組,廚師不應該依賴於特定的食材,而是應該依賴於可以用來煮各種餐點的食材的抽象概念。
Inversion of Control (IoC): The control of the flow of a program is inverted.
在我們例子中,顧客點餐,廚師備餐,對於餐點準備的控制流程,顧客不控制備餐的流程,而是接受最終完成的餐點。
IoC Container: A container that manages and controls the creation and life cycle of objects, and also injects their dependencies.
在我們的例子中,可以把廚房想像成是IoC容器,他主管了各個食材、廚房用具的生命週期,並確保能夠提供廚師需要的食材或工具。
Dependency Injection (DI): A technique for achieving IoC, where the objects are given their dependencies instead of creating them themselves.
在我們的例子中,廚師是被提供食材的人(由廚房提供),而不是自己去尋找食材。
結合舉例
接下來我們接續上面的例子,透過程式的方式來講解上面的所有概念(DIP, IoC, IoC Container, DI)
1 | public interface IChef |
- 在上面的例子中,’IChef’介面和’Chef’類別遵守了DIP,因為他們依賴於抽象的’IIngredients’,而不是特定的食材實體。
- 而’Chef’類別透過 「建構元注入」 的方式,給予’IIngredients’的依賴,而不是自己主動直接創建一個實體。
- ‘Kitchen’ class則扮演IoC Container的角色,他管理了’Chef’和’Ingredients’的創建與生命週期,並注入’Ingredients’實體進入’Chef’的建構元中。
- 而Main method可以當成是我們的Application,透過’Kitchen’獲取’Chef’實體,並呼叫Cook() method。
.NET C#實現
下面我將簡單使用.NET預設的DI框架(Microsoft.Extensions.DependencyInjection)來實現註冊依賴實體,與依賴注入。
其中還有一些進階的用法,像是把註冊相關的邏輯抽提出來寫成擴充方法,還有使用Attribute與反射來解決建構元注入太多的問題,但在這篇教學中先使用最簡單的方法實作,為的是讓各位先理解基本的概念與用法,進階用法會在之後的文章詳細介紹。
DI生命週期與註冊
在.NET的預設DI框架中,註冊實體物件時可以指定其生命週期,分為三種(重要!)
- Transient (一次性) : 每次注入時,都建立一個新的實體。
- Scoped (作用域) : 每次的Request,都建立一個新的實體,同一個Request下,重複利用同一個實體 (這裡的Request 常指Http Request)。
- Singleton (單例) : 使用單例模式(Singleton Pattern),從程式開始到結束,只建立一個實體,每次都重複利用同一個,直到程式被終止。
實作
讓我們繼續以上面餐廳的例子實作
首先定義好相關的class與Interface,其中使用DIP我這邊就不特別提了
1 | public interface IChef |
接著就是我們主要註冊DI實體的地方,在Program.cs的檔案中,我這邊只寫出關鍵的部分。
1 | // ... |
解釋一下,這邊的builder.Services屬於IServiceCollection類,一但呼叫’AddScoped<IIngredients, Ingredients>()’方法,IoC Container就知道要建立一個Ingredients實體,去對應到程式中的三種DI形式之一,並注入實體讓IIngredients指向,在我們的例子中是「建構元注入」,因此,DI框架透過「反射原理」,知道Chef class中的建構元有IIngredients,透過之前註冊的資訊,IoC Continaer主動建立一個Ingredients實體,並注入到Chef class的建構元中。
整理一下上面的流程
- 利用builder.Services.AddScoped<IIngredients, Ingredients>()註冊依賴資訊,以及生命週期給IoC Container
- IoC Container利用反射原理,得知Chef class中的建構元有IIngredients,並與之前註冊的依賴資訊做對應
- IoC Container建立一個Ingredients實體,並注入進Chef class的建構元中,讓建構元的IIngredients指標指向
- 在建構元中,透過建構元參數指向的Ingredients實體,賦值給Chef class的內部欄位_ingredients
結語
這篇文章我十分詳細的介紹了DIP, IoC, DI的概念與實作,這些概念對於軟體開發來說非常重要,但大家也要清楚理解這些思想要解決的問題,以及使用它們的好處,清楚自己在做什麼,而不是為設計而設計,其實OOP很多的pattern,都會有其好處以及trade off,因此了解為何使用就顯得非常重要。
P.S. :
- 我自己很喜歡使用指標和記憶體的概念來理解物件與其值,這對於理解Pass by value/reference和Stack, Heap的記憶體分配非常有用,十分建議大家使用。
- Microsoft.Extensions.DependencyInjection這個namespace,利用IServiceProvider來管理我們程式中中所註冊的依賴,我們也可以透過注入這個IServiceProvider來取得實體,這在之後的DI進化的文章會有著關鍵作用。