在 .NET Core 也能優雅地使用 DI

.NET Core 已經內建 DI,讓我們可以享受 DI container 實現 依賴注入 的方便,Visual Studio 2017 在 ASP.NET Core Web Application 的 template 中,預設已經可直接使用 DI,若要在 console app 也使用 DI,則需另外設定。

Version


Visual Studio 2017 15.4.5
.NET Core SDK 2.1.101
Microsoft.Extensions.DependencyInjection 2.0.0

User Story


di012

一個常見的分層架構,除了 OrderController 外,常見我們還會依 職責 拆分 OrderServicePaymentService … 等。

OrderController.Index() 呼叫 OrderService.CreateOrder(),然後 OrderService.CreateOrder() 再去呼叫 PaymentService.PayCreditCard()

傳統我們會這樣寫 code :

OrderController.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class OrderController
{
private readonly OrderService orderService;

public OrderController()
{

this.orderService = new OrderService();
}

public Index()
{

this.orderService.CreateOrder();
}
}

OrderService.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class OrderService
{
private readonly PaymentService paymentService;

public OrderService()
{

this.paymentService = new PaymentService();
}

public void CreateOrder()
{

this.paymentService.payCreditCard();
}
}

PaymentService.cs

1
2
3
4
5
6
7
class PaymentService
{
public PayCreditCard()
{

...
}
}

這是傳統 OOP 寫法,但這有幾個問題 :

  • PaymentService 直接被 OrderService new 在裡面,因此 OrderController 只要用了 OrderService 就必須得用 PaymentService,完全沒有改變 PaymentService 的空間,也就是 OrderController 直接耦合 PaymentService ,無法替換 PaymentService
  • 無法對 OrderService 做單元測試,因為 PaymentService 直接 newOrderService 內,無法對 PaymentService 做 mock

這就是典型的 耦合,也就是 高階模組 直接相依於低階模組,然後高階模組就被低階模組綁死了,無法抽換,也無法單元測試。

Task


目前 OrderControllerPaymentService 直接偶合在一起,也就是用戶端只要用了 OrderController,就一定得使用 PaymentService,別無選擇。

現在用戶端希望使用 OrderController 時,還能決定 OrderController 的相依物件,不見的一定要 PaymentService,完全由用戶端決定,也就是對用戶端與 PaymentService 解耦合。

Architecture


di005

OrderControllerOrderServicePaymentService 已經符合 依賴反轉原則,也使用了 依賴注入,但用戶端要注入相依物件還是很複雜,因此要使用 依賴注入容器 簡化 依賴注入

Implementation


安裝 DependencyInjnection 套件

DI 主要是在 Microsoft.Extensions.DependencyInjection 套件裡,console app 預設沒有安裝,需要手動自行安裝。

GUI 安裝

di000

  1. 在 project 選擇 Dependencies
  2. 按滑鼠右鍵選擇 Manage NuGet Packages...

di001

  1. 選擇 Browse
  2. 輸入 DependencyInjection
  3. 選擇 Microsoft.Extensions.DependencyInjection 套件
  4. Install 安裝套件

di002

安裝完成後,在 Dependencies 下的 NuGet 會看到 Microsoft.Extensions.DependencyInjection 套件。

指令安裝

di003

Tools -> NuGet Package Manager -> Package Manager Console

啟動 package manager console

1
PM> Install-Package Microsoft.Extensions.DependencyInjection

di004

使用 Install-Package 指令安裝 Microsoft.Extensions.DependencyInjection 套件。

di002

安裝完成後,在 Dependencies 下的 NuGet 會看到 Microsoft.Extensions.DependencyInjection 套件。

IPaymentService

di009

IPaymentService.cs

1
2
3
4
interface IPaymentService
{
void PayCreditCard();
}

因為 OrderServicePaymentService 的高階模組,由 OrderService 定義出 IPaymentService,由 PaymentService 所依賴。

PaymentService

di006

PaymentService.cs

1
2
3
4
5
6
7
class PaymentService : IPaymentService
{
public void PayCreditCard()
{

Console.WriteLine("PaymentService.PayCreditCard()");
}
}

低階模組 PaymentService 實現 (依賴) IPaymentService

IOrderService

di010

IOrderService.cs

1
2
3
4
interface IOrderService
{
void CreateOrder();
}

因為 OrderControllerOrderService 的高階模組,由 OrderController 定義出 IOrderService,由 OrderService 所依賴。

OrderService

di007

OrderService.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class OrderService: IOrderService
{
private readonly IPaymentService paymentService;

public OrderService(IPaymentService paymentService)
{

this.paymentService = paymentService;
}

public void CreateOrder()
{

this.paymentService.PayCreditCard();
}
}

低階模組 OrderService 實現 (依賴) IOrderService

第 3 行

1
private readonly IPaymentService paymentService;

