ASP.NET Core應用基本編程模式[5]:如何放置你的初始化程式碼

一個ASP.NET Core應用的核心就是由一個伺服器和一組有序中間件組成的請求處理管道,伺服器只負責監聽、接收和分發請求,以及最終完成對請求的響應,所以一個ASP.NET Core應用針對請求的處理能力和處理方式由註冊的中間件來決定。一個ASP.NET Core在啟動過程中的核心工作就是註冊中間件,本節主要介紹應用啟動過程中以中間件註冊為核心的初始化工作。

目錄
一、Startup
二、IHostingStartup
三、IStartupFilter

一、Startup

由於ASP.NET Core應用承載於以IHost/IHostBuilder為核心的承載系統中,所以在啟動過程中需要的所有操作都可以直接調用IHostBuilder介面相應的方法來完成,但是我們傾向於將這些程式碼單獨定義在按照約定定義的Startup類型中。由於註冊Startup的核心目的是註冊中間件,所以Configure方法是必需的,用於註冊服務的ConfigureServices方法和用來設置第三方依賴注入容器的ConfigureContainer方法是可選的。如下所示的程式碼片段體現了典型的Startup類型定義方式。

public class Startup
{
    public void Configure(IApplicationBuilder app);
    public void ConfigureServices(IServiceCollection services);
    public void ConfigureContainer(FoobarContainerBuilder container);
}

除了顯式調用IWebHostBuilder介面的UseStartup方法或者UseStartup<TStartup>方法註冊一個Startup類型,如果另外一個程式集中定義了合法的Startup類型,我們可以通過配置將它作為啟動程式集。作為啟動程式集的配置項目的名稱為startupAssembly,對應靜態類型WebHostDefaults的只讀欄位StartupAssemblyKey。

public static class WebHostDefaults
{
    public static readonly string StartupAssemblyKey;
    ...
}

一旦啟動程式集通過配置的形式確定下來,系統就會試著從該程式集中找到一個具有最優匹配度的Startup類型。下面列舉了一系列Startup類型的有效名稱,Startup類型載入器正是按照這個順序從啟動程式集類型列表中進行篩選的,如果最終沒有任何一個類型滿足條件,那麼系統會拋出一個InvalidOperationException異常。

  • Startup{EnvironmentName}(全名匹配)。
  • Startup(全名匹配)。
  • {StartupAssembly}.Startup{EnvironmentName}(全名匹配)。
  • {StartupAssembly}.Startup (全名匹配)。
  • Startup{EnvironmentName}(任意命名空間)。
  • Startup(任意命名空間)。

由此可以看出,當ASP.NET Core框架從啟動程式集中定位Startup類型時會優先選擇類型名稱與當前環境名稱相匹配的。為了使讀者對這個選擇策略有更加深刻的認識,下面做一個實例演示。我們利用Visual Studio創建一個名為App的控制台應用,並編寫了如下這段簡單的程式。在如下所示的程式碼片段中,我們將當前命令行參數作為配置源。我們既沒有調用IWebHostBuilder介面的Configure方法註冊任何中間件,也沒有調用UseStartup方法或者UseStartup<TStartup>方法註冊Startup類型。

class Program
{
    static void Main(string[] args)
    {
        Host.CreateDefaultBuilder(args).ConfigureWebHostDefaults(builder => builder.ConfigureLogging(options => options.ClearProviders()))
        .Build()
        .Run();
    }
}

我們創建了另一個名為AppStartup的類庫項目,並在其中定義了如下3個繼承自抽象類StartupBase的類型。根據命名約定,StartupDevelopment類型和StartupStaging類型分別針對Development環境與Staging環境,而Startup類型則不針對某個具體的環境(環境中性)。

namespace AppStartup
{
    public abstract class StartupBase
    {
        public StartupBase() => Console.WriteLine(this.GetType().FullName);
        public void Configure(IApplicationBuilder app) { }
    }

    public class StartupDevelopment : StartupBase { }
    public class StartupStaging: StartupBase { } 
    public class Startup: StartupBase { }
}

