原文链接: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提供的依赖注入容器在功能上比较基本,但它仍然非常适合大多数需求。
您可以在任何需要的地方注入组件,从而使组件在此过程中更具可测试性。