在 .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 外,常見我們還會依 職責 拆分 OrderService 與 PaymentService … 等。

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 直接 new 在 OrderService 內,無法對 PaymentService 做 mock

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

Task


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

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

Architecture


di005

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

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();
}

因為 OrderService 為 PaymentService 的高階模組,由 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();
}

因為 OrderController 為 OrderService 的高階模組,由 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(),其中也因為要呼叫 PaymentService 的 PayCreditCard(),根據 界面隔離原則,因此 IPaymentService 有 PayCreditCard()。

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(),其中也因為要呼叫 OrderService 的 CreateOrder(),根據 界面隔離原則,因此 IOrderService 有 CreateOrder()。

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();
}
}

OrderController 與 OrderService 都已經用了 依賴注入,讓我們可以在 constructor 注入相依的 OrderService 與 PaymentService,傳統我們會這樣建立 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();
}
}

由於 OrderController 與 OrderService 都使用了 依賴注入,所以必須使用巢狀的 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 可以決定低階模組 OrderController 與 OrderService 相依什麼物件,只要在 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

▲