基于IHostBuilder/IHost的服务承载系统建立在依赖注入框架之上,它在服务承载过程中依赖的服务(包括作为宿主的IHost对象)都由代表依赖注入容器的IServiceProvider对象提供。在定义承载服务时,也可以采用依赖注入方式来消费它所依赖的服务。作为依赖注入容器的IServiceProvider对象能否提供我们需要的服务实例,取决于相应的服务注册是否预先添加到依赖注入框架中。服务注册可以通过调用IHostBuilder接口或者IWebHostBuilder接口相应的方法来完成,前者在《服务承载系统》已经有详细介绍,下面介绍基于IWebHostBuilder接口的服务注册。[本文节选自《ASP.NET Core 3框架揭秘》第11章, 更多关于ASP.NET Core的文章请点这里]
目录
一、服务注册
二、服务的消费
在Startup中注入服务
在中间件中注入服务
三、生命周期
两个IServiceProvider对象
基于服务范围的验证
四、集成第三方依赖注入框架
一、服务注册
ASP.NET Core应用提供了两种服务注册方式,一种是调用IWebHostBuilder接口的ConfigureServices方法。如下面的代码片段所示,IWebHostBuilder定义了两个Configure
Services方法重载,它们的参数类型分别是Action<IServiceCollection>和Action<WebHostBuilderContext, IServiceCollection>,我们注册的服务最终会被添加到作为这两个委托对象输入的IServiceCollection集合中。WebHostBuilderContext代表当前IWebHostBuilder在构建WebHost过程中采用的上下文,我们可以利用它得到当前应用的配置和与承载环境相关的信息。
public interface IWebHostBuilder { IWebHostBuilder ConfigureServices(Action<IServiceCollection> configureServices); IWebHostBuilder ConfigureServices(Action<WebHostBuilderContext, IServiceCollection> configureServices); ... } public class WebHostBuilderContext { public IConfiguration Configuration { get; set; } public IWebHostEnvironment HostingEnvironment { get; set; } }
除了直接调用IWebHostBuilder接口的ConfigureServices方法注册服务,还可以利用注册的Startup类型来完成服务的注册。所谓的Startup类型就是通过调用如下两个扩展方法注册到IWebHostBuilder接口上用来对应用程序进行初始化的。由于ASP.NET Core应用针对请求的处理能力与方式完全取决于注册的中间件,所以这里所谓的针对应用程序的初始化主要体现在针对中间件的注册上。
public static class WebHostBuilderExtensions { public static IWebHostBuilder UseStartup<TStartup>(this IWebHostBuilder hostBuilder) where TStartup: class; public static IWebHostBuilder UseStartup(this IWebHostBuilder hostBuilder, Type startupType); }
对于注册的中间件来说,它往往具有针对其他服务的依赖。当ASP.NET Core框架在创建具体的中间件对象时,会利用依赖注入框架来提供注入的依赖服务。中间件依赖的这些服务自然需要被预先注册,所以中间件和服务注册成为Startup对象的两个核心功能。与中间件类型类似,我们在大部分情况下会采用约定的形式来定义Startup类型。如下所示的代码片段就是一个典型的Startup的定义,中间件和服务的注册分别实现在Configure方法和ConfigureServices方法中。由于并不是在任何情况下都有服务注册的需求,所以ConfigureServices方法并不是必需的。Startup对象的ConfigureServices方法的调用发生在整个服务注册的最后阶段,在此之后,ASP.NET Core应用就会利用所有的服务注册来创建作为依赖注入容器的IServiceProvider对象。
public class Startup { public void ConfigureServices(IServiceCollection servives); public void Configure(IApplicationBuidler app); }
除了可以采用上述两种方式为应用程序注册所需的服务,ASP.NET Core框架本身在构建请求处理管道之前也会注册一些服务,这些公共服务除了供框架自身消费,也可以供应用程序使用。那么ASP.NET Core框架究竟预先注册了哪些服务?为了得到这个问题的答案,我们编写了如下这段简单的程序。
class Program { static void Main() { Host.CreateDefaultBuilder().ConfigureWebHostDefaults(builder => builder.UseStartup<Startup>()) .Build() .Run(); } } public class Startup { public void ConfigureServices(IServiceCollection services) { var provider = services.BuildServiceProvider(); foreach (var service in services) { var serviceTypeName = GetName(service.ServiceType); var implementationType = service.ImplementationType ?? service.ImplementationInstance?.GetType() ?? service.ImplementationFactory?.Invoke(provider)?.GetType(); if (implementationType != null) { Console.WriteLine($"{service.Lifetime,-15} {GetName(service.ServiceType),-50}{GetName(implementationType)}"); } } } public void Configure(IApplicationBuilder app) { } private string GetName(Type type) { if (!type.IsGenericType) { return type.Name; } var name = type.Name.Split('`')[0]; var args = type.GetGenericArguments().Select(it => it.Name); return $"{name}<{string.Join(",", args)}>"; } }
在如上所示的Startup类型的ConfigureServices方法中,我们从作为参数的IServiceCollection对象中获取当前注册的所有服务,并打印每个服务对应的声明类型、实现类型和生命周期。这段程序执行之后,系统注册的所有公共服务会以图11-7所示的方式输出到控制台上,我们可以从这个列表中发现很多熟悉的类型。
二、服务的消费
ASP.NET Core框架中的很多核心对象都是通过依赖注入方式提供的,如用来对应用进行初始化的Startup对象、中间件对象,以及ASP.NET Core MVC应用中的Controller对象和View对象等,所以我们可以在定义它们的时候采用注入的形式来消费已经注册的服务。下面简单介绍几种服务注入的应用场景。
在Startup中注入服务
构成HostBuilderContext上下文的两个核心对象(表示配置的IConfiguration对象和表示承载环境的IHostEnvironment对象)可以直接注入Startup构造函数中进行消费。由于ASP.NET Core应用中的承载环境通过IWebHostEnvironment接口表示,IWebHostEnvironment接口派生于IHostEnvironment接口,所以也可以通过注入IWebHostEnvironment对象的方式得到当前承载环境相关的信息。
我们可以通过一个简单的实例来验证针对Startup的构造函数注入。如下面的代码片段所示,我们在调用IWebHostBuilder接口的Startup<TStartup>方法时注册了自定义的Startup类型。在定义Startup类型时,我们在其构造函数中注入上述3个对象,提供的调试断言不仅证明了3个对象不为Null,还表明采用IHostEnvironment接口和IWebHostEnvironment接口得到的其实是同一个实例。
class Program { static void Main() { Host.CreateDefaultBuilder().ConfigureWebHostDefaults(builder => builder.UseStartup<Startup>()) .Build() .Run(); } } public class Startup { public Startup(IConfiguration configuration, IHostEnvironment hostingEnvironment, IWebHostEnvironment webHostEnvironment) { Debug.Assert(configuration != null); Debug.Assert(hostingEnvironment != null); Debug.Assert(webHostEnvironment != null); Debug.Assert(ReferenceEquals(hostingEnvironment, webHostEnvironment)); } public void Configure(IApplicationBuilder app) { } }
依赖服务还可以直接注入用于注册中间件的Configure方法中。如果构造函数注入还可以对注入的服务有所选择,那么对于Configure方法来说,通过任意方式注册的服务都可以注入其中,包括通过调用IHostBuilder、IWebHostBuilder和Startup自身的ConfigureServices方法注册的服务,还包括框架自行注册的所有服务。
如下面的代码片段所示,我们分别调用IWebHostBuilder和Startup的ConfigureServices方法注册了针对IFoo接口与IBar接口的服务,这两个服务直接注入Startup的Configure方法中。另外,Configure方法要求提供一个用来注册中间件的IApplicationBuilder对象作为参数,但是对该参数出现的位置并未做任何限制。
class Program { static void Main() { Host.CreateDefaultBuilder().ConfigureWebHostDefaults(builder => builder .UseStartup<Startup>() .ConfigureServices(svcs => svcs.AddSingleton<IFoo, Foo>())) .Build() .Run(); } } public class Startup { public void ConfigureServices(IServiceCollection services) => services.AddSingleton<IBar, Bar>(); public void Configure(IApplicationBuilder app, IFoo foo, IBar bar) { Debug.Assert(foo != null); Debug.Assert(bar != null); } }
在中间件中注入服务
ASP.NET Core请求处理管道最重要的对象是真正用来处理请求的中间件。由于ASP.NET Core在创建中间件对象并利用它们构建整个请求处理管道时,所有的服务都已经注册完毕,所以注册的任何一个服务都可以注入中间件类型的构造函数中。如下所示的代码片段体现了针对中间件类型的构造函数注入。(S1107)
class Program { static void Main() { Host.CreateDefaultBuilder().ConfigureWebHostDefaults(builder => builder.ConfigureServices(svcs => svcs .AddSingleton<FoobarMiddleware>() .AddSingleton<IFoo, Foo>() .AddSingleton<IBar, Bar>()) .Configure(app => app.UseMiddleware<FoobarMiddleware>())) .Build() .Run(); } } public class FoobarMiddleware: IMiddleware { public FoobarMiddleware(IFoo foo, IBar bar) { Debug.Assert(foo != null); Debug.Assert(bar != null); } public Task InvokeAsync(HttpContext context, RequestDelegate next) { Debug.Assert(next != null); return Task.CompletedTask; } }
如果采用基于约定的中间件类型定义方式,注册的服务还可以直接注入真正用于处理请求的InvokeAsync方法或者Invoke方法中。另外,将方法命名为InvokeAsync更符合TAP(Task-based Asynchronous Pattern)编程模式,之所以保留Invoke方法命名,主要是出于版本兼容的目的。如下所示的代码片段展示了针对InvokeAsync方法的服务注入。
class Program { static void Main() { Host.CreateDefaultBuilder().ConfigureWebHostDefaults(builder => builder.ConfigureServices(svcs => svcs .AddSingleton<IFoo, Foo>() .AddSingleton<IBar, Bar>()) .Configure(app => app.UseMiddleware<FoobarMiddleware>())) .Build() .Run(); } } public class FoobarMiddleware { private readonly RequestDelegate _next; public FoobarMiddleware(RequestDelegate next) => _next = next; public Task InvokeAsync(HttpContext context, IFoo foo, IBar bar) { Debug.Assert(context != null); Debug.Assert(foo != null); Debug.Assert(bar != null); return _next(context); } }
虽然约定定义的中间件类型和Startup类型采用了类似的服务注入方式,它们都支持构造函数注入和方法注入,但是它们之间有一些差别。中间件类型的构造函数、Startup类型的Configure方法和中间件类型的Invoke方法或者InvokeAsync方法都具有一个必需的参数,其类型分别为RequestDelegate、IApplicationBuilder和HttpContext,对于该参数在整个参数列表的位置,前两者都未做任何限制,只有后者要求表示当前请求上下文的参数HttpContext必须作为方法的第一个参数。按照上述约定,如下这个中间件类型FoobarMiddleware的定义是不合法的,但是Startup类型的定义则是合法的。对于这一点,笔者认为可以将这个限制放开,这样不仅可以使中间件类型的定义更加灵活,还能保证注入方式的一致性。
public class FoobarMiddleware { public FoobarMiddleware(RequestDelegate next); public Task InvokeAsync(IFoo foo, IBar bar, HttpContext context); } public class Startup { public void Configure(IFoo foo, IBar bar, IApplicationBuilder app); }
对于基于约定的中间件,构造函数注入与方法注入存在一个本质区别。由于中间件被注册为一个Singleton对象,所以我们不应该在它的构造函数中注入Scoped服务。Scoped服务只能注入中间件类型的InvokeAsync方法中,因为依赖服务是在针对当前请求的服务范围中提供的,所以能够确保Scoped服务在当前请求处理结束之后被释放。
三、生命周期
当我们调用IServiceCollection相关方法注册服务的时候,总是会指定一种生命周期。由第3章和第4章的介绍可知,作为依赖注入容器的多个IServiceProvider对象通过ServiceScope 构成一种层次化结构。Singleton服务实例保存在作为根容器的IServiceProvider对象上,而Scoped服务实例以及需要回收释放的Transient服务实例则保存在当前IServiceProvider对象中,只有不需要回收的Transient服务才会用完就被丢弃。
至于服务实例是否需要回收释放,取决于服务实现类型是否实现IDisposable接口,服务实例的回收释放由保存它的IServiceProvider对象负责。具体来说,当IServiceProvider对象因自身的Dispose方法被调用而被回收释放时,它会调用自身维护的所有服务实例的Dispose方法。对于一个非根容器的IServiceProvider对象来说,其生命周期决定于对应的IServiceScope对象,调用ServiceScope的Dispose方法会导致对封装IServiceProvider对象的回收释放。
两个IServiceProvider对象
如果在一个具体的ASP.NET Core应用中讨论服务生命周期会更加易于理解:Singleton是针对应用程序的生命周期,而Scoped是针对请求的生命周期。换句话说,Singleton服务的生命周期会一直延续到应用程序关闭,而Scoped服务的生命周期仅仅与当前请求上下文绑定在一起,那么这样的生命周期模式是如何实现的?
ASP.NET Core应用针对服务生命周期管理的实现原理其实也很简单。在应用程序正常启动后,它会利用注册的服务创建一个作为根容器的IServiceProvider对象,我们可以将它称为ApplicationServices。如果应用在处理某个请求的过程中需要采用依赖注入的方式激活某个服务实例,那么它会利用这个IServiceProvider对象创建一个代表服务范围的IServiceScope对象,后者会指定一个IServiceProvider对象作为子容器,请求处理过程中所需的服务实例均由它来提供,我们可以将它称为RequestServices。
在处理完当前请求后,这个IServiceScope对象的Dispose方法会被调用,与它绑定的这个IServiceProvider对象也随之被回收释放,由它提供的实现了IDisposable接口的Transient服务实例也会随之被回收释放,最终由它提供的Scoped服务实例变成可以被GC回收的垃圾对象。表示当前请求上下文的HttpContext类型具有如下所示的RequestServices属性,它返回的就是这个针对当前请求的IServiceProvider对象。
public abstract class HttpContext { public abstract IServiceProvider RequestServices { get; set; } ... }
为了使读者对注入服务的生命周期有深刻的认识,下面演示一个简单的实例。这是一个ASP.NET Core MVC应用,我们在该应用中定义了3个服务接口(IFoo、IBar和IBaz)和对应的实现类(Foo、Bar和Baz),后者派生于实现了IDisposable接口的基类Base。我们分别在Base的构造函数和实现的Dispose方法中输出相应的文字,以确定服务实例被创建和释放的时间。
class Program { static void Main() { Host.CreateDefaultBuilder().ConfigureWebHostDefaults(builder => builder .ConfigureServices(svcs => svcs .AddSingleton<IFoo, Foo>() .AddScoped<IBar, Bar>() .AddTransient<IBaz, Baz>() .AddControllersWithViews()) .Configure(app => app .Use(next => httpContext => { Console.WriteLine($"Receive request to {httpContext.Request.Path}"); return next(httpContext); }) .UseRouting() .UseEndpoints(endpoints => endpoints.MapControllers()))) .ConfigureLogging(builder=>builder.ClearProviders()) .Build() .Run(); } } public class HomeController: Controller { private readonly IHostApplicationLifetime _lifetime; public HomeController(IHostApplicationLifetime lifetime, IFoo foo, IBar bar1, IBar bar2, IBaz baz1, IBaz baz2) =>_lifetime = lifetime; [HttpGet("/index")] public void Index() {} [HttpGet("/stop")] public void Stop() => _lifetime.StopApplication(); } public interface IFoo {} public interface IBar {} public interface IBaz {} public class Base : IDisposable { public Base()=> Console.WriteLine($"{this.GetType().Name} is created."); public void Dispose() => Console.WriteLine($"{this.GetType().Name} is disposed."); } public class Foo : Base, IFoo {} public class Bar : Base, IBar {} public class Baz : Base, IBaz {}
在注册ASP.NET Core MVC框架相关的服务之前,我们采用不同的生命周期对这3个服务进行了注册。为了确定应用程序何时开始处理接收的请求,可以利用注册的中间件打印出当前请求的路径。我们在HomeController的构造函数中注入了上述3个服务和1个用来远程关闭应用的IHostApplicationLifetime服务,其中IBar和IBaz被注入了两次。HomeController包含Index和Stop两个Action方法,它们的路由指向的路径分别为“/index”和“/stop”,Stop方法利用注入的IHostApplicationLifetime服务关闭当前应用。
我们先采用命令行的形式来启动该应用程序,然后利用浏览器依次向该应用发送3个请求,前两个请求指向Action方法Index(“/index”),后一个指向Action方法Stop(“ /stop”),此时控制台上出现的输出结果如下图所示。由输出结果可知:由于IFoo服务采用的生命周期模式为Singleton,所以在整个应用的生命周期中只会创建一次。对于每个接收的请求,虽然IBar和IBaz都被注入了两次,但是采用Scoped模式的Bar对象只会被创建一次,而采用Transient模式的Baz对象则被创建了两次。再来看释放服务相关的输出,采用Singleton模式的IFoo服务会在应用被关闭的时候被释放,而生命周期模式分别为Scoped和Transient的IBar服务与IBaz服务都会在应用处理完当前请求之后被释放。(S1110)
基于服务范围的验证
由《依赖注入[8]:服务实例的生命周期》的介绍可知,Scoped服务既不应该由作为根容器的ApplicationServices来提供,也不能注入一个Singleton服务中,否则它将无法在请求结束之后释放。如果忽视了这个问题,就容易造成内存泄漏,下面是一个典型的例子。
如下所示的实例程序使用了一个名为FoobarMiddleware的中间件。在该中间件初始化过程中,它需要从数据库中加载由Foobar类型表示的数据。在这里我们采用Entity Framework Core提供的基于SQL Server的数据访问,所以可以为实体类型Foobar定义对应的FoobarDbContext,它以服务的形式通过调用IServiceCollection的AddDbContext<TDbContext>扩展方法进行注册,注册的服务默认采用Scoped生命周期。
class Program { static void Main() { Host.CreateDefaultBuilder().ConfigureWebHostDefaults(builder => builder .UseDefaultServiceProvider(options=>options.ValidateScopes = false) .ConfigureServices(svcs => svcs.AddDbContext<FoobarDbContext>(options=>options.UseSqlServer("connection string"))) .Configure(app =>app.UseMiddleware<FoobarMiddleware>())) .Build() .Run(); } } public class FoobarMiddleware { private readonly RequestDelegate _next; private readonly Foobar _foobar; public FoobarMiddleware(RequestDelegate next, FoobarDbContext dbContext) { _next = next; _foobar = dbContext.Foobar.SingleOrDefault(); } public Task InvokeAsync(HttpContext context) { ... return _next(context); } } public class Foobar { [Key] public string Foo { get; set; } public string Bar { get; set; } } public class FoobarDbContext : DbContext { public DbSet<Foobar> Foobar { get; set; } public FoobarDbContext(DbContextOptions options) : base(options){} }
采用约定方式定义的中间件实际上是一个Singleton对象,而且它是在应用初始化过程中由根容器的IServiceProvider对象创建的。由于FoobarMiddleware的构造函数中注入了FoobarDbContext对象,所以该对象自然也由同一个IServiceProvider对象来提供。这就意味着FoobarDbContext对象的生命周期会延续到当前应用程序被关闭的那一刻,造成的后果就是数据库连接不能及时地被释放。
在一个ASP.NET Core应用中,如果将服务的生命周期注册为Scoped模式,那么我们希望服务实例真正采用基于请求的生命周期模式。由第4章的介绍可知,我们可以通过启用针对服务范围的验证来避免采用作为根容器的IServiceProvider对象来提供Scoped服务实例。我们只需要调用IWebHostBuilder接口的两个UseDefaultServiceProvider方法重载将ServiceProviderOptions的ValidateScopes属性设置为True即可。
public static class WebHostBuilderExtensions { public static IWebHostBuilder UseDefaultServiceProvider(this IWebHostBuilder hostBuilder, Action<ServiceProviderOptions> configure); public static IWebHostBuilder UseDefaultServiceProvider(this IWebHostBuilder hostBuilder, Action<WebHostBuilderContext, ServiceProviderOptions> configure); } public class ServiceProviderOptions { public bool ValidateScopes { get; set; } public bool ValidateOnBuild { get; set; } }
出于性能方面的考虑,如果在Development环境下调用Host的静态方法CreateDefaultBuilder来创建IHostBuilder对象,那么该方法会将ValidateScopes属性设置为True。在上面演示的实例中,我们刻意关闭了针对服务范围的验证,如果将这行代码删除,在开发环境下启动该程序之后会出现下图所示的异常。
如果确实需要在中间件中注入Scoped服务,可以采用强类型(实现IMiddleware接口)的中间件定义方式,并将中间件以Scoped服务进行注册即可。如果采用基于约定的中间件定义方式,我们有两种方案来解决这个问题:第一种解决方案就是按照如下所示的方式在InvokeAsync方法中利用HttpContext的RequestServices属性得到基于当前请求的IServiceProvider对象,并利用它来提供依赖的服务。
public class FoobarMiddleware { private readonly RequestDelegate _next; public FoobarMiddleware(RequestDelegate next)=> _next = next; public Task InvokeAsync(HttpContext context) { var dbContext = context.RequestServices.GetRequiredService<FoobarDbContext>(); Debug.Assert(dbContext != null); return _next(context); } }
第二种解决方案则是按照如下所示的方式直接在InvokeAsync方法中注入依赖的服务。我们在上面介绍两种中间件定义方式时已经提及:InvokeAsync方法注入的服务就是由基于当前请求的IServiceProvider对象提供的,所以这两种解决方案其实是等效的。
public class FoobarMiddleware { private readonly RequestDelegate _next; public FoobarMiddleware(RequestDelegate next)=> _next = next; public Task InvokeAsync(HttpContext context) { var dbContext = context.RequestServices.GetRequiredService<FoobarDbContext>(); Debug.Assert(dbContext != null); return _next(context); } }
四、集成第三方依赖注入框架
由《服务承载系统[6]: 承载服务启动流程[下篇]》的介绍可知,通过调用IHostBuilder接口的UseServiceProviderFactory<TContainerBuilder> 方法注册IServiceProviderFactory<TContainerBuilder>工厂的方式可以实现与第三方依赖注入框架的整合。该接口定义的ConfigureContainer<TContainerBuilder>方法可以对提供的依赖注入容器做进一步设置,这样的设置同样可以定义在注册的Startup类型中。
《依赖注入[4]:一个Mini版的依赖注入框架》创建了一个名为Cat的简易版依赖注入框架,并在第4章为其创建了一个IServiceProviderFactory<TContainerBuilder>实现,具体类型为CatServiceProvider,下面演示如何通过注册这个CatServiceProvider实现与第三方依赖注入框架Cat的整合。如果使用Cat框架,我们可以通过在服务类型上标注MapToAttribute特性的方式来定义服务注册信息。在创建的演示程序中,我们采用如下方式定义了3个服务(Foo、Bar和Baz)和对应的接口(IFoo、IBar和IBaz)。
public interface IFoo { } public interface IBar { } public interface IBaz { } [MapTo(typeof(IFoo), Lifetime.Root)] public class Foo : IFoo { } [MapTo(typeof(IBar), Lifetime.Root)] public class Bar : IBar { } [MapTo(typeof(IBaz), Lifetime.Root)] public class Baz : IBaz { }
在如下所示的代码片段中,我们调用IHostBuilder接口的UseServiceProviderFactory方法注册了CatServiceProviderFactory工厂。我们将针对Cat框架的服务注册实现在注册Startup类型的ConfigureContainer方法中,这是除Configure方法和ConfigureServices方法外的第三个约定的方法。我们将CatBuilder对象作为该方法的参数,并调用它的Register方法实现了针对当前程序集的批量服务注册。
class Program { static void Main() { Host.CreateDefaultBuilder() .ConfigureWebHostDefaults(builder => builder.UseStartup<Startup>()) .UseServiceProviderFactory(new CatServiceProviderFactory()) .Build() .Run(); } } public class Startup { public void Configure(IApplicationBuilder app, IFoo foo, IBar bar, IBaz baz) { app.Run(async context => { var response = context.Response; response.ContentType = "text/html"; await response.WriteAsync($"foo: {foo}<br/>"); await response.WriteAsync($"bar: {bar}<br/>"); await response.WriteAsync($"baz: {baz}<br/>"); }); } public void ConfigureContainer(CatBuilder container) => container.Register(Assembly.GetEntryAssembly()); }
为了检验ASP.NET Core能否利用Cat框架来提供所需的服务,我们将注册的3个服务直接注入Startup类型的Configure方法中。我们在该方法中利用注册的中间件将这3个注入的服务实例的类型写入相应的HTML文档中。如果利用浏览器访问该应用,得到的输出结果如下图所示。
ASP.NET Core编程模式[1]:管道式的请求处理
ASP.NET Core编程模式[2]:依赖注入的运用
ASP.NET Core编程模式[3]:配置多种使用形式
ASP.NET Core编程模式[4]:基于承载环境的编程
ASP.NET Core编程模式[5]:如何放置你的初始化代码