使用 .NET 探討軟體工程中的 Dependency Injection(DI)技巧

陳首吉
14 min readAug 3, 2024

--

名詞解釋

Dependency Injection(相依性注入)是軟體工程領域裡面常見於降低模組耦合度(Coupling)的技巧,用來產生更模組化的程式碼,方便做測試、程式碼擴展以及簡化日後的維護工作(Maintenance)。

其中,Dependency 指的是這個「模組」(可以是一個 Class、一個Method…)所需要使用到的變數、物件……等等。簡而言之,Dependency Injection 是將 Class 或 Method 需要的變數、物件,用參數傳遞的方式從外部傳進來,而非在Method 或 Constructor 的內部自行宣告。

Non-Dependency Injection 例子

/**************** API開發端 ****************/
// Dog.cs
public class Dog
{
public string GetSound()
{
return "Woof!";
}
}

// MyPet.cs
public class MyPet
{
/* 把太明確的 Dependency 直接刻在 Class 內部 */
private readonly Dog _dog; <<<<<<<<<<

public MyPet()
{
/* 對 Dependency 的初始化 */
_dog = new Dog(); <<<<<<<<<<
}

public void Say()
{
Console.WriteLine(_dog.GetSound()); <<<<<<<<<<
}
}

一切看起來都非常合理,MyPet 這個 Class 裡面有 _dog ,然後可以利用 Say() 這個 method 來印出這個寵物的叫聲。

/******************** API使用端 *********************/
/* public class MyPet() */
/* Object Method: */
/* public void MyPet.Say() Print pet's sound. */
/***************************************************/
var pet = new MyPet();
pet.Say(); // Output: Woof!

不過這個狀況,因為 MyPet這個 Class 在 Constructor 內自己初始化了一隻狗( MyPet的 Dependency),其實不是一個彈性的作法;今天若要改成貓、魚、鳥或其他種動物,除了建立新的 Cat、 Fish、Bird 這些新的 Class 之外,還需要額外在MyPet中修改被 <<<<<<<<<<標注的這幾行。因為MyPet 這種寫法本身太過於依賴 Dog,我們會說 MyPetDog 高度耦合。

高度耦合的程式碼,缺乏良好的擴展性(Scalability),因為我們會因為新的需求,除了增加新模組,還需要大幅度的改變既有的程式碼。

Dependency Injection 例子

/**************** API開發端 ****************/
// IAnimal.cs
public interface IAnimal
{
string GetSound();
}

// Dog.cs
public class Dog : IAnimal
{
public string GetSound()
{
return "Woof!";
}
}

// Cat.cs
public class Cat : IAnimal
{
public string GetSound()
{
return "Meow!";
}
}

// MyPet.cs
public class MyPet
{
private readonly IAnimal _animal;

// Dependency 作為 Constructor 的參數被助入
public MyPet(IAnimal animal)
{
_animal = animal;
}

public void Say()
{
Console.WriteLine(_animal.GetSound());
}
}

而這種 Dependency Injection 的方式和上面有很顯著的差異。首先,我們沒有直接在 MyPet 這個 Class 中宣告一個明確的 DogCat ,而是宣告了一個 Interface,讓我們在使用上, _animal這個變數變得非常彈性,能夠接受任何動物的種類(只要這個動物是繼承 IAnimal這個 Interface)。

其次,若有新增動物種類的需求時,只要如法炮製多寫一個 Bird Class 並讓他繼承自 IAnimal 這個 Interface;對於 MyPet這個 Class,我們並不用因為新增新的動物,就修改任何一行程式碼。

/******************** API使用端 *********************/
/* public class Dog() : IAnimal */
/* public class Cat() : IAnimal */
/* */
/* public class MyPet(IAnimal animal) */
/* Object Method: */
/* public void MyPet.Say() Print pet's sound. */
/***************************************************/
var dog = new Dog();
var pet1= new MyPet(dog);
pet1.Say(); // Output: Woof!

var cat = new Cat();
var pet2 = new MyPet(cat);
pet2.Say(); // Output: Meow!

Class 終究是一個「藍圖」的概念,在 Class 中 hardcode 任何太過於明確的宣告或定義,都會稍稍地破壞這個概念,使的這個 Class 的重複使用性(Reusability)降低,例如上述 Non-Dependency Injection 的例子,我們將 _dog 直接刻在 MyPet中很明顯就讓 MyPet這個 Class 失去了彈性。

優點

DI 的優點顯而易見,因為使用 DI 可以有效的模組化程式碼並且簡化相依性的管理(Dependency Management),除了比較好擴充程式碼之外,對於作單元測試的複雜度也可以下降,因為我們只需要 mock 傳入的 dependency 就好。另外 DI 也可以促進 Design Pattern 中的 SRP(單一責任原則),鼓勵開發者將一個 Class 的責任限制在單一功能,促進更清晰的設計。