我們宣告了 IPaymentService 型別的 field,注意此為抽象型別 interface,而非具體型別,所以 OrderService 不會直接依賴 PaymentService,而是依賴 IPaymentService,符合 依賴反轉原則 : 高階模組不直接依賴低階模組,而是依賴抽象

第 5 行

1
2
3
4
public OrderService(IPaymentService paymentService)
{

this.paymentService = paymentService;
}

PaymentService 由 constructor 的參數傳進來,而不是直接 new 在 constructor 內。

如此 OrderService 的相依物件,就可透過 constructor 傳進來,從此高階模組 OrderService 就不再直接相依於低階模組 PaymentService,而是由更高階模組 OrderController 決定 OrderService 該依賴什麼物件,再由 constructor 參數傳進來,這就是 依賴注入

注意 constructor 參數的型別是 IPaymentService,而不是 PaymentService,如此才會符合 依賴反轉原則 : 高階模組不直接依賴低階模組,而是依賴抽象

我們改用 依賴注入 後有幾個優點 :

  • 由原本相依於 PaymentService,改相依於 IPaymentService interface,符合 依賴反轉原則
  • 原本直接在 constructor 去 new,高階模組無法抽換低階模組,現在高階模組可透過 constructor 直接換掉低階模組,原來的 OrderService 程式碼不用變動,符合 開放封閉原則
  • 單元測試時,可直接透過 constructor 注入 mock 物件隔離測試

OOP 心法

若使用 DI 寫法,則不須再使用 new,讓程式碼更為乾淨,類似 static class 只要一行程式碼就可解決

10 行

1
2
3
4
public void CreateOrder()
{

this.paymentService.PayCreditCard();
}

CreateOrder() 去呼叫 PaymentService.PayCreditCard(),其中也因為要呼叫 PaymentServicePayCreditCard(),根據 界面隔離原則,因此 IPaymentServicePayCreditCard()

OrderController

di011

OrderController.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class OrderController
{
private readonly IOrderService orderService;

public OrderController(IOrderService orderService)
{

this.orderService = orderService;
}

public void Index()
{

this.orderService.CreateOrder();
}
}

第 3 行

1
private readonly IOrderService orderService;

我們宣告了 IOrderService 型別的 field,注意此為抽象型別 interface,而非具體型別,所以 OrderController 不會直接依賴 OrderService,而是依賴 IOrderService,符合 依賴反轉原則 : 高階模組不直接依賴低階模組,而是依賴抽象

第 5 行

1
2
3
4
public OrderController(IOrderService orderService)
{

this.orderService = orderService;
}

OrderController 由 constructor 的參數傳進來,而不是直接 new 在 constructor 內。

如此 OrderController 的相依物件,就可透過 constructor 傳進來,從此高階模組 OrderController 就不再直接相依於低階模組 OrderService,而是由更高階模組 Program 決定 OrderController 該依賴什麼物件,再由 constructor 參數傳進來,這就是 依賴注入

注意 constructor 參數的型別是 IOrderService,而不是 OrderService,如此才會符合 依賴反轉原則 : 高階模組不直接依賴低階模組,而是依賴抽象

第 10 行

1
2
3
4
public void Index()
{

this.orderService.CreateOrder();
}

Index() 去呼叫 OrderService.CreateOrder(),其中也因為要呼叫 OrderServiceCreateOrder(),根據 界面隔離原則,因此 IOrderServiceCreateOrder()

Program

di008

Program.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Program
{
static void Main(string[] args)
{

var services = new ServiceCollection();
services.AddTransient<OrderController>();
services.AddTransient<IOrderService, OrderService>();
services.AddTransient<IPaymentService, PaymentService>();

var serviceProvider = services.BuildServiceProvider();

OrderController orderController = serviceProvider.GetService<OrderController>();
orderController.Index();
}
}

OrderControllerOrderService 都已經用了 依賴注入,讓我們可以在 constructor 注入相依的 OrderServicePaymentService,傳統我們會這樣建立 OrderController :

1
2
3
4
5
6
7
8
9
10
11
12
class Program
{
static void Main(string[] args)
{

OrderController orderController = new OrderController(
new OrderService(
new PaymentService()
)
);
orderController.Index();
}
}

由於 OrderControllerOrderService 都使用了 依賴注入,所以必須使用巢狀的 new 才能將 低階模組 的相依物件一一注入。

目前只是很簡單的 3 個 service,用戶端就已經出現很複雜的寫法,實務上的 低階模組 會多,new 的寫法會更加複雜。

此時我們需要使用 依賴注入容器 (DI Container / IoC Container)。

第 5 行

1
2
3
4
var services = new ServiceCollection();
services.AddTransient<OrderController>();
services.AddTransient<IOrderService, OrderService>();
services.AddTransient<IPaymentService, PaymentService>();

要讓 DI container 幫我們 new,首先必須告訴 DI container :

當遇到 xxx interface 時,請幫我注入 yyy class

