原文鏈接:ASP.NET Core Dependency Injection Deep Dive - Joonas W's blog
這篇文章我們來深入探討 ASP.NET Core、MVC Core 中的依賴注入,我們將示范幾乎所有可能的操作把依賴項注入到組件中。
依賴注入是 ASP.NET Core 的核心,它能讓您應用程序中的組件增強可測試性,還使您的組件只依賴於能夠提供所需服務的某些組件。
舉個例子,這里我們有一個接口和它的實現類:
public interface IDataService
{
IList<DataClass> GetAll();
}
public class DataService : IDataService
{
public IList<DataClass> GetAll()
{
//Get data...
return data;
}
}
如果另一個服務依賴於DataService
,那么它們依賴於特定的實現,測試這樣的服務可能會非常困難。如果該服務依賴於IDataService
,那么它們只關心接口提供的契約。實現什么並不重要,它使我們能夠通過一個模擬實現來測試服務的行為。
服務生命周期
在我們討論如何在實踐中進行注入之前,了解什么是服務生命周期至關重要。當一個組件通過依賴注入請求另一個組件時,它所接收的實例是否對該組件的實例來說是唯一的,這取決於它的生命周期。設置生命周期從而決定組件實例化的次數,以及組件是否共享。
在 ASP.NET Core中,內置的DI容器有三種模式:
- Singleton
- Scoped
- Transient
Singleton意味着只會創建一個實例,該實例在需要它的所有組件之間共享。因此始終使用相同的實例。
Scoped意味着每個作用域創建一個實例。作用域是在對應用程序的每個請求上創建的,因此,任何注冊為Scoped
的組件每個請求都會創建一次。
Transient每次請求時都會創建瞬態組件,並且永遠不會共享。
理解這一點非常重要,如果將組件A注冊為單例,則它不能依賴於具有Scoped
或Transient
生命周期的組件。總而言之:
組件不能依賴比自己的生命周期小的組件。
違反這條規則的后果顯而易見,依賴的組件可能會在依賴項之前釋放。
通常,您希望將組件(如應用程序范圍的配置容器)注冊為Singleton
。數據庫訪問類(如 Entity Framework 上下文)建議使用Scoped
,以便可以重復使用連接。但是如果您想並行運行任何東西,請記住 Entity Framework 上下文不能由兩個線程共享。如果您需要這樣做,最好將上下文注冊為Transient
,這樣每個組件都有自己的上下文實例而且可以並行運行。
服務注冊
注冊服務是在Startup
類的ConfigureServices(IServiceCollection)
方法中完成的。
這是一個服務注冊的例子:
services.Add(new ServiceDescriptor(typeof(IDataService), typeof(DataService), ServiceLifetime.Transient));
這行代碼將DataService
添加到服務集合中。服務類型設置為IDataService
,因此如果請求了該類型的實例,則它們將獲得DataService
的實例。生命周期也設置為Transient
,這樣每次都會創建一個新實例。
ASP.NET Core 提供了很多擴展方法,使注冊各種生命周期的服務和其他設置更加方便。
下面是使用擴展方法的更簡單的示例:
services.AddTransient<IDataService, DataService>();
是不是更簡單一點?封裝后它當然更容易調用,這樣做更簡單。對於不同的生命周期,也有類似的擴展方法,你也許可以猜到它們的名字。
如果願意,您也可以在使用單一類型注冊(實現類型=服務類型):
services.AddTransient<DataService>();
但是呢,當然組件必須取決於具體的類型,所以這可能是不需要的。
實現工廠
在一些特殊情況下,您可能想要接管某些服務的實例化。在這種情況下,您可以在服務描述符上注冊一個實現工廠(Implementation Factory)。這有一個例子:
services.AddTransient<IDataService, DataService>((ctx) =>
{
IOtherService svc = ctx.GetService<IOtherService>();
//IOtherService svc = ctx.GetRequiredService<IOtherService>();
return new DataService(svc);
});
它使用另一個組件IOtherService
實例化DataService
。您可以使用GetService<T>()
或GetRequiredService<T>()
來獲取在服務集合中注冊的依賴項。
區別在於GetService<T>()
如果找不到T
類型服務,則返回null
;GetRequiredService<T>()
如果找不到它,則會引發InvalidOperationException
異常。
單例作為常量注冊
如果您想自己實例化一個單例,你可以這樣做:
services.AddSingleton<IDataService>(new DataService());
它允許一個非常有趣的場景,假設DataService
實現兩個接口。如果我們這樣做:
services.AddSingleton<IDataService, DataService>();
services.AddSingleton<ISomeInterface, DataService>();
我們得到兩個實例,兩個接口都有一個。如果我們打算共享一個實例,這是一種方法:
var dataService = new DataService();
services.AddSingleton<IDataService>(dataService);
services.AddSingleton<ISomeInterface>(dataService);
如果組件具有依賴關系,則可以從服務集合構建服務提供者並從中獲取必要的依賴項:
IServiceProvider provider = services.BuildServiceProvider();
IOtherService otherService = provider.GetRequiredService<IOtherService>();
var dataService = new DataService(otherService);
services.AddSingleton<IDataService>(dataService);
services.AddSingleton<ISomeInterface>(dataService);
請注意,您應該在ConfigureServices
的末尾執行此操作,以便在此之前確保已經注冊了所有依賴項。
注入
我們已經注冊了我們的組件,現在我們就可以實際使用它們了。
在 ASP.NET Core 中注入組件的典型方式是構造函數注入,針對不同的場景確實存在其他選項,但構造器注入允許您定義在沒有這些其他組件的情況下此組件不起作用。
舉個例子,我們來做一個基本的日志記錄中間件組件:
public class LoggingMiddleware
{
private readonly RequestDelegate _next;
public LoggingMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task Invoke(HttpContext ctx)
{
Debug.WriteLine("Request starting");
await _next(ctx);
Debug.WriteLine("Request complete");
}
}
在中間件中注入組件有三種不同的方式:
- 構造函數
Invoke
方法參數HttpContext.RequestServices
讓我們使用三種全部方式注入我們的組件:
public class LoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly IDataService _svc;
public LoggingMiddleware(RequestDelegate next, IDataService svc)
{
_next = next;
_svc = svc;
}
public async Task Invoke(HttpContext ctx, IDataService svc2)
{
IDataService svc3 = ctx.RequestServices.GetService<IDataService>();
Debug.WriteLine("Request starting");
await _next(ctx);
Debug.WriteLine("Request complete");
}
}
中間件在應用的整個生命周期中僅實例化一次,因此通過構造函數注入的組件對於所有通過的請求都是相同的。
作為Invoke
方法的參數注入的組件是中間件絕對必需的,如果它找不到要注入的IDataService
,它將引發InvalidOperationException
異常。
第三個通過使用HttpContext
請求上下文的RequestServices
屬性的GetService<T>()
方法來獲取可選的依賴項。RequestServices
屬性的類型是IServiceProvider
,因此它與實現工廠中的提供者完全相同。如果您打算要求拿到這個組件,可以使用GetRequiredService<T>()
。
如果IDataService
被注冊為Singleton
,我們會在它們中獲得相同的實例。
如果它被注冊為Scoped
,svc2
和svc3
將會是同一個實例,但不同的請求會得到不同的實例。
在Transient
的情況下,它們都是不同的實例。
每種方法的用例:
- 構造函數:所有請求都需要的單例(Singleton)組件
- Invoke參數:在請求中總是必須的作用域(Scoped)和瞬時(Transient)組件
- RequestServices:基於運行時信息可能需要或可能不需要的組件
如果可能的話,我會盡量避免使用RequestServices
,並且只在中間件必須能夠在缺少某些組件一樣可以運行的情況下才使用它。
Startup類
在Startup
類的構造函數中,您至少可以注入IHostingEnvironment
和ILoggerFactory
。它們是官方文檔中提到的僅有兩個接口。可能有其他的,但我不知道。
public Startup(IHostingEnvironment env, ILoggerFactory loggerFactory)
{
...
}
IHostingEnvironment
通常用於為應用程序設置配置。您可以使用ILoggerFactory
設置日志記錄。
Configure
方法允許您注入已注冊的任何組件。
public void Configure(
IApplicationBuilder app,
IHostingEnvironment env,
ILoggerFactory loggerFactory,
IDataService dataSvc)
{
...
}
因此,如果在管道配置過程中有需要的組件,您可以在這里簡單地要求它們。
如果使用app.Run()
/app.Use()
/app.UseWhen()
/app.Map()
在管道上注冊簡單中間件,則不能使用構造函數注入。事實上,通過ApplicationServices
/ RequestServices
是獲取所需組件的唯一方法。
這里有些例子:
IDataService dataSvc2 = app.ApplicationServices.GetService<IDataService>();
app.Use((ctx, next) =>
{
IDataService svc = ctx.RequestServices.GetService<IDataService>();
return next();
});
app.Map("/test", subApp =>
{
IDataService svc1 = subApp.ApplicationServices.GetService<IDataService>();
subApp.Run((context =>
{
IDataService svc2 = context.RequestServices.GetService<IDataService>();
return context.Response.WriteAsync("Hello!");
}));
});
app.MapWhen(ctx => ctx.Request.Path.StartsWithSegments("/test2"), subApp =>
{
IDataService svc1 = subApp.ApplicationServices.GetService<IDataService>();
subApp.Run(ctx =>
{
IDataService svc2 = ctx.RequestServices.GetService<IDataService>();
return ctx.Response.WriteAsync("Hello!");
});
});
因此,您可以在配置時通過IApplicationBuilder
上的ApplicationServices
請求組件,並在請求時通過HttpContext
上的RequestServices
請求組件。
在MVC Core中注入
在MVC中進行依賴注入的最常見方法是構造函數注入。
您可以在任何地方做到這一點。在控制器中,您有幾個選項:
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();
}
}
如果您希望稍后根據運行時決策獲取依賴項,則可以再次使用Controller
基類(技術上講,ControllerBase
最好)的HttpContext
屬性上可用的RequestServices
。
您也可以通過在特定的 Action 上添加參數,並使用FromServicesAttribute
特性對其進行裝飾來注入所需的服務,這會指示 MVC Core 從服務集合中獲取它,而不是嘗試對其進行模型綁定。
Razor 視圖
您還可以使用新的關鍵字@inject
在Razor視圖中注入組件:
@using Microsoft.AspNetCore.Mvc.Localization
@inject IViewLocalizer Localizer
在這里,我們在_ViewImports.cshtml
中注入了一個視圖本地化器,因此我們將它作為Localizer
在所有視圖中提供。
請注意,不應濫用此機制將本應該來自控制器的數據帶入視圖。
Tag helper
構造函數注入也適用於Tag Helper:
[HtmlTargetElement("test")]
public class TestTagHelper : TagHelper
{
private readonly IDataService _dataService;
public TestTagHelper(IDataService dataService)
{
_dataService = dataService;
}
}
視圖組件
視圖組件也一樣:
public class TestViewComponent : ViewComponent
{
private readonly IDataService _dataService;
public TestViewComponent(IDataService dataService)
{
_dataService = dataService;
}
public async Task<IViewComponentResult> InvokeAsync()
{
return View();
}
}
在視圖組件中也可以獲得HttpContext
,因此有權訪問RequestServices
。
過濾器
MVC過濾器也支持構造函數注入,以及有權訪問RequestServices
:
public class TestActionFilter : ActionFilterAttribute
{
private readonly IDataService _dataService;
public TestActionFilter(IDataService dataService)
{
_dataService = dataService;
}
public override void OnActionExecuting(ActionExecutingContext context)
{
Debug.WriteLine("OnActionExecuting");
}
public override void OnActionExecuted(ActionExecutedContext context)
{
Debug.WriteLine("OnActionExecuted");
}
}
但是,通過構造函數注入我們不能像往常一樣在控制器上添加特性,因為它在運行的時候必須要獲得依賴項。
這里我們有兩種方式可以將其添加到控制器或 Action 級別:
[TypeFilter(typeof(TestActionFilter))]
public class HomeController : Controller
{
}
// or
[ServiceFilter(typeof(TestActionFilter))]
public class HomeController : Controller
{
}
以上這兩種方式關鍵的區別是TypeFilterAttribute
會先找出過濾器的依賴項並通過DI獲取它們,然后創建過濾器。另一方面,ServiceFilterAttribute
則是直接嘗試從服務集合中尋找過濾器!
所以,為了使[ServiceFilter(typeof(TestActionFilter))]
正常工作,我們需要多一點配置:
public void ConfigureServices(IServiceCollection services)
{
services.AddTransient<TestActionFilter>();
}
現在ServiceFilterAttribute
就可以找到過濾器了。
如果您想添加全局過濾器:
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc(mvc =>
{
mvc.Filters.Add(typeof(TestActionFilter));
});
}
這樣就不需要將過濾器添加到服務集合,它的工作方式就好像您已經在每個控制器上添加了TypeFilterAttribute
一樣。
HttpContext
我已經多次提到過HttpContext
。如果您想訪問控制器/視圖/視圖組件之外的HttpContext
,那怎么辦?例如,要訪問當前登錄用戶的聲明?
您只要簡單地注入IHttpContextAccessor
,如下所示:
public class DataService : IDataService
{
private readonly HttpContext _httpContext;
public DataService(IOtherService svc, IHttpContextAccessor contextAccessor)
{
_httpContext = contextAccessor.HttpContext;
}
//...
}
這樣可以讓您的服務層直接訪問HttpContext
,而不需要通過調用方法來傳遞它。
結論
相對於 Ninject 或 Autofac 等較大、較老的DI框架來說,ASP.NET Core提供的依賴注入容器在功能上比較基本,但它仍然非常適合大多數需求。
您可以在任何需要的地方注入組件,從而使組件在此過程中更具可測試性。