由於基類StartupBase的構造函數會將自身類型的全名輸出到控制台上,所以可以根據這個輸出確定哪個Startup類型會被選用。我們採用命令行的形式多次啟動App應用,並以命令行參數的形式指定啟動程式集名稱和當前環境名稱,控制台上呈現的輸出結果如下圖所示。如果沒有顯式指定環境名稱,當前應用就會採用默認的Production環境名稱,所以不針對具體環境的AppStartup.Startup被選擇作為Startup類型。當我們將環境名稱分別顯式設置為Development和Staging之後,被選擇作為Startup類型的分別為StartupDevelopment和StartupStaging。

15

與具體承載環境進行關聯除了可以體現在Startup類型的命名(Startup{EnvironmentName})上,還可以體現在對方法的命名(Configure{EnvironmentName}、Configure{EnvironmentName}Services和Configure{EnvironmentName}Container)上。如下所示的這個Startup類型針對開發環境、預發環境和產品環境定義了對應的方法,如果還有其他的環境,不具有環境名稱的3個方法將會被使用,在上面介紹服務註冊和中間件註冊時已經有明確的說明。

public class Startup
{
    public void Configure(IApplicationBuilder app);
    public void ConfigureServices(IServiceCollection services);
    public void ConfigureContainer(FoobarContainerBuilder container);

    public void ConfigureDevelopment(IApplicationBuilder app);
    public void ConfigureDevelopmentServices(IServiceCollection services);
    public void ConfigureDevelopmentContainer(FoobarContainerBuilder container);

    public void ConfigureStaging(IApplicationBuilder app);
    public void ConfigureStagingServices(IServiceCollection services);
    public void ConfigureStagingContainer(FoobarContainerBuilder container);

    public void ConfigureProduction(IApplicationBuilder app);
    public void ConfigureProductionServices(IServiceCollection services);
    public void ConfigureProductionContainer(FoobarContainerBuilder container);
}

二、IHostingStartup

除了通過註冊Startup類型來初始化應用程式,我們還可以通過註冊一個或者多個IHostingStartup服務達到類似的目的。由於IHostingStartup服務可以通過第三方程式集來提供,如果第三方框架、類庫或者工具需要在應用啟動時做相應的初始化工作,就可以將這些工作實現在註冊的IHostingStart服務中。如下所示的程式碼片段是服務介面IHostingStartup的定義,它只定義了一個唯一的Configure方法,該方法可以利用輸入參數得到當前使用的IWebHost
Builder對象。

public interface IHostingStartup
{
    void Configure(IWebHostBuilder builder);
}

IHostingStartup服務是通過如下所示的HostingStartupAttribute特性來註冊的。從給出的定義可以看出這是一個針對程式集的特性,在構造函數中指定的就是註冊的IHostingStartup類型。由於在同一個程式集中可以多次使用該特性(AllowMultiple=true),所以同一個程式集可以提供多個IHostingStartup服務類型。

[AttributeUsage((AttributeTargets) AttributeTargets.Assembly, Inherited=false, AllowMultiple=true)]
public sealed class HostingStartupAttribute : Attribute
{
    public Type HostingStartupType { get; }
    public HostingStartupAttribute(Type hostingStartupType);
}

如果希望某個程式集提供的IHostingStartup服務類型能夠真正應用到當前程式中,我們需要採用配置的形式對程式集進行註冊。註冊IHostingStartup程式集的配置項名稱為hostingStartupAssemblies,對應靜態類型WebHostDefaults的只讀欄位HostingStartupAssemblies
Key。通過配置形式註冊的程式集名稱以分號進行分隔。當前應用名稱會作為默認的IHostingStartup程式集進行註冊,如果針對IHostingStartup類型的註冊定義在該程式集中,就不需要對該程式集進行顯式配置。

public static class WebHostDefaults
{
    public static readonly string HostingStartupAssembliesKey;
    public static readonly string PreventHostingStartupKey; 
    public static readonly string HostingStartupExcludeAssembliesKey;
}

