與 ASP.NET MVC 相比,ASP.NET Core 架構上更傾向靠依賴注入(Dependency Injection)處理服務物件的傳遞, 造成一項非常有感的改變 - 過去一些慣用靜態物件或方法解決的情境,在 ASP.NET Core 要改成從建構式參數取得才能引用。

舉兩個典型例子:

  1. 使用記憶體快取過去直接抓 MemoryCache.Default 來用就可以了,在 ASP.NET Core MVC Controller 如果要用 MemoryCache, 得先在 Startup 中註冊服務 (services.AddMemoryCache()),Controller 建構式則要寫 public MyController(IMemoryCache memoryCache) 才能取得 MemoryCache 實體。
  2. 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 容器提供三種選項:

  1. Singleton
    整個 Process 只建立一個 Instance,任何時候都共用它。
  2. Scoped
    在網頁 Request 處理過程(指接到瀏覽器請求到回傳結果前的執行期間)共用一個 Instance。
  3. 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);

注入

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 的系統功用﹙不是個人的功能﹚,那當然要登記吧!直接免除,在生成時加入也合理

Post a comment


59 + 9 =