實務案例

使用 ASP.NET Razor Pages 的架構,撰寫一個簡單的庫存管理系統後端。其中我們必須使用 Dependency Injection 的方式,為每個需要用到資料庫連線的 Class,注入一個 DatabaseService 作為參數。

下面是一個非常不好的例子,除了我們直接將連線資訊 hardcode 到邏輯當中非常危險之外;這種寫法還會讓我們每增加一個 Model,就必須重新把這段 SqlConnectionBuilder 的 code 貼到每個 Model 的邏輯中,這樣會導致我們的連線資訊遍布整個專案,如果哪天連線資訊一修改,我們就必須找到每個 hardcode 的地方,再去一一修正,非常不便於維護。

// WarehouseAvailableStock.cshtml.cs
public class WarehouseAvailableStockModel : PageModel
{
public async Task<IActionResult> OnPostAsync()
{
var builder = new SqlConnectionStringBuilder
{
DataSource = "192.168.122.78,1433",
InitialCatalog = "fwstorage",
UserID = "adminuser",
Password = "IMADMINHAHAHA",
Encrypt = true,
TrustServerCertificate = true,
};

using (var connection = new SqlConnection(builder.ConnectionString))
{
string ViewStockSQL = "SELECT ProductId, Quantity FROM Stock WHERE WarehouseId = @WarehouseId;";
// 執行查看庫存的邏輯......
}
}
}
// WarehouseAddStock.cshtml.cs
public class WarehouseAddStockModel : PageModel
{
public async Task<IActionResult> OnPostAsync()
{
var builder = new SqlConnectionStringBuilder
{
DataSource = "192.168.122.78,1433",
InitialCatalog = "fwstorage",
UserID = "adminuser",
Password = "IMADMINHAHAHA",
Encrypt = true,
TrustServerCertificate = true,
};

using (var connection = new SqlConnection(builder.ConnectionString))
{
string AddNewStockSQL = "INSERT INTO Stock (ProductId, Quantity, WarehouseId) VALUES (@ProductId, @Quantity, @WarehouseId);";
// 執行插入資料的邏輯.......
}
}
}

比較好的作法是

比較好的作法是我們可以新增一個 DatabaseService.cs ,建立一個 DatabaseService Class 來儲存連線資訊,這樣我們在需要連線到資料庫時,只需要把這個 Class 的實體當作參數傳入個別的 Model 中,實現 Dependency Injection 的設計模式。

using Microsoft.Data.SqlClient;

namespace WarehouseManagement.Services
{
public class DatabaseService
{
private readonly SqlConnectionStringBuilder _builder;

// Constructor to accept the connection string
public DatabaseService(string connectionString)
{
_builder = new SqlConnectionStringBuilder(connectionString);
}

public SqlConnection GetConnection()
{
return new SqlConnection(_builder.ConnectionString);
}
}
}

appsettings.json中填入連線資訊並且在 Program.cs (.NET 專案的程式進入點)中 new 一個 DatabaseService的實體。

// appsettings.json
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"DefaultConnection": "Data Source=192.168.122.78,1433;Initial Catalog=fwstorage;User ID=adminuser;Password=IMADMINHAHAHA;Encrypt=True;TrustServerCertificate=True;"
},
"AllowedHosts": "*",
}

// Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRazorPages();
builder.Services.AddControllers();

var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddSingleton(new DatabaseService(connectionString));

如此一來我們就可以免除在每一個 Model 中都 hardcode 連線資訊進去,因為連線資訊已經被當作 Dependency 傳入每個需要用到的地方了!

// WarehouseAvailableStock.cshtml.cs
public class WarehouseAvailableStockModel : PageModel
{
private readonly DatabaseService _databaseService;

public WarehouseAvailableStockModel(DatabaseService databaseService)
{
_databaseService = databaseService;
}

public async Task<IActionResult> OnPostAsync()
{
using (var connection = _databaseService.GetConnection())
{
string ViewStockSQL = "SELECT ProductId, Quantity FROM Stock WHERE WarehouseId = @WarehouseId;";
// rest of the logic
}
}
}
// WarehouseAddStock.cshtml.cs
public class WarehouseAddStockModel : PageModel
{
private readonly DatabaseService _databaseService;

public WarehouseAddStockModel(DatabaseService databaseService)
{
_databaseService = databaseService;
}

public async Task<IActionResult> OnPostAsync()
{
using (var connection = _databaseService.GetConnection())
{
string AddNewStockSQL = "INSERT INTO Stock (ProductId, Quantity, WarehouseId) VALUES (@ProductId, @Quantity, @WarehouseId);";
// rest of the logic
}
}
}

參考資料

--

--