這一特性還有一個全局開關。如果不希望第三方程式集對當前應用程式進行干預,我們可以通過配置項preventHostingStartup關閉這一特性,該配置項的名稱對應WebHostDefaults的PreventHostingStartupKey屬性。另外,對於布爾值類型的配置項,「true」(不區分大小寫)和「1」都表示True,其他值則表示False。WebHostDefaults還通過HostingStartupExcludeAssembliesKey屬性定義了另一個配置項,其名稱為hostingStartupExcludeAssemblies,用於設置需要被排除的程式集列表。

下面通過對前面的程式略加修改來演示針對IHostingStartup服務的初始化。首先在App項目中定義了如下這個實現了IHostingStartup介面的類型Foo,它實現的Configure方法會在控制台上列印出相應的文字以確定該方法是否被調用。這個自定義的IHostingStartup服務類型通過HostingStartupAttribute特性進行註冊。IHostingStartup相關的配置只有通過環境變數和調用IWebHostBuilder介面的UseSetting方法進行設置才有效,所以雖然我們採用命令行參數提供原始配置,但是必須調用UseSetting方法將它們應用到IWebHostBuilder對象上。

[assembly: HostingStartup(typeof(Foo))]

class Program
{
    static void Main(string[] args)
    {
        var config = new ConfigurationBuilder()
                .AddCommandLine(args)
                .Build();

        Host.CreateDefaultBuilder().ConfigureWebHostDefaults(builder => builder
            .ConfigureLogging(options => options.ClearProviders())
            .UseSetting("hostingStartupAssemblies", config["hostingStartupAssemblies"])
            .UseSetting("preventHostingStartup", config["preventHostingStartup"])
            .Configure(app => app.Run(context => Task.CompletedTask)))
        .Build()
        .Run();
    }
}

public class Foo : IHostingStartup
{
    public void Configure(IWebHostBuilder builder) => Console.WriteLine("Foo.Configure()");
}

另一個AppStartup項目包含如下兩個自定義的IHostingStartup服務類型Bar和Baz,我們採用如下方式利用HostingStartupAttribute特性對它們進行了註冊。

[assembly: HostingStartup(typeof(Bar))]
[assembly: HostingStartup(typeof(Baz))]

public abstract class HostingStartupBarBase : IHostingStartup
{
    public void Configure(IWebHostBuilder builder) => Console.WriteLine($"{GetType().Name}.Configure()");
}
public class Bar : HostingStartupBarBase {}
public class Baz : HostingStartupBarBase {}

我們採用命令行以下圖所示的形式兩次啟動App應用。對於第一次應用啟動,由於對啟動程式集AppStartup進行了顯式設置,由它提供的兩個IHostingStartup服務(Bar和Baz)都得以正常執行。而註冊的IHostingStartup服務Foo,由於被註冊到當前應用程式對應的程式集,雖然我們沒有將它顯式地添加到啟動程式集列表中,但它依然會執行,而且是在其他程式集註冊的IHostingStartup服務之前執行。至於第二次應用啟動,由於我們通過命令行參數關閉了針對IHostingStartup服務的初始化功能,所以Foo、Bar和Baz這3個自定義IHostingStartup服務都不會執行。

16

三、IStartupFilter

中間件的註冊離不開IApplicationBuilder對象,註冊的IStartup服務的Configure方法會利用該對象幫助我們完成中間件的構建與註冊。調用IWebHostBuilder介面的Configure方法時,系統會註冊一個類型為DelegateStartup的IStartup服務,DelegateStartup會利用提供的Action<IApplicationBuilder>對象完成中間件的構建與註冊。

如果調用IWebHostBuilder介面的UseStartup方法或者UseStartup<Startup>方法註冊了一個Startup類型並且該類型沒有實現IStartup介面,系統就會按照約定規則創建一個類型為ConventionBasedStartup的IStartup服務。如果註冊的Startup類型實現了IStartup介面,意味著註冊的就是IStartup服務。

