一個ASP.NET Core應用被啟動之後就具有了針對請求的處理能力,而這個能力是由管道賦予的,所以應用的啟動同時意味著管道的成功構建。由於管道是由注冊的服務器和若干中間件構成的,所以應用啟動過程中一個核心的工作就是完成中間節的注冊。由於依賴注入在ASP.NET Core應用這得到非常廣泛的應用,框架絕大部分的工作都會分配給我們預先注冊的服務,所以服務注冊也是啟動WebHost過程的另一項核心工作。這兩項在啟動過程中必須完成的核心工作通過一個名為Startup的對象來承載。 [本文已經同步到《ASP.NET Core框架揭秘》之中]
目錄
一、 DelegateStartup
二、ConventionBasedStartup
StartupMethods
StartupLoader
如何選擇啟動類型
如何選擇服務注冊方法和中間件注冊方法
StartupMethods對象的創建
UseStartup方法究竟做了些什麼?
三、選擇哪一個Startup
這裡所謂的Startup實際上是對所有實現了IStartup接口的所有類型以及對應對象的統稱。如下面的代碼片段所示,服務注冊由ConfigureServices方法來實現,它返回一個ServiceProvider對象,至於另一個方法Configure則負責完成中間件的注冊,方法輸入參數是一個ApplicationBuilder對象。
1: public interface IStartup
2: {
3: IServiceProvider ConfigureServices(IServiceCollection services);
4: void Configure(IApplicationBuilder app);
5: }
IStartup接口所在的NuGet包中還定義了另一個實現了這個接口的抽象類StartupBase。如下面的代碼片段所示,StartupBase實現了抽象方法ConfigureServices,該方法直接利用提供的ServiceCollection對象創建返回的ServiceProvider。換句話說,派生於StartupBase的Startup類型如果沒用重寫ConfigureServices方法,它們實際上只關心中間件的注冊,而不需要注冊額外的服務。
1: public abstract class StartupBase : IStartup
2: {
3: public abstract void Configure(IApplicationBuilder app);
4: public virtual IServiceProvider ConfigureServices(IServiceCollection services)
5: {
6: return services.BuildServiceProvider();
7: }
8: }
我們來想想具體的應用中是如何注冊中間件和服務的。中間件的注冊可以采用兩種方式,最簡單的方式就是直接調用IWebHostBuilder的Configure方法。如下面的代碼片段所示,這個方法直接上是借助於一個類型為Action<IApplicationBuilder>的委托對象將中間件注冊到提供的ApplicationBuilder對象上。
1: public static class WebHostBuilderExtensions
2: {
3: public static IWebHostBuilder Configure(this IWebHostBuilder hostBuilder, Action<IApplicationBuilder> configureApp);
4: }
5:
6: new WebHostBuilder()
7: .Configure(app => app
8: .UseExceptionHandler("/Home/Error")
9: .UseStaticFiles()
10: .UseIdentity()
11: .UseMvc())
12: …
如果我們在應用中通過調用上面這個Configure方法來注冊所需的中間件,WebHost在啟動的時候會創建一個類型為DelegateStartup的Startup對象來完成真正的中間件注冊工作。如下面的代碼片段所示,DelegateStartup派生於StartupBase這個抽象類,它利用一個在構造時提供的Action<IApplicationBuilder>對象實現了Configure方法。很明顯,我們調用IWebHostBuilder的Configure方法指定的Action<IApplicationBuilder>對象將用來創建這個DelegateStartup對象。
1: public class DelegateStartup : StartupBase
2: {
3: private Action<IApplicationBuilder> _configureApp;
4:
5: public DelegateStartup(Action<IApplicationBuilder> configureApp)
6: {
7: _configureApp = configureApp;
8: }
9:
10: public override void Configure(IApplicationBuilder app)
11: {
12: _configureApp(app);
13: }
14: }
如下的代碼片段體現了 IWebHostBuilder的擴展方法Configure的實現邏輯。如下面的代碼片段所示,這個方法根據提供的Action<IApplicationBuilder>對象創建了一個DelegateStartup對象,並調用ConfigureServices方法以淡例模式注冊到WebHostBuilder的服務集合中。這段代碼還體現了另一個細節,除了進行Startup的服務注冊之外,該方法還對“ApplicationName”選項(對應WebHostOptions的ApplicationName)進行了設置。
1: public static class WebHostBuilderExtensions
2: {
3: public static IWebHostBuilder Configure(this IWebHostBuilder hostBuilder, Action<IApplicationBuilder> configureApp)
4: {
5: var startupAssemblyName = configureApp.GetMethodInfo().DeclaringType.GetTypeInfo().Assembly.GetName().Name;
6: return hostBuilder
7: .UseSetting("applicationName", startupAssemblyName)
8: .ConfigureServices(svcs => svcs.AddSingleton<IStartup>(new DelegateStartup(configureApp));
9: }
10: }
我們知道應用中最常見的服務和中間件注冊代碼都定義在一個單獨的類中,通常直接將其命名為Startup。為了與IStartup接口代表的Startup相區別,我們使用 “啟動類(型)” 來稱呼這個類。按照約定,啟動類中會分別定義一個ConfigureServices和Configure方法來注冊服務和中間件。一般情況下,這樣的類型一般需要通過調用UseStartup<T>這個擴展方法注冊到WebHostBuilder上。
1: public class Startup
2: {
3: public void ConfigureServies(IServiceCollection services);
4: public void Configure(IApplicationBuilder app);
5: }
6:
7: new WebHostBuilder()
8: .UseStartup<Startup>()
9: …
如果我們在應用中將服務和中間件注冊的實現定義在啟動類型中,當WebHost被啟動的時候,ASP.NET Core會創建一個類型為ConventionBasedStartup的Startup對象。這個Startup類型之所以采用這樣的命名方式,是因為ASP.NET Core並沒有采用接口實現的方式為啟動類型做強制性的約束,而僅僅是為作為啟動類型的定義提供了一個約定而已,至於具體采用怎樣的約定,我們將在後續部分進行詳細介紹。
在了解了啟動類型的約定以及常見的定義形式之外,我們現在來討論這對這個類型創建的ConventionBasedStartup就是怎樣的對象。從下面的代碼片段可以看出,一個ConventionBasedStartup對象是根據一個類型為StartupMethods對象創建的。顧名思義,StartupMethods只在提供兩個用戶注冊服務和中間件的方法,這兩個方法體現在由它的兩個屬性(ConfigureServicesDelegate和ConfigureDelegate)提供的兩個委托對象,這兩個委托對象分別實現了定義在ConventionBasedStartup的ConfigureServices和Configure方法。
1: public class ConventionBasedStartup : IStartup
2: {
3: public ConventionBasedStartup(StartupMethods methods);
4: public IServiceProvider ConfigureServices(IServiceCollection services);
5: public void Configure(IApplicationBuilder app);
6: }
7:
8: public class StartupMethods
9: {
10: public Func<IServiceCollection, IServiceProvider> ConfigureServicesDelegate { get; }
11: public Action<IApplicationBuilder> ConfigureDelegate { get; }
12:
13: public StartupMethods(Action<IApplicationBuilder> configure);
14: public StartupMethods(Action<IApplicationBuilder> configure, Func<IServiceCollection, IServiceProvider> configureServices);
15: }
既然ConventionBasedStartup對象是根據提供的一個StartupMethods對象創建的,那麼現在的核心問題則變成了這個StartupMethods對象如何根據啟動類型創建的。StartupMethods的創建者是一個類型為StartupLoader的對象,如下面的代碼片段所示,StartupLoader定了兩個名為FindStartupType和LoadMethods靜態方法,前者用於啟動類型的解析,後者則實現了StartupMethods對象的創建。
1: public class StartupLoader
2: {
3: public static Type FindStartupType(string startupAssemblyName, string environmentName);
4: public static StartupMethods LoadMethods(IServiceProvider services, Type startupType, string environmentName);
5: }
如果啟動類型沒有通過調用WebHostBuilder的如下兩個擴展方法UseStartup/UseStartup<TStartup>的顯式注冊,那麼StartupLoader的FindStartupType方法會被調用來解析出正確的啟動類型。這個方法具有兩個參數,分別代表啟動類型所在的程序集名稱和當前環境名稱,它們實際上對應著WebHostOptions的兩個同名屬性。當FindStartupType方法被執行並成功加載了提供的程序集之後,它會按照約定的啟動類型全名從該程序集中加載啟動類型,候選的啟動類型全名按照選擇優先級排列如下:
這個列表體現了啟動類型解析過程中選擇有效類型名稱的一個基本策略,即“環境名稱優先”和“無命名空間優先”。我們可以通過一個簡單的實例來證明這個策略的存在。我們在一個ASP.NET Core控制台應用中添加一個名為“StartupLib”(程序集也采用這個名稱)的類庫項目,然後在這個項目中定義如下兩組啟動類,其中一組具有命名空間,另一組則采用程序集名稱作為命名空間。這些啟動類都派生於我們自定義的基類StartupBase,後者的Configure方法中注冊了一個中間件將自身的類型作為響應內容。對於每組中的三個啟動類,一個命名為Startup,另外兩個則分別以環境名稱( “Development” 和 “Production” )作為後綴。
1: public class StartupBase
2: {
3: public void ConfigureServices(IServiceCollection services){}
4: public void Configure(IApplicationBuilder app)
5: {
6: app.Run(async context => await context.Response.WriteAsync(this.GetType().FullName));
7: }
8: }
9:
10: public class Startup : StartupBase{}
11: public class StartupDevelopment : StartupBase{}
12: public class StartProduction : StartupBase{}
13:
14: namespace StartupLib
15: {
16: public class Startup : StartupBase{}
17: public class StartupDevelopment : StartupBase{}
18: public class StartProduction : StartupBase{}
19: }
我們采用如下的程序來啟動一個ASP.NET Core應用。如下面的代碼代碼片段所示,我們在利用WebHostBuilder創建並啟動WebHost之前,調用UseSettings方法以配置的形式指定了啟動程序集(“StartupLib”)和當前運行環境(“Development”)的名稱。
1: public class Program
2: {
3: public static void Main()
4: {
5: new WebHostBuilder()
6: .UseKestrel()
7: .UseSetting("startupAssembly", "StartupLib")
8: .UseSetting("environment", "Development")
9: .Build()
10: .Run();
11: }
12: }
根據上述的啟動類型解析規則,對於六個候選的啟動類型,最終被選擇的是不具有命名空間的StartupDevelopment類型。當應用啟動之後,我們利用浏覽器請求應用監聽地址(“http://localhost:5000”),這個被選擇的啟動程序的名稱將會以如下的形式直接顯示出來。
在了解了ASP.NET Core針對啟動類型命名的約定之後,我們來討論一下定義在啟動類中用於注冊服務和中間件的兩個方法的約定。這兩個方法可以是靜態方法,也可以是實例方法。從方法命名來看,這兩個方法除了命名為“ConfigureServices”和“Configure”之外,方法名還可以攜帶運行環境名稱,具體采用的格式分別為“Configure{EnvironmentName}Services”和“Configure{EnvironmentName}”,後者具有更高的選擇優先級。
ConfigureServices/Configure{EnvironmentName}Services方法具有一個類型為IServiceCollection接口的參數,表示存放注冊服務的ServiceCollection對象。如過這個方法沒有定義任何參數,它依然是合法的。一般來說,這個方法不具有返回值(返回類型為void),但是它也可以定義成一個返回類型為IServiceProvider的方法。如果這個方法返回一個ServiceProvider對象,後續過程中獲取的所有服務將從這個ServiceProvider中提取。對於沒有返回值的情況,系統會根據當前注冊的服務創建一個ServiceProvider。
Configure/Configure{EnvironmentName}方法只要求只要求第一個參數類型采用IApplicationBuilder接口,至於這個方法可以包含多少個參數,各個參數應該具有怎樣的類型,並未做任何規定。實際上我們為這個方法定義任意後續參數都是合法的。當ConventionBasedStartup在調用這個方法的時候,同樣是采用依賴注入的方式來提供這些參數。如下面的代碼片段所示,我們為啟動類的Configure方法定義相應的參數來直接使用在ConfigureServices方法上注冊的三個服務。
1: new WebHostBuilder()
2: .ConfigureServices(services => services.AddSingleton<IFoobar, Foobar>())
3: …
4:
5: public class Startup
6: {
7: public Startup(IFoobar foobar)
8: {
9: Debug.Assert(foobar.GetType() == typeof(Foobar));
10: }
11: public void ConfigureServies(IServiceCollection services) ;
12: public void Configure(IApplicationBuilder app) ;
13: }
除此之外,對於定義成實例類型的啟動類,我們並不要求它具有一個默認無參的構造函數。如果構造函數具有參數,ConventionBasedStartup在實例化的時候會采用構造函數注入的方式來提供構造函數的參數。至於提供參數所用的ServiceProvider,就是WebHostBuilder在創建WebHost對象時作為構造函數參數提供的那個ServiceProvider。如下面的代碼片段所示,我們利用WebHostBuilder創建並啟動WebHost之前,調用他的ConfigureServices方法針對接口IFoobar注冊了一個服務,那麼注冊為啟動類的Startup類可以在構造函數中以注入的形式使用這個服務對象。
1: public class StartupLoader
2: {
3: public static StartupMethods LoadMethods(IServiceProvider serviceProvider, Type startupType, string environmentName)
4: {
5: return new StartupMethods(BuildConfigureDelegate(serviceProvider, startupType, environmentName),
6: BuildConfigureServicesDelegate(serviceProvider, startupType, environmentName));
7: }
8:
9: private static Func<IServiceCollection, IServiceProvider> BuildConfigureServicesDelegate(IServiceProvider serviceProvider, Type startupType, string environmentName)
10: {
11: MethodInfo method = FindMethod(startupType, $"Configure{environmentName}Services", "ConfigureServices");
12: object instance = method.IsStatic ? null : ActivatorUtilities.GetServiceOrCreateInstance(serviceProvider, startupType);
13: return services =>
14: {
15: object[] arguments = method.GetParameters().Length > 0 ? new object[] { services } : new object[0];
16: return (method.Invoke(instance, arguments) as IServiceProvider) ?? services.BuildServiceProvider();
17: };
18: }
19:
20: private static Action<IApplicationBuilder> BuildConfigureDelegate(IServiceProvider serviceProvider, Type startupType, string environmentName)
21: {
22: MethodInfo method = FindMethod(startupType, $"Configure{environmentName}", "Configure");
23: object instance = method.IsStatic ? null : ActivatorUtilities.GetServiceOrCreateInstance(serviceProvider, startupType);
24: object[] arguments = method.GetParameters().Select(p => serviceProvider.GetService(p.ParameterType)).ToArray();
25: return app =>
26: {
27: arguments[0] = app;
28: method.Invoke(instance, arguments);
29: };
30: }
31:
32: private static MethodInfo FindMethod(Type startupType, string method1, string method2)
33: {
34: BindingFlags bindAttribute = BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static;
35: return startupType.GetMethod(method1, bindAttribute)?? startupType.GetMethod(method2, bindAttribute);
36: }
37: }
當我們調用IWebHostBuilder接口的擴展方法UseStartup/UseStartup<TStartup>注冊某個啟動類的時候,該方法會按照如下的形式創建一個ConventionBasedStartup對象並注冊到WebHostBuilder的服務集合上。和上面介紹的Configure方法一樣,UseStartup方法同樣會設置 “ApplicationName” 選項。除此之外,這段還體現了另一個細節,那就是如果我們直接定義一個實現了IStartup接口的啟動類,UseStartup方法會直接注冊這個類型,而不會再多此一舉地創建一個ConventionBasedStartup對象。
1: public static class WebHostBuilderExtensions
2: {
3: public static IWebHostBuilder UseStartup<TStartup>(this IWebHostBuilder hostBuilder) where TStartup : class
4: {
5: return UseStartup(hostBuilder, typeof(TStartup));
6: }
7:
8: public static IWebHostBuilder UseStartup(this IWebHostBuilder hostBuilder, Type startupType)
9: {
10: var startupAssemblyName = startupType.GetTypeInfo().Assembly.GetName().Name;
11: return hostBuilder
12: .UseSetting("ApplicationName", startupAssemblyName)
13: .ConfigureServices(svcs =>
14: {
15: if (typeof(IStartup).IsAssignableFrom(startupType))
16: {
17: svcs.AddSingleton(typeof(IStartup), startupType);
18: }
19: else
20: {
21: svcs.AddSingleton<IStartup>(sp => new ConventionBasedStartup(StartupLoader.LoadMethods(sp, startupType, sp.GetService<IHostingEnvironment>().EnvironmentName)));
22: }
23: });
24: }
25: }
應用啟動的時候,Startup幫助我們完成所需服務和中間件的注冊,而Startup對象自身也是服務的形式被注冊到WebHostBuilder或者WebHost的服務集合中。總的來說,Startup的注冊具有如下三種途徑:
那麼現在的問題來說,如果我們采用上述這三種途徑創建並注冊了多個Startup,那麼系統是只選擇其中一個,還是所有的Startup均有效呢?就如下這段程序來說,如果當前程序集同時定義了三個有效的Startup類型(Startup、Startup1和Startup2),最終將會有五個Startup對象被注冊,其中兩個是通過Configure方法注冊的DelegateStartup對象,對於額外三個ConventionBasedStartup對象來說, 其中兩個針對顯式指定的啟動類型(Startup1和Startup2),另外一個則是針對默認的約定解析出來的啟動類型(Startup)。對於這個五個Startup對象,究竟哪些是有效的呢?
1: new WebHostBuilder()
2: .Configure(app => {})
3: .Configure(app => {})
4: .UseStartup<Startup1>()
5: .UseStartup<Startup2>()
6: .UseSetting("startupAssembly", Assembly.GetEntryAssembly().FullName)
7: …
不論我們注冊多少個Startup,系統最終都只會其中一個來注冊服務和中間件。由於WebHost會直接利用ServiceProvider來獲取Startup對象,根據 “後來居上” 的原則,最終選擇的總是最後注冊的那個Startup。由於Configure方法和UseStartup方法最終都是調用WebHostBuilder的ConfigureServices方法進行服務注冊,所以最後調用的方法具有最高的優先級。至於根據指定啟動程序集名稱而創建出來的ConventionBasedStartup,針對它的注冊信息會放在最前面,所以具有最低優先級。根據這個策略,上面這段程序最終選擇的啟動類是Startup2。