筆記 - 不可不知的 ASP.NET Core 依賴注入
20 | 60,432 |
與 ASP.NET MVC 相比,ASP.NET Core 架構上更傾向靠依賴注入(Dependency Injection)處理服務物件的傳遞, 造成一項非常有感的改變 - 過去一些慣用靜態物件或方法解決的情境,在 ASP.NET Core 要改成從建構式參數取得才能引用。
舉兩個典型例子:
- 使用記憶體快取過去直接抓 MemoryCache.Default 來用就可以了,在 ASP.NET Core MVC Controller 如果要用 MemoryCache, 得先在 Startup 中註冊服務 (services.AddMemoryCache()),Controller 建構式則要寫 public MyController(IMemoryCache memoryCache) 才能取得 MemoryCache 實體。
- ASP.NET MVC Controller 以外的外部類別想取得網站實體路徑可以用靜態方法 HostingEnvironment.MapPath(), 在 ASP.NET Core 則要在建構式接入 IHostingEnvironment,再依 ContentRootPath 或 WebRootPath 推算。 而這些外部類別還需在 Startup ConfigureServices(IServiceCollection services) 以 services.AddTransient()/AddSignleton() 方法註冊, 由 DI 容器統一處理,才能經由依賴注入取得 IHostingEnvironment、IMemoryCache。
上述是我轉到 ASP.NET Core 後最不習慣的差異,過去太習慣用靜態物件解決問題,轉成 ASP.NET Core 寫法老覺得卡卡,感覺問題出在心法, 該做做功課匡正觀念再出發。
在網路上翻到 Azure MVP Joonas Westlin 有篇簡單扼要的 ASP.NET Core DI 介紹 - ASP.NET Core Dependency Injection Deep Dive,認真啃完,筆記備忘。
依賴注入(Dependency Injection,以下簡稱 DI)是 ASP.NET Core 的核心,目的在減少物件相依性,能大幅提升程式的可測試性。 而為了避免程式綁死在特定實作上,ASP.NET Core 習慣另外定義介面,平日使用介面而不直接使用底層物件 (如前面 MemoryCache 的例子,建構式參數型別使用 IMemoryCache 而非 MemoryCache) 最大好處是可以神不知鬼不覺地抽換實作方式,程式依然運作如常,這個特性在寫單元測試時格外珍貴。
註:如果對 IoC/DI 原理陌生,我有幾篇 Autofac 筆記有較深入的說明可供參考。
服務生命週期
服務生命週期指:透過 DI 取得某個元件時,是每次要求得建立一顆新元件,還是從頭到尾共用一個 Instance (執行個體)。
ASP.NET DI 容器提供三種選項:
- Singleton
整個 Process 只建立一個 Instance,任何時候都共用它。 - Scoped
在網頁 Request 處理過程(指接到瀏覽器請求到回傳結果前的執行期間)共用一個 Instance。 - Transient
每次要求元件時就建立一個新的,永不共用。
被註冊到 DI 容器的元件間也會彼此依賴,有個原則是生命週期長的不能參考生命週期比他短的,例如:註冊為 Singleton 的元件不能依賴註冊成 Scoped 的元件, 原因很明顯,如果生命週期比自己短,可能所依賴元件在後期已被消減無法使用。
一般來說,要整個 Process 共用一份的服務可註冊成 Singleton,EF Context (提醒:不能跨執行緒共用) 建議註冊成 Scoped,以方便 DB 連線重複使用, 若想各用各的避開彼此打架的風險,註冊成 Transient 就對了。
服務註冊
註冊動作寫在 Startup.cs 的 ConfigureServices(IServiceCollection services) 方法。寫法為:
//通用寫法
services.Add(new ServiceDescriptor(typeof(IDataService), typeof(DataService), ServiceLifetime.Transient));
//精簡寫法
services.AddTransient<IDataService, DataService>();
//未另宣告介面,直接使用實作型別
services.AddTransient<DataService>();
//註冊Scoped或Singleton,做法相同
services.AddSingleton<GlobalService>();
services.AddScoped<MyService>();
註冊時也可以選擇自行處理建立物件的細節:
services.AddTransient<IDataService, DataService>((ctx) =>
{
IOtherService svc = ctx.GetService<IOtherService>();
//GetService<T>找不到服務時會傳回null,若不允許可改用GetRequiredService<T>
//找不到時丟出例外
//IOtherService svc = ctx.GetRequiredService<IOtherService>();
return new DataService(svc);
});
有種特殊情況是 Singleton 服務實作兩種介面,可選擇兩個介面共用一個 Instance,或一個介面一個 Instance:
//一個介面一個 Instance
services.AddSingleton<IDataService, DataService>();
services.AddSingleton<ISomeInterface, DataService>();
//兩個介面共用一個 Instance
var dataService = new DataService();
services.AddSingleton<IDataService>(dataService);
services.AddSingleton<ISomeInterface>(dataService);
建立服務時如需其他相依服務,可使用 BuildServiceProvider:
IServiceProvider provider = services.BuildServiceProvider();
IOtherService otherService = provider.GetRequiredService<IOtherService>();
var dataService = new DataService(otherService);
services.AddSingleton<IDataService>(dataService);
services.AddSingleton<ISomeInterface>(dataService);
[2020-11-14 更新] ASP.NET Core 3.0 起不再建議於 Configure() 中使用 BuildServiceProvider,將會產生 ASP0000 Calling 'BuildServiceProvider' from application code results in an additional copy of singleton services being created. Consider alternatives such as dependency injecting services as parameters to 'Configure'. 警告,依據官方文件,可使用內建的 DI 做法,如:
services.AddSingleton<IDataService>(
(svc) => new DataService(svc.GetRequiredService<IOtherService>()));
注入
ASP.NET Core 標準的元件注入做法是透過建構式參數,在某些情境還有其他選項,但建構式法能確保一旦缺少依賴元件就停擺, 避免跑出無法預期的結果。(有點像強型別檢查)
以 LoggingMiddleware 為例示範三種注入元件的方法:(建構式、Invoke 參數、HttpContext.RequestServices)
public class LoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly IDataService _svc;
//建構式接收IDataService
public LoggingMiddleware(RequestDelegate next, IDataService svc)
{
_next = next;
_svc = svc;
}
//Invoke方法加上IDataService參數,執行時也會注入,缺了會有InvalidOperationException
public async Task Invoke(HttpContext ctx, IDataService svc2)
{
//HttpContext.RequestServices即前段最後例子中的IServiceProvider
IDataService svc3 = ctx.RequestServices.GetService<IDataService>();
Debug.WriteLine("Request starting");
await _next(ctx);
Debug.WriteLine("Request complete");
}
}
一般不建議用 RequestServices 動態取得,除非是缺少服務也要可以執行的特殊情境。
Startup 類別
預設的 Startup 建構式只有 IConfiguration 參數,一般會再加上 IHostingEnvironment、ILoggerFactory 方便設定網站及 Log 機制。
Startup 有兩個方法:ConfigureServices() 用來註冊服務,Configure() 用於設定 Pipeline。 在 Configure 方法可以加入自訂服務介面或型別當參數,方便設定 Pipeline 時引用。
如果你不是用標準的 MVC 架構,而是用 app.Run()/app.Use()/app.UseWhen()/app.Map() 自建簡單 Pipeline, 就無法像 MVC 靠 Controller 建構式注入元件,此時要從 DI 容器取得物件。 設定階段用 app.ApplicationServices.GetService<T>();Request 執行階段則用 app.RequestServices.GetService<T>()。
MVC Core DI
MVC Controller 注入元件的做法主要是透過建構式參數,以下是典型做法。 除此之外,我們也可以在 Action 加上 [FromServices] Attribute 要求 ASP.NET Core 注入元件。
public class HomeController : Controller
{
private readonly IDataService _dataService;
public HomeController(IDataService dataService)
{
_dataService = dataService;
}
[HttpGet]
public IActionResult Index([FromServices] IDataService dataService2)
{
IDataService dataService3 = HttpContext.RequestServices.GetService<IDataService>();
return View();
}
}
在 cshtml 最上方可透過 @inject IViewLocalizer Localizer 以 DI 建立 IViewLocalizer 型別放入變數 Localizer 備用。但請節制使用,複雜邏輯最好還是集中在 Controller。
Tag Helper、View Component、ActionFilter 要注入元件也都是透過建構式。
但要注意 ActionFilter 使用建構式注入參數後,要改用以下方式套用 Controller。 TypeFilterAttribute 及 ServiceFilterAttribute 會透過 DI 取得 ActionFilter 所需服務建立物件並套用在 Controller 上, 使用 ServiceTypeFilter 的話必須在 Startup ConfigureServices 註冊 TestActionFilter。 若要註冊成全域使用,則可寫在 AddMvc()。
註:ServiceTypeAttribute 與 TypeFilterAttribute 的差異在於 ServiceTypeAttribute 是透過 DI 容器解析,我們可以決定元件的生命週期, 而 TypeFilterAttribute 使用 Microsoft.Extensions.DependencyInjection.ObjectFactory,解析對象(TestActionFilter)不需註冊, 但它建構式依賴的服務需要透過 DI 容提供。參考
[TypeFilter(typeof(TestActionFilter))]
public class HomeController : Controller
{
}
// 或
[ServiceFilter(typeof(TestActionFilter))]
public class HomeController : Controller
{
}
// 註冊
public void ConfigureServices(IServiceCollection services)
{
//...略...
services.AddTransient<TestActionFilter>();
//...略...
services.AddMvc(mvc =>
{
//相當於為每個Controller加上[TypeFilter(typeof(TestActionFilter))]
mvc.Filters.Add(typeof(TestActionFilter));
});
}
HttpContext
在 ASP.NET Core 沒有 HttpContext.Current 可用,如果要在 Controller/View 以外使用 HttpContext,要使用 IHttpContextAccessor。
public class DataService : IDataService
{
private readonly HttpContext _httpContext;
public DataService(IHttpContextAccessor contextAccessor)
{
_httpContext = contextAccessor.HttpContext;
}
//...
}
Notes of ASP.NET Core DI practices
Comments
# by Ike
「我們也可以在 Action 參加透過 [FromServices]」 裡面是不是有錯字?
# by Ike
留言的 Captcha 考倒我了 85 + 3 我填入 88 它回應我「Error: Captcha validation failed」
# by Jeffrey
to lik, 沒錯,反覆斟酌用字結果糊成一團 Orz 感謝指正。 至於Captcha問題我也遇過一兩次,不過一直無法有效重視難以調查。我把它視為偶爾出現的"薛丁格計算等式" XD
# by tomexou
有時候為了簡便處理,也是會建一個靜態的global物件來串接所有的服務模組,畢竟mvc web介面只是其中一個模組,考慮到的是更高的效能。不過我會儘量符合每一框架的規範,在易於維護的考量下去作衡量,就像EF很多人卡在linq及sql效能衝突下,其實轉個念頭只要ado包得像linq一樣,也是很好擴充及維護的。
# by DarkGuo
在跨專案的情境下,感覺還是靜態屬性比較方便,省得傳遞來去。
# by 卡比
希望將來會智能一點,既然用戶調用某些需要 DI 的系統功用﹙不是個人的功能﹚,那當然要登記吧!直接免除,在生成時加入也合理
# by Lik
TypeFilterAttribute 及 ServiceFilterAttribute 有沒有辦法應用在 asp.net mvc5 上呢?
# by guest
謝謝,好清楚。每次有.net 問題,你的文章是茫茫大海中一盞明燈阿。
# by Mike
Save my day!! 有不懂果然就是找這裡最快XD
# by Calvin
您好: 請問目前「建立服務時如需其他相依服務,可使用 BuildServiceProvider」這個寫法使用後有 ASP0000 Calling 'BuildServiceProvider' from application code results in an additional copy of singleton services being created. Consider alternatives such as dependency injecting services as parameters to 'Configure'. 的錯誤,請問有建議的使用方法嗎
# by Jeffrey
to Calvin, 看起來是 ASP.NET Core 3.0 起做了調整(2.2 的文件沒有,3.0+ 出現),我補充於內文了。
# by Feng
to 黑大 最近在看DI相當模糊,尤其他的優點更是完全看不懂 是否可以寫一篇關於優點的範例呢? 不明白的地方是:如果一個service僅在一個地方使用,運用DI是否有其優點及必要性呢? 感謝!
# by Jeffrey
to Feng,這篇介紹 Autofac 觀念的文章 https://blog.darkthread.net/blog/autofac-notes-1/ ,有舉了一個範例,在完全不修改主要程式的情況下抽換不同功能的元件。如果要做單元測試,這超級重要。 例如:某個依賴 WebApi 提供內容的函式,單元測試期間需模擬不同 WebAPI 傳回結果,此時可透過 DI 將 WebAPI 服務元抽換成假元件傳回特定值以驗證不同狀況下邏輯正確。範例:https://blog.darkthread.net/blog/aspnet-core-di-practice/ 另一種案例,系統原本透過 DI 取得連 MSSQL 的資料庫服務元件,只需修改 services.AddTransient() 換成 SQLite 版服務元件,系統完全不用修改就從 MSSQL 換成 SQLite。 用程式碼複雜化換取易於測試及更換實作細節時程式不用修改。但如果沒寫測試,或程式簡單到永遠不會更動底層,的確較難體會其優點。
# by Jerry
To 黑大: 「被註冊到 DI 容器的元件間也會彼此依賴,有個原則是生命週期長的不能參考生命週期比他短的,例如:註冊為 Singleton 的元件不能依賴註冊成 Scoped 的元件, 原因很明顯,如果生命週期比自己短,可能所依賴元件在後期已被消減無法使用。」這句話可能是 joonasw 誤解了。 因為這篇微軟文件 https://docs.microsoft.com/en-us/dotnet/core/extensions/dependency-injection#scope-validation 有一句話「If a scoped service is created in the root container, the service's lifetime is effectively promoted to singleton because it's only disposed by the root container when the app shuts down. 」代表如果生命週期短的 Service 被注入到生命週期長的 Service,生命週期短的 Service 的生命週期就會(被迫?)變成跟被注入的 Service 生命週期一樣。因為 Singleton 的 Service 就是被註冊在 root container。
# by Jeffrey
to Jerry, 在 Singleton 使用 Scoped 服務在執行階段會發生 " Cannot consume scoped service 'XXX' from singleton 'OOO'." 錯誤。參考: https://blog.darkthread.net/blog/aspnetcore-use-scoped-in-singleton/ 但這是 2020 遇到的狀況,不確定是否後來版本有改變行為。
# by Jerry
To 黑大: 嗯!我看文件有說在 Development 環境下使用 CreateDefaultBuilder 來建立 host 的情況下,Service Provider 會檢查: 1. Scoped 服務有沒有被 Root Service Provider 解析 2. Scoped 服務有沒有被注入到 Singleton 中 如果上面有其中一個狀況發生就會拋出 Exception。 謝謝黑大的回覆!
# by Jeffrey
to Jerry,謝謝分享,你文件看得真仔細,我也跟著學到新東西。
# by Ho.Chun
請問,如果是 console 類型的專案 AddScoped() 是等同於 AddSingleton() 嗎 ?
# by Jeffrey
to Ho.Chun, Console 專案一般較少會用 Scoped 生命週期,如果要註冊成 Scoped,使用時必須先 CreateScope() 建立 IServiceScope,再 IServiceScope.ServiceProvider.GetRequiredService<T>() 取得 Scoped 物件。類似在 Singleton 引用 Scoped 的做法:https://blog.darkthread.net/blog/aspnetcore-use-scoped-in-singleton/
# by 123
123