[.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帶來的好處。

  1. 可維護性 (maintainability)
  2. 寬鬆耦合 (loose coupling)
  • 所謂的可維護性,就是你在日後需要修改或更新程式的時候,所花費的時間和精力,如果修改起來很費時費力,那我們就說他可維護性低。
  • 所謂的耦合度,就是物件與物件間的依賴、相關程度,如果在A類內去new B,B類內又去new C,彼此相互直接依賴,這樣類別之間相互呼叫令彼此有所牽連,便是耦合(coupling),物件關係越緊密,耦合度越高,耦合度高的程式碼,一旦有任何變動,容易發生連鎖反應,牽一髮動全身,因此龐大的軟體更應該考慮低耦合高內聚的設計方式。

而DIP, IoC和DI可以讓程式之間解耦,提高程式的可維護性。
基本上「可維護性」和「寬鬆耦合」就是我們學習DIP, IoC和DI的原因

下面我會一一介紹DIP, IoC以及DI,最後附上實際應用,即最後組合出來的結果。

DIP

DIP - Dependency Inversion Principle(依賴倒轉原則)

  • 一種原則、思想
  • 高層次的模組不應該依賴低層的模組,低層次的模組也不應該依賴高層次的模組
    兩者都應該依賴抽象

什麼意思呢?讓我們看看下面的範例:

簡單範例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Database
{
public void Connect() { /* database connect logic */ }
public void Disconnect() { /* database disconnect logic */ }
public void SaveData(string data) { /* database save data logic */ }
}

public class DataAccess
{
private Database _database = new Database();

public void SaveData(string data)
{
_database.Connect();
_database.SaveData(data);
_database.Disconnect();
}
}

上面的程式碼違反了DIP,因為DataAccess直接依賴了’Database’類別,如果Database做了任何變動,DataAccess也需要跟著變動,所有有用到關於Database的class也都需要跟著變動,因此,’DataAccess’ class應該依賴抽象的Interface,而不是具體的實作。


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
27
28
29
30
31
32
33
34
35
36
37
38
public interface IDatabase
{
void Connect();
void Disconnect();
void SaveData(string data);
}

public class SqlServerDatabase : IDatabase
{
public void Connect() { /* SQL Server database connect logic */ }
public void Disconnect() { /* SQL Server database disconnect logic */ }
public void SaveData(string data) { /* SQL Server database save data logic */ }
}

public class OracleDatabase : IDatabase
{
public void Connect() { /* Oracle database connect logic */ }
public void Disconnect() { /* Oracle database disconnect logic */ }
public void SaveData(string data) { /* Oracle database save data logic */ }
}

public class DataAccess
{
private IDatabase _database;

public DataAccess()
{
_database = new SqlServerDatabase();
//_database = new OracleDatabase(); 在這裡抽換
}

public void SaveData(string data)
{
_database.Connect();
_database.SaveData(data);
_database.Disconnect();
}
}

由上面的範例可知,我們定義了一個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
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
27
28
29
30
31
32
33
34
35
36
37
38
public interface ILogger
{
void Log(string message);
}

public class ConsoleLogger : 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 CreateUser(string username, string password)
{
_logger.Log($"Creating user {username}");
// Implementation to create a user
}
}

class Program
{
static void Main(string[] args)
{
ILogger logger = new ConsoleLogger();
UserService userService = new UserService(logger);
userService.CreateUser("johndoe", "secret");
}
}

在這裡,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的背後思想主要是:

  1. 為了保證DIP,一個類別應該只依賴抽象
  2. 於是具體的實現必須透過某種方式”注入”到這個類別
  3. 那麼依據IoC原則,最好透過第三方容器來做到這件事

而DI又有主要的三種形式:

  1. 建構元注入 (Constructor Injection)
  2. 設值方法注入 (Setter Injection)
  3. 介面注入 (Interface Injection)

下面舉個簡單的範例