除了採用上述兩種方式利用系統提供的IStartup服務來註冊中間件,我們還可以通過註冊IStartupFilter服務來達到相同的目的。一個應用程式可以註冊多個IStartupFilter服務,它們會按照註冊的順序組成一個鏈表。IStartupFilter介面具有如下所示的唯一方法Configure,中間件的註冊體現在它返回的Action<IApplicationBuilder>對象上,而作為唯一參數的Action<IApplication
Builder>對象則代表了針對後續中間件的註冊。

public interface IStartupFilter
{
    Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next);
}

雖然註冊中間件是IStartup對象和IStartupFilter對象的核心功能,但是兩者之間還是不盡相同的,它們之間的差異在於:IStartupFilter對象的Configure方法會在IStartup對象的Configure方法之前執行。正因為如此,如果需要將註冊的中間件前置或者後置,就需要利用IStartupFilter對象來註冊它們。

接下來我們同樣會演示一個針對IStartupFilter的中間件註冊的實例。首先定義如下兩個中間件類型FooMiddleware和BarMiddleware,它們派生於同一個基類StringContentMiddleware。當InvokeAsync方法被執行時,中間件在將請求分發給後續中間件之前和之後會分別將一段預先指定的文字寫入響應消息的主體內容中,它們代表了中間件針對請求的前置和後置處理。

public abstract class StringContentMiddleware
{
    private readonly RequestDelegate _next;
    private readonly string _preContents;
    private readonly string _postContents; 

    public StringContentMiddleware(RequestDelegate next, string preContents, 
        string postContents)
    {
        _next = next;
        _preContents = preContents;
        _postContents = postContents;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        await context.Response.WriteAsync(_preContents);
        await _next(context);
        await context.Response.WriteAsync(_postContents);
    }
}

public class FooMiddleware : StringContentMiddleware
{
    public FooMiddleware (RequestDelegate next) : base(next, "Foo=>", "Foo") { }
}

public class BarMiddleware : StringContentMiddleware
{
    public BarMiddleware (RequestDelegate next) : base(next, "Bar=>", "Bar=>") { }
}

可以採用如下方式對FooMiddleware和BarMiddleware這兩個中間件進行註冊。具體來說,我們為中間件類型FooMiddleware創建了一個自定義的IStartupFilter類型FooStartupFilter,FooStartupFilter實現的Configure方法中註冊了這個中間件。FooStartupFilter最終通過IWebHostBuilder介面的ConfigureServices方法進行註冊。至於中間件類型BarMiddleware,我們調用IWebHostBuilder介面的Configure方法對它進行註冊。

class Program
{
    static void Main()
    {
        Host.CreateDefaultBuilder().ConfigureWebHostDefaults(builder => builder
            .ConfigureServices(svcs => svcs.AddSingleton<IStartupFilter, FooStartupFilter>())
            .Configure(app => app
                .UseMiddleware<BarMiddleware>()
                .Run(context => context.Response.WriteAsync("...=>"))))
        .Build()
        .Run();
    }
}

public class FooStartupFilter : IStartupFilter
{
    public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
    {
        return app => {
            app.UseMiddleware<FooMiddleware>();
            next(app);
        };
    }
}

由於IStartupFilter的Configure方法會在IStartup的Configure方法之前執行,所以對於最終構建的請求處理管道來說,FooMiddleware中間件置於BarMiddleware中間件前面。換句話說,當管道在處理某個請求的過程中,FooMiddleware中間件的前置請求處理操作會在BarMiddleware中間件之前執行,而它的後置請求處理操作則在BarMiddleware中間件之後執行。在啟動這個程式之後,如果利用瀏覽器對該應用發起請求,得到的輸出結果如下圖所示。

17

ASP.NET Core編程模式[1]:管道式的請求處理
ASP.NET Core編程模式[2]:依賴注入的運用
ASP.NET Core編程模式[3]:配置多種使用形式
ASP.NET Core編程模式[4]:基於承載環境的編程
ASP.NET Core編程模式[5]:如何放置你的初始化程式碼