IoC(控制反轉)和 DI(依賴注入)常被混用,但兩者的層次不同:IoC 是設計原則,DI 是實現手段之一。搞清楚這個關係,才不會看到「容器」兩字就覺得是 IoC 的全部。

控制反轉 (IoC) 是什麼

傳統寫法裡,程式碼自己決定何時建立依賴、用哪個實作——控制權在你的類別手上。IoC 把這個控制權交出去:由外部(框架、容器、或呼叫方)決定要注入什麼。

IoC 並不等於 DI 容器。Martin Fowler 最早定義 IoC 時,涵蓋了 Service Locator、Template Method、以及依賴注入等多種形式。DI 容器只是實現 IoC 最常見的一種工具。

IoC 帶來的實際好處:

  • 解耦:類別依賴介面而非具體實作,替換實作時不需動到使用方。
  • 可測試性:測試時可把真實依賴換成 mock,不需啟動整個應用程式。
  • 可擴展性:新增實作、切換策略,不需修改現有程式碼。

這三點都源自同一個機制——類別不再自己 new 依賴,而是被動接受注入。

依賴注入 (DI) 的注入方式

DI 的核心想法:不在類別內部建立依賴物件,改由外部傳入。

注入方式有三種,但在 .NET 內建 DI 容器的情境下,實際可用的只有建構函式注入:

建構函式注入(.NET 內建容器的唯一原生支援方式):

1
2
3
4
5
6
7
8
9
public class OrderService
{
private readonly IPaymentGateway _payment;

public OrderService(IPaymentGateway payment)
{
_payment = payment;
}
}

屬性注入方法注入在 .NET 內建 DI 容器中並非原生支援。若要使用,需要改用 Autofac 或 Ninject 等第三方容器。直接在屬性上加 [Inject] 在內建容器下不會有任何效果,這是新手常踩的坑。

.NET Core 內建 DI 容器

註冊服務

.NET 6+ 的 Program.cs:

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

builder.Services.AddTransient<IMyService, MyService>();
builder.Services.AddScoped<IUserRepository, UserRepository>();
// ILogger<T> 由框架自動註冊,不需要手動 AddSingleton<ILogger, ...>

var app = builder.Build();
app.Run();

.NET 5 以前的 Startup.cs:

1
2
3
4
5
6
7
8
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddTransient<IMyService, MyService>();
services.AddScoped<IUserRepository, UserRepository>();
}
}

控制器使用:

1
2
3
4
5
6
7
8
9
10
11
public class MyController : Controller
{
private readonly IMyService _myService;
private readonly ILogger<MyController> _logger;

public MyController(IMyService myService, ILogger<MyController> logger)
{
_myService = myService;
_logger = logger;
}
}

生命週期管理

.NET DI 容器提供三種生命週期:

  • Transient:每次從容器解析(resolve)時建立新實例。注意「每次解析」不等於「每次 HTTP 請求」——同一個請求裡如果有兩個類別都依賴同一個 Transient 服務,兩者拿到的是不同實例。
  • Scoped:同一個請求(或範圍)內共用同一實例,跨請求則各自獨立。
  • Singleton:整個應用程式生命週期共用同一實例,只建立一次。

進階用法

工廠方法(Transient 的進階寫法):

當建立服務需要額外參數或執行時期邏輯時,用 AddTransient 的 factory 多載:

1
2
3
4
5
6
builder.Services.AddTransient<IEmailSender>(provider =>
{
var config = provider.GetRequiredService<IConfiguration>();
var smtpHost = config["Smtp:Host"];
return new SmtpEmailSender(smtpHost);
});

同一介面的多個實作:

1
2
builder.Services.AddTransient<INotification, EmailNotification>();
builder.Services.AddTransient<INotification, SmsNotification>();

取出所有實作用 GetServices<T>()(注意是複數):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class NotificationDispatcher
{
private readonly IEnumerable<INotification> _channels;

public NotificationDispatcher(IEnumerable<INotification> channels)
{
_channels = channels;
}

public async Task SendAll(string message)
{
foreach (var ch in _channels)
await ch.Send(message);
}
}

條件註冊(依環境切換實作):

1
2
3
4
if (builder.Environment.IsDevelopment())
builder.Services.AddTransient<IPaymentGateway, FakePaymentGateway>();
else
builder.Services.AddTransient<IPaymentGateway, StripePaymentGateway>();

最佳實踐

優先建構函式注入。 依賴清單一目了然,且強迫你在類別被建立時就完成初始化,不存在「物件存在但依賴還沒設定」的中間狀態。

依賴抽象,不依賴具體實作。 IUserRepository 而非 SqlUserRepository,這樣日後換成 Redis 或 Cosmos DB 不需改動使用方。

避免 Service Locator 模式。 直接注入 IServiceProvider 再手動 GetService<T>() 是反模式——依賴關係藏在方法體裡,測試時無法從建構子看出類別需要什麼。

注意生命週期陷阱(Captive Dependency)。 Singleton 服務若持有 Scoped 服務,Scoped 服務會被「捕獲」成事實上的 Singleton——它在整個應用程式生命週期只建立一次,不會隨請求重置,導致資料隔離失效。.NET 在開發模式下預設開啟 scope validation,會在啟動時直接丟例外,但正式環境預設關閉,要特別留意。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 危險:Singleton 持有 Scoped,會在容器建立時被鎖定
builder.Services.AddSingleton<IOrderProcessor, OrderProcessor>();
builder.Services.AddScoped<IUserContext, UserContext>(); // 被捕獲,不會隨請求重置

// 正確做法:改用 IServiceScopeFactory 在需要時自行建立 scope
public class OrderProcessor : IOrderProcessor
{
private readonly IServiceScopeFactory _scopeFactory;

public OrderProcessor(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}

public void Process()
{
using var scope = _scopeFactory.CreateScope();
var userContext = scope.ServiceProvider.GetRequiredService<IUserContext>();
// userContext 現在是正確的 Scoped 實例
}
}