簡單範例

  1. 建構元注入 (Constructor Injection)
    屬於最常見的注入方式,IoC Container將實體DI到呼叫者的建構元中,當呼叫者被new(建立)時,就會自動注入相關實體進入建構元。
    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
    27
    public 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);
    }
    }
    在這裡,當有人去new UserService時,IoC Container就會自動注入當初註冊的實體進入UserService的建構元。

  1. 設值方法注入 (Setter Injection)
    透過setter method注入實體,他允許我們在呼叫者實體被創建後(instantiated),才注入相關依賴實體。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    public class UserService
    {
    private ILogger _logger;

    public ILogger Logger
    {
    set { _logger = value; }
    }

    public void AddUser(string userName)
    {
    _logger.Log("User Added: " + userName);
    }
    }
  2. 介面注入 (Interface Injection)
    依賴透過Interface注入近實例中,這個Interface必須定義一個方法來注入依賴,再藉由實例去實作此介面,來實現具體的DI

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    public 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
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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
public interface IChef
{
void Cook();
}

public class Chef : IChef
{
private readonly IIngredients _ingredients;

public Chef(IIngredients ingredients)
{
_ingredients = ingredients;
}

public void Cook()
{
Console.WriteLine("Cooking with " + _ingredients.GetIngredients());
}
}

public interface IIngredients
{
string GetIngredients();
}

public class Ingredients : IIngredients
{
public string GetIngredients()
{
return "Tomatoes, Onions, Garlic, and Spices";
}
}

class Kitchen
{
private static IChef _chef;
private static IIngredients _ingredients;

static Kitchen()
{
_ingredients = new Ingredients();
_chef = new Chef(_ingredients);
}

public static IChef GetChef()
{
return _chef;
}
}

class Program
{
static void Main(string[] args)
{
var chef = Kitchen.GetChef();
chef.Cook();
Console.ReadLine();
}
}
  • 在上面的例子中,’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框架中,註冊實體物件時可以指定其生命週期,分為三種(重要!)

  1. Transient (一次性) : 每次注入時,都建立一個新的實體。
  2. Scoped (作用域) : 每次的Request,都建立一個新的實體,同一個Request下,重複利用同一個實體 (這裡的Request 常指Http Request)。
  3. Singleton (單例) : 使用單例模式(Singleton Pattern),從程式開始到結束,只建立一個實體,每次都重複利用同一個,直到程式被終止。

實作

讓我們繼續以上面餐廳的例子實作
首先定義好相關的class與Interface,其中使用DIP我這邊就不特別提了

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
27
28
29
30
31
32
public interface IChef
{
void Cook();
}

public class Chef : IChef
{
private readonly IIngredients _ingredients;

public Chef(IIngredients ingredients)
{
_ingredients = ingredients;
}

public void Cook()
{
Console.WriteLine("Cooking with " + _ingredients.GetIngredients());
}
}

public interface IIngredients
{
string GetIngredients();
}

public class Ingredients : IIngredients
{
public string GetIngredients()
{
return "Tomatoes, Onions, Garlic, and Spices";
}
}

接著就是我們主要註冊DI實體的地方,在Program.cs的檔案中,我這邊只寫出關鍵的部分。

1
2
3
4
5
6
7
8
// ...

using Microsoft.Extensions.DependencyInjection;

builder.Services.AddScoped<IChef, Chef>();
builder.Services.AddScoped<IIngredients, Ingredients>();

// ...

解釋一下,這邊的builder.Services屬於IServiceCollection類,一但呼叫’AddScoped<IIngredients, Ingredients>()’方法,IoC Container就知道要建立一個Ingredients實體,去對應到程式中的三種DI形式之一,並注入實體讓IIngredients指向,在我們的例子中是「建構元注入」,因此,DI框架透過「反射原理」,知道Chef class中的建構元有IIngredients,透過之前註冊的資訊,IoC Continaer主動建立一個Ingredients實體,並注入到Chef class的建構元中。

整理一下上面的流程

  1. 利用builder.Services.AddScoped<IIngredients, Ingredients>()註冊依賴資訊,以及生命週期給IoC Container
  2. IoC Container利用反射原理,得知Chef class中的建構元有IIngredients,並與之前註冊的依賴資訊做對應
  3. IoC Container建立一個Ingredients實體,並注入進Chef class的建構元中,讓建構元的IIngredients指標指向
  4. 在建構元中,透過建構元參數指向的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進化的文章會有著關鍵作用。