.NET 提供了 ServiceCollection,由它負責蒐集所有 interface 與 class 的 mapping。

  • AddTrasient() : 每次注入時,都重新 new 一個新的 instance
  • AddScoped() : 每個 request 都重新 new 一個新的instance
  • AddSingleton() : 程式啟動後會 new 一個 instance,之後會重複使用,也就是運行期間只有一個 instance

泛型參數部份 :

  • 若只有 class,沒有 interface,如 OrderController :
    • 只須傳入第一個參數為 class
  • 若有 interface 與對應 class
    • 第一個參數為 interface
    • 第二個參數為 class

也就是告訴 ServiceCollection,當 constructor 的型別為此 interface 時,請幫我 new 這個 class。

1
2
3
public static IServiceCollection AddTransient<TService, TImplementation>(this IServiceCollection services)
where TService : class
where TImplementation : class, TService;

其中 TImplementation 的 constraint 為 TService,也就是若你這樣寫

1
services.AddTransient<IPaymentService, OrderService>();

因為 OrderService 根本沒有實作 IPaymentService,編譯會錯誤。

OOP 心法

在使用泛型時,一定要搭配 constraint,才會發揮泛型的威力

10 行

1
var serviceProvider = services.BuildServiceProvider();

DI container 要靠 service provider 來建立 object,所以下一步要靠 ServiceCollection.BuildServiceProvider() 先產生 ServiceProvider

12 行

1
OrderController orderController = serviceProvider.GetService<OrderController>();

之後就可用 ServiceProvider.GetService() 來建立 object,而不須使用巢狀 new

DI 常犯錯誤

  • 若只有 class,則 GetService() 泛型參數傳入 class
  • 若有 interface,則 GetService() 泛型參數要傳的是 interface,而不是 class,因為 constructor 宣告的是 interface 型別,不是 class 型別。

Q : 使用 service provider 建立 object,與自己 new object,差別在哪裡 ?

A : 你只須用 service provider 建立最外層的 object 即可,剩下相依物件,DI container 會幫你建立;但若你使用 new,怎每一層的相依物件都要自己處理。

13 行

1
orderController.Index();

orderController 已經被 DI container 建立,就可以使用一般的方式使用 method。

Q : 依賴注入與 DI container 除了讓我們不用複雜的 new ,還有其他優點嗎 ?

1
2
3
4
5
6
7
8
class Program
{
static void Main(string[] args)
{

var orderController = new OrderController();
orderController.Index();
}
}

在沒有使用 依賴注入 與 DI container 之前,Program 只能去 new OrderController(),至於 OrderController 用了哪些相依物件,高階模組 Program 無法決定,也就是高階模組已經被低階模組綁死了,無從決定低階模組到底用了什麼相依物件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Program
{
static void Main(string[] args)
{

var services = new ServiceCollection();
services.AddTransient<OrderController>();
services.AddTransient<IOrderService, OrderService>();
services.AddTransient<IPaymentService, PaymentService>();

var serviceProvider = services.BuildServiceProvider();

OrderController orderController = serviceProvider.GetService<OrderController>();
orderController.Index();
}
}

第 5 行

1
2
3
4
var services = new ServiceCollection();
services.AddTransient<OrderController>();
services.AddTransient<IOrderService, OrderService>();
services.AddTransient<IPaymentService, PaymentService>();

高階模組 Program 可以決定低階模組 OrderControllerOrderService 相依什麼物件,只要在 ServiceCollection 加以定義即可。

也就是高階模組不再相依於低階模組,而是高階模組可以自行定義低階模組的相依物件,符合 依賴反轉原則

Q : 高階模組能決定低階模組有什麼意義呢 ?

一旦高階模組可以決定低階模組,就可以在程式一開始 if else 就決定使用那些低階模組,決定之後,程式就可以很簡單的執行,不須再 if else 判斷。

但若高階模組無法決定低階模組,則在低階模組勢必增加很多 if else 判斷,判斷在什麼條件下,使用什麼低階模組,而且 if else 會分散在各低階模組,程式複雜度會提高。

Conclusion


  • Console app 無法使用 DI container,須自行安裝 Microsoft.Extensions.DependencyInjection,可使用 GUI 安裝,可以使用指令安裝
  • DI 可以讓程式碼更乾淨,用起來類似 static 只要一行
  • DI container 讓 依賴注入 更容易實現,尤其在用戶端可以完全掌握 低階模組 的相依物件,完全符合 依賴反轉原則

Sample Code


完整的範例可以在我的 GitHub 找到。

Reference


John Wu, ASP.NET Core 教學 - Dependency Injection
Microsoft, Introduction to Dependency Injection in ASP.NET Core
Joonas W, ASP.NET Core Dependency Injection Deep Dive
ASP.NET Hacker, Using Dependency Injection in .NET Core Console Apps

2017-11-29