工作中的 C# 實作:Dependency Injection (依賴注入)

AdSense

什麼是依賴注入?

依賴注入 (Dependency Injection,簡稱 DI) 是一種軟體設計模式,用來降低程式碼模組之間的耦合性 (Coupling)。

想像一下,你今天想煮一杯咖啡:

  • 不使用 DI:你需要自己準備咖啡豆、磨豆機、濾杯和熱水壺。你煮咖啡的動作和這些工具是緊密相連的,如果你想換成用咖啡機,你必須修改你煮咖啡的方式。
  • 使用 DI:你只需要專注於「煮咖啡」這個動作。而一個「管家」會為你準備好所有東西,並直接送到你面前。你只需要告訴管家你需要「一杯熱騰騰的咖啡」,不需要知道這些東西是從哪裡來的。

在程式設計中,這個「管家」就是 DI 容器 (DI Container),它負責建立 (或實例化) 你需要的物件 (依賴),並將它們自動傳遞給需要這些物件的類別。

為什麼需要依賴注入?

DI 模式帶來幾個重要的好處:

  • 降低耦合性 (Decoupling):你的程式碼變得更加模組化,每個類別只專注於自己的功能,不關心它所依賴的物件是怎麼被建立的。這使得修改或替換依賴變得非常容易。
  • 提高可測試性 (Testability):當你的類別不自己建立依賴物件,而是由外部傳入時,你可以輕鬆地在單元測試中,用模擬 (Mock) 或假的 (Fake) 物件來替換真實的依賴。
  • 提高可維護性 (Maintainability):程式碼結構清晰、職責單一,更容易維護和擴充。

核心概念

  1. 依賴 (Dependency):一個類別或元件所需要的物件。例如,煮麵的例子中,麵條和牛肉就是依賴。
  2. 依賴注入 (Injector):負責建立依賴物件並將其提供給依賴者。在 DI 模式中,這通常是一個 DI 容器 (DI Container)。
  3. 依賴者 (Dependent):需要使用依賴的類別或元件。煮麵的人就是依賴者。

依賴注入的實作方式

在 C# 中,依賴注入主要有三種常見的實作方式:

1. 建構子注入 (Constructor Injection)

這是最常見也最推薦的方式。透過類別的建構子來接收它所需要的依賴物件。

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
// 抽象化:定義一個介面,讓其他類別依賴這個介面
public interface IEmailSender
{
void Send(string to, string subject, string body);
}

// 實作:建立一個類別來實作介面
public class EmailSender : IEmailSender
{
public void Send(string to, string subject, string body)
{
Console.WriteLine($"寄送郵件給: {to}");
}
}

// 依賴者:透過建構子接收 IEmailSender 的實例
public class OrderService
{
private readonly IEmailSender _emailSender;

public OrderService(IEmailSender emailSender) // 依賴注入發生在這裡
{
_emailSender = emailSender;
}

public void PlaceOrder(string customerEmail)
{
// OrderService 只知道它需要一個能寄信的東西 (IEmailSender),不關心是哪個實作類別
_emailSender.Send(customerEmail, "訂單確認", "您的訂單已成功建立。");
}
}

2. 屬性注入 (Property Injection)

將依賴作為類別的公開屬性 (public property),然後在類別外部進行設定。這種方式彈性較高,但容易造成物件狀態不一致。

1
2
3
4
5
6
7
8
9
public class OrderService
{
public IEmailSender EmailSender { get; set; }

public void PlaceOrder(string customerEmail)
{
EmailSender.Send(customerEmail, "訂單確認", "您的訂單已成功建立。");
}
}

3. 方法注入 (Method Injection)

將依賴作為方法的參數傳入。適用於只有單一方法需要特定依賴的情境。

1
2
3
4
5
6
7
public class OrderService
{
public void PlaceOrder(string customerEmail, IEmailSender emailSender)
{
emailSender.Send(customerEmail, "訂單確認", "您的訂單已成功建立。");
}
}

服務註冊與生命週期

在 .NET Core 應用程式中,我們使用內建的 DI 容器來管理和注入服務。這通常在 Program.cs 檔案中設定。

服務註冊的方式

使用 builder.Services 來告訴 DI 容器如何提供服務。

1
2
3
4
5
6
7
8
var builder = WebApplication.CreateBuilder(args);

// 將 IEmailSender 和 EmailSender 註冊到 DI 容器中
builder.Services.AddTransient<IEmailSender, EmailSender>();

var app = builder.Build();

// ...

服務的生命週期

註冊服務時,你必須指定它的生命週期,這決定了 DI 容器何時建立和釋放服務的實例。

名稱 方法 說明 適用情境
瞬時 (Transient) AddTransient<TInterface, TClass>() 每次從 DI 容器取得服務時,都會建立一個新的實例。 輕量、無狀態的服務,例如 EmailSender
範圍 (Scoped) AddScoped<TInterface, TClass>() 單一 HTTP 請求的範圍內,只會建立一個實例。請求結束時釋放。 適用於需要維持請求狀態的服務,例如 EF Core 的 DbContext。因為 DbContext 需要在一個請求中追蹤所有變更,以確保資料的一致性。
單例 (Singleton) AddSingleton<TInterface, TClass>() 整個應用程式的生命週期中,只會建立一個實例。 適用於共享配置、快取或日誌服務。

只註冊實作類別

當你只註冊具體的類別時,例如 builder.Services.AddTransient<OrderService>();,DI 容器的註冊表裡會有一筆記錄:keyOrderServicevalue 也是 OrderService。DI 容器會知道如何建立 OrderService 的實例。然而,它不知道這個類別是否對應任何介面。

因此,只能在需要 OrderService 本身的建構子中注入,而不能在需要 IOrderService 介面的建構子中注入。這也是為什麼我們強烈建議總是透過介面來註冊和注入服務,以實現真正的低耦合。

需要 OrderService 的類別,DI 容器會去註冊表裡找 OrderService,然後成功地找到並注入。

1
2
3
4
5
6
7
8
// 這個可以正常運作
public class MyController
{
public MyController(OrderService orderService)
{
//...
}
}

需要 IOrderService 的類別,DI 容器會去註冊表裡找 IOrderService,找不到然後出錯。

1
2
3
4
5
6
7
8
// 這個會出錯!
public class AnotherService
{
public AnotherService(IOrderService orderService) // DI 容器找不到 IOrderService 的註冊
{
//...
}
}

TryAdd... 方法

除了我們之前提到的 AddTransientAddScopedAddSingleton 之外,還有它們對應的 TryAdd... 方法。

Add... vs. TryAdd...:主要差異

最簡單的區別就是:

  • Add... 系列:如果你多次註冊同一個服務,DI 容器會保留所有註冊,並依據你最後註冊的那個來提供實例
  • TryAdd... 系列:如果你要註冊的介面服務已經被註冊過,它就會忽略這次的註冊不會重複註冊

什麼時候用 TryAdd...

TryAdd... 系列方法最適合用在寫擴充套件 (Extension) 或函式庫時。

想像一下,你正在開發一個名為 MyLogger 的 NuGet 套件。這個套件需要一個 ILogger 的實作。你希望使用者安裝你的套件後,ILogger 服務就能自動被註冊到 DI 容器裡。

  • 使用 Add...
1
2
3
4
5
6
7
8
9
10
// 在你的套件裡
public static IServiceCollection AddMyLogger(this IServiceCollection services)
{
services.AddSingleton<ILogger, MyLogger>();
return services;
}

// 使用者端
builder.Services.AddLogging(); // 微軟內建的日誌服務
builder.Services.AddMyLogger(); // 你的套件

這個時候,因為 AddLogging() 已經註冊了 ILogger,你的 AddMyLogger() 又再次註冊了它,DI 容器最終會使用你套件提供的 MyLogger。這可能會覆寫使用者原本的設定,造成非預期的行為。

  • 使用 TryAdd...
1
2
3
4
5
6
7
8
9
10
// 在你的套件裡
public static IServiceCollection AddMyLogger(this IServiceCollection services)
{
services.TryAddSingleton<ILogger, MyLogger>();
return services;
}

// 使用者端
builder.Services.AddLogging(); // 微軟內建的日誌服務
builder.Services.AddMyLogger(); // 你的套件

這樣一來,你的 TryAddSingleton 會檢查 ILogger 是否已經被註冊。因為 AddLogging() 已經註冊過了,你的 TryAddSingleton 會自動忽略這次註冊,確保使用者原本的設定不會被意外覆蓋。

如何在 .NET Framework 使用 DI

在 .NET Framework 時代,沒有內建的依賴注入 (Dependency Injection,DI) 容器。所以,你無法直接在 .NET Framework 的專案中,像在 .NET Core 裡那樣,使用 services.AddTransient<...>() 這類語法。

在 .NET Framework 中,如果我們想要實作 DI,通常會採用以下幾種方式:

1. 手動建立物件 (Manual Dependency Injection)

這是最原始的做法。當你需要一個服務時,自己手動 new 出來,然後傳入依賴的服務。這在程式碼少的時候還行,但當服務變多變複雜時,就會變得難以維護。

2. 使用第三方 DI 容器

這是 .NET Framework 時代最主流的做法。許多知名的第三方 DI 容器,例如:

  • Autofac
  • Ninject
  • Unity
  • StructureMap

這些套件都提供了類似 AddTransient 的功能,讓你可以在應用程式啟動時,集中註冊所有服務。以 Autofac 為例,它的註冊語法會是這樣:

1
2
3
4
5
6
7
8
// 建立一個 DI 容器的 Builder
var builder = new ContainerBuilder();

// 註冊服務
builder.RegisterType<EmailSender>().As<IEmailSender>().InstancePerDependency();

// 建立容器
var container = builder.Build();

這段程式碼中的 InstancePerDependency() 就相當於 .NET Core 裡的 AddTransient(),代表每次請求時都建立一個新的實例。

3. .NET Framework 4.7.2 的支援

微軟後來在 .NET Framework 4.7.2 開始,也對依賴注入做了一些整合,主要是為了讓它能更好地支援 ASP.NET Core 的一些功能。但這個內建的 DI 容器功能比較簡單,且不像 .NET Core 那樣是核心設計的一部分。