一題多解,ASP.NET Core應用啟動初始化的N種方案[上篇]

2022-07-05 09:00:43

ASP.NET Core應用本質上就是一個由中介軟體構成的管道,承載系統將應用承載於一個託管程序中執行起來,其核心任務就是將這個管道構建起來。在ASP.NET Core的發展歷史上先後出現了三種應用承載的程式設計方式,而且後一種程式設計模式都提供了針對之前程式設計模式的全部或者部分相容,這就導致了一種現象:相同的更能具有N種實現方式。對這個發展歷程不是特別瞭解的讀者會有很多疑問?為什麼這麼多不同的程式設計模式都在作同一件事?它們之間的有什麼差別之處?為什麼有的API在最新的Minimal API又不能用了呢?[本文部分內容來源於《ASP.NET Core 6框架揭祕》第15章]

目錄
一、應用承載過程中需要哪些初始化工作?
二、第一代應用承載模型
     基本程式設計模式
     利用環境變數和命令列引數
    承載環境設定方法
    使用Startup型別
三、第二代應用承載模型
     基本程式設計模式
     承載環境設定方法
     針對IWebHostBuilder的適配
    Startup建構函式注入的限制

一、應用承載過程中需要哪些初始化工作?

我們所謂的應用承載(Hosting)本就是將一個ASP.NET Core應用在一個具體的程序(Self-Host程序、IIS工作程序或者Windows Service程序等)中被啟動的過程,在這個過程中需要利用提供的API完成一些必要的初始化工作。由於ASP.NET Core應用本質上就是一個由中介軟體構成的管道,所有整個初始化過程的目的就是為了構建這一中介軟體管道,毫不誇張地說,構建的中介軟體管道就是「應用」本身,所以「中介軟體註冊」是最為核心的初始化工作。由於依賴注入的廣泛應用,中介軟體的功能基本都依賴於注入的服務來完成,所以將依賴服務註冊到依賴注入框架是另一項核心的初始化工作。

和任何型別的應用一樣,ASP.NET Core同樣需要通過設定來動態改變其執行時行為,所以針對設定的設定也是並不可少的。一個ASP.NET Core應用的設定分為兩類,一種是用在中介軟體管道構建過程中,也就是應用承載過程中,我們將其稱為「承載設定(Hosting Configuration)」。另一類設定則被用來控制中介軟體管道處理請求的行為,正如上面所說,中介軟體管道就是應用本身,所以這類設定被稱為應用設定(App Configuration)。承載設定中有一個重要的組成部分,那就是描述當前的承載環境(Hosting Environment),比如應用的標識、部署環境的名稱、存放內容檔案和Web資源的目錄等。承載設定最終會合併到應用設定中。

綜上所示,ASP.NET Core應用承載的程式設計模型主要完成如下幾種初始化工作,這些工作都具有N種實現方法。在接下來的內容中,我們將逐個介紹在三種不同的應用承載方式中,這些功能都有哪些實現方式。

  • 中介軟體註冊
  • 服務註冊
  • 承載設定的設定
  • 應用設定的設定
  • 承載環境的設定

二、第一代應用承載模型

ASP.NET Core 1.X/2.X採用的承載模型以如下圖所示的IWebHostBuilder和IWebHost為核心。IWebHost物件代表承載Web應用的宿主(Host),管道隨著IWebHost物件的啟動被構建出來。IWebHostBuilder物件作為宿主物件的構建者,我們針對管道構建的設定都應用在它上面。

image

基本程式設計模式

現在我們將針對上述5種初始化設定放在一個簡單的演示範例中。該演示範例會註冊如下這個FoobarMiddleware中介軟體,後者利用注入的IHandler服務完成請求的處理工作。作為IHandler介面的預設實現型別,Handler利用建構函式注入的IOptions<FoobarbazOptions>物件得到設定選項FoobarbazOptions,並將其內容作為請求的響應。

public class FoobarMiddleware
{
    private readonly RequestDelegate _next;
    public FoobarMiddleware(RequestDelegate _) { }
    public Task InvokeAsync(HttpContext httpContext, IHandler handler) => handler.InvokeAsync(httpContext);
}

public interface IHandler
{
    Task InvokeAsync(HttpContext httpContext);
}

public class Handler : IHandler
{
    private readonly FoobarbazOptions _options;
    private readonly IWebHostEnvironment _environment;

    public Handler(IOptions<FoobarbazOptions> optionsAccessor, IWebHostEnvironment environment)
    {
        _options = optionsAccessor.Value;
        _environment = environment;
    }

    public Task InvokeAsync(HttpContext httpContext)
    {
        var payload = @$"
Environment.ApplicationName: {_environment.ApplicationName}
Environment.EnvironmentName: {_environment.EnvironmentName}
Environment.ContentRootPath: {_environment.ContentRootPath}
Environment.WebRootPath: {_environment.WebRootPath}
Foo: {_options.Foo}
Bar: {_options.Bar}
Baz: {_options.Baz}
";
        return httpContext.Response.WriteAsync(payload);
    }
}

public class FoobarbazOptions
{
    public string Foo { get; set; } = default!;
    public string Bar { get; set; } = default!;
    public string Baz { get; set; } = default!;
}

我們會利用與當前「承載環境」對應設定來繫結設定選項FoobarbazOptions,後者的三個屬性分別來源於三個獨立的組態檔。其中settings.json被所有環境共用,settings.dev.json針對名為「dev」的開發環境。我們為承載環境提供更高的要求,在環境基礎上進步劃分子環境,settings.dev.dev1.json針對的就是dev下的子環境dev1。針對子環境的設定需要利用上述的承載設定來提供。

image

如下所示的就是上述三個組態檔的內容。如果當前環境和子環境分別為dev和dev1,那麼設定選項FoobarbazOptions的內容將來源於這三個組態檔。細心的朋友可能還注意到了:我們並沒有放在預設的根目錄下,而是放在建立的resources目錄下,這是因為我們需要利用針對承載環境的設定改變ASP.NET Core應用存放內容檔案和Web資原始檔的根目錄。

settings.json
{
  "Foo": "123"
}

settings.dev.json
{
  "Bar": "abc"
}

settings.dev.dev1.json
{
  "Baz": "xyz"
}

如下的應用承載程式涵蓋了上述的5種初始化操作。中介軟體的註冊通過呼叫IWebHostBuilder的Configure方法來完成,該方法的引數型別為Action<IApplicationBuilder>,中介軟體就是通過呼叫UseMiddleware<TMiddleware>方法註冊到IApplicationBuilder物件上。IWebHostBuilder並未對承載設定定義專門的方法,但是我們可以利用UseSettings方法以鍵值對的形式對其進行設定,這裡我們採用這種方式完成了針對「環境」、「內容檔案根目錄」、「Web資原始檔根目錄」和「子環境」的設定,前三個是「承載環境」的三個重要屬性。承載設定最終會體現到表示承載上下文的WebHostBuilderContext物件上。

using App;
new WebHostBuilder()
    .UseKestrel()
    .UseSetting(WebHostDefaults.EnvironmentKey,"dev")
    .UseSetting(WebHostDefaults.ContentRootKey , Path.Combine(Directory.GetCurrentDirectory(), "resources"))
    .UseSetting(WebHostDefaults.WebRootKey, Path.Combine(Directory.GetCurrentDirectory(), "resources", "web"))
    .UseSetting("SubEnvironment", "dev1")
    .ConfigureAppConfiguration((context, configBuilder) => configBuilder
            .AddJsonFile(path: "settings.json", optional: false)
            .AddJsonFile(path: $"settings.{context.HostingEnvironment.EnvironmentName}.json", optional: true)
            .AddJsonFile(path: $"settings.{context.HostingEnvironment.EnvironmentName}.{context.Configuration["SubEnvironment"]}.json", optional: true))
    .ConfigureServices((context, services) => services
        .AddSingleton<IHandler, Handler>()
        .Configure<FoobarbazOptions>(context.Configuration))
    .Configure(app => app.UseMiddleware<FoobarMiddleware>())
    .Build()
    .Run();

依賴服務利用IWebHostBuilder的ConfigureServices方法進行註冊,該方法的引數型別為Action<WebHostBuilderContext, IServiceCollection>,意味著我們可以針對之前提供的承載設定(比如承載環境)進行鍼對性的服務註冊。在這裡我們不僅註冊了依賴服務Handler,還利用當前設定對設定選項FoobarbazOptions實施了繫結。應用設定通過專門的方法ConfigureAppConfiguration進行設定,該方法的引數型別為Action<WebHostBuilderContext, IConfigurationBuilder>,意味著承載設定依然可以利用WebHostBuilderContext上下文獲取到,這裡我們這是利用它得到對當前環境匹配的三個組態檔。程式啟動後,請求可以得到如下的響應內容。

image

利用環境變數和命令列引數

由於ASP.NET Core應用在啟動時會使用字首為「ASPNETCORE_」的環境變數作為承載設定,所以上述利用UseSettings方法針對承載設定的設定都可以按照如下的方式利用環境變數代替。

Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "dev");
Environment.SetEnvironmentVariable("ASPNETCORE_SUBENVIRONMENT", "dev1");
Environment.SetEnvironmentVariable("ASPNETCORE_CONTENTROOT", Path.Combine(Directory.GetCurrentDirectory(), "resources"));
Environment.SetEnvironmentVariable("ASPNETCORE_WEBROOT", Path.Combine(Directory.GetCurrentDirectory(), "resources", "web"));

WebHost.CreateDefaultBuilder(args)
    .ConfigureAppConfiguration((context, configBuilder) => configBuilder
            .AddJsonFile(path: "settings.json", optional: false)
            .AddJsonFile(path: $"settings.{context.HostingEnvironment.EnvironmentName}.json", optional: true)
            .AddJsonFile(path: $"settings.{context.HostingEnvironment.EnvironmentName}.{context.Configuration["SubEnvironment"]}.json", optional: true))
    .ConfigureServices((context, services) => services
        .AddSingleton<IHandler, Handler>()
        .Configure<FoobarbazOptions>(context.Configuration))
    .Configure(app => app.UseMiddleware<FoobarMiddleware>())
    .Build()
    .Run();

上面的程式碼片段並沒有直接建立WebHostBuilder物件,而是呼叫WebHost的靜態方法CreateDefaultBuilder方法建立了一個具有預設設定的IWebHostBuilder物件。由於該方法傳入了命令列引數args,它會將命令列引數作為承載設定源之一,所以程式中四個針對承載設定選項也可以利用命令列引數來完成。

承載環境設定方法

其實承載環境(環境名稱、內容檔案根目錄和Web資原始檔根目錄)具有專門的方法,所以最方便的還是直接按照如下的方式呼叫這些方法對它們進行設定。對於我們演示的範例來說,針對環境名稱、內容檔案和Web資原始檔根目錄的設定可以直接呼叫IWebHostBuilder的UseEnvironment、UseContentRoot和UseWebRoot擴充套件方法來完成。

WebHost.CreateDefaultBuilder(args)
    .UseEnvironment("dev")
    .UseContentRoot(Path.Combine(Directory.GetCurrentDirectory(), "resources"))
    .UseWebRoot(Path.Combine(Directory.GetCurrentDirectory(), "resources", "web"))
    .UseSetting("SubEnvironment", "dev1")
    .ConfigureAppConfiguration((context, configBuilder) => configBuilder
            .AddJsonFile(path: "settings.json", optional: false)
            .AddJsonFile(path: $"settings.{context.HostingEnvironment.EnvironmentName}.json", optional: true)
            .AddJsonFile(path: $"settings.{context.HostingEnvironment.EnvironmentName}.{context.Configuration["SubEnvironment"]}.json", optional: true))
    .ConfigureServices((context, services) => services
        .AddSingleton<IHandler, Handler>()
        .Configure<FoobarbazOptions>(context.Configuration))
    .Configure(app => app.UseMiddleware<FoobarMiddleware>())
    .Build()
    .Run();

使用Startup型別

為了不讓應用承載程式程式碼顯得過於臃腫,我們一般都會將服務註冊和中介軟體註冊移到按照約定定義的Startup型別中。如下面的程式碼片段所示,中介軟體和服務註冊分別實現在Startup型別的ConfigureServices和Configure方法中,我們直接在建構函式中注入IConfiguration物件得到承載設定物件。值得一提,對於第一代應用承載方式,我們可以在Startup型別的建構函式中注入通過呼叫IWebHostBuilder的ConfigureServices方法註冊的任何服務(包括ASP.NET Core內部通過呼叫這個方法註冊的服務,比如本例的IConfiguration物件)。Startup型別只需要呼叫IWebHostBuilder的UseStartup<TStartup>擴充套件方法進行註冊即可。

WebHost.CreateDefaultBuilder(args)
    .UseEnvironment("dev")
    .UseContentRoot(Path.Combine(Directory.GetCurrentDirectory(), "resources"))
    .UseWebRoot(Path.Combine(Directory.GetCurrentDirectory(), "resources", "web"))
    .UseSetting("SubEnvironment", "dev1")
    .ConfigureAppConfiguration((context, configBuilder) => configBuilder
            .AddJsonFile(path: "settings.json", optional: false)
            .AddJsonFile(path: $"settings.{context.HostingEnvironment.EnvironmentName}.json", optional: true)
            .AddJsonFile(path: $"settings.{context.HostingEnvironment.EnvironmentName}.{context.Configuration["SubEnvironment"]}.json", optional: true))
    .UseStartup<Startup>()
    .Build()
    .Run();

public class Startup
{
    public IConfiguration Configuration { get; }
    public Startup(IConfiguration configuration) => Configuration = configuration;
    public void ConfigureServices(IServiceCollection services) => services
        .AddSingleton<IHandler, Handler>()
        .Configure<FoobarbazOptions>(Configuration);
    public void Configure(IApplicationBuilder app) => app.UseMiddleware<FoobarMiddleware>();
}

三、第二代應用承載模型

除了承載Web應用,我們還有很多針對後臺服務(比如很多批次處理任務)的承載需求,為此微軟推出了以IHostBuilder/IHost為核心的服務承載系統。Web應用本身實際上就是一個長時間執行的後臺服務,我們完全可以將應用定義成一個IHostedService服務,該型別就是下圖所示的GenericWebHostService。如果將上面介紹的稱為第一代應用承載模式的話,這就是第二代承載模式。

image

基本程式設計模式

和所有的Builder模式一樣,絕大部分API都落在作為構建者的IHostBuilder介面上,服務註冊、承載設定、應用設定都具有對應的方法。由於中介軟體隸屬於GenericWebHostService這一單一的承載服務,所以只能記住與IWebHostBuilder。如果採用第二代應用承載模型,上面演示的程式可以改寫成如下的形式。

Host.CreateDefaultBuilder()
    .ConfigureHostConfiguration(config => config.AddInMemoryCollection(new Dictionary<string, string> {
        [WebHostDefaults.EnvironmentKey] = "dev",
        [WebHostDefaults.ContentRootKey] = Path.Combine(Directory.GetCurrentDirectory(), "resources"),
        [WebHostDefaults.WebRootKey] = Path.Combine(Directory.GetCurrentDirectory(), "resources","web"),
        ["SubEnvironment"] = "dev1"
    }))
    .ConfigureAppConfiguration((context, configBuilder) => configBuilder
            .AddJsonFile(path: "settings.json", optional: false)
            .AddJsonFile(path: $"settings.{context.HostingEnvironment.EnvironmentName}.json", optional: true)
            .AddJsonFile(path: $"settings.{context.HostingEnvironment.EnvironmentName}.{context.Configuration["SubEnvironment"]}.json", optional: true))
    .ConfigureServices((context,services) => services
        .AddSingleton<IHandler, Handler>()
        .Configure<FoobarbazOptions>(context.Configuration))
    .ConfigureWebHost(webHostBuilder => webHostBuilder.Configure(app=>app.UseMiddleware<FoobarMiddleware>()))
    .Build()
    .Run();

如上面的程式碼片段所示,我們通過呼叫Host的靜態方法CreateDefaultBuilder方法建立一個具有預設設定的IHostBuidler物件。IHostBuilder為承載設定的設定提供了獨立的ConfigureHostConfiguration方法,該方法的引數型別為Action<IConfigurationBuilder>,我們演示的例子利用這個方法註冊了一個基於記憶體字典的設定源,承載環境(環境名稱、內容檔案和Web資原始檔根目錄)和子環境名稱在這裡進行了設定。針對應用設定的設定通過ConfigureAppConfiguration方法來完成,該方法的引數型別為Action<HostBuilderContext, IConfigurationBuilder>,代表承載上下文的HostBuilderContext可以得到預先設定的承載環境和承載設定,我們的例子利用到定位與當前環境相匹配的組態檔。

IHostBuilder同樣定義了ConfigureServices方法,該方法的引數型別為Action<HostBuilderContext, IServiceCollection>,意味著服務依然可以針對承載環境和承載設定進行註冊。由於中介軟體的註冊依然落在IWebHostBuilder上,所以IHostBuilder提供了ConfigureWebHost/ConfigureWebHostDefaults這兩個擴充套件方法予以適配,它們具有一個型別為Action<IWebHostBuilder>的引數。

承載環境設定方法

和IWebHostBuilder一樣,IHostBuidler同樣提供了用來直接設定承載環境的方法。對於我們演示的範例來說,針對環境名稱、內容檔案和Web資原始檔根目錄的設定可以直接呼叫IHostBuidler的UseEnvironment、UseContentRoot和UseWebRoot擴充套件方法來完成。由於Web資原始檔並未「服務承載」的範疇,所以針對Web資原始檔根目錄的設定還得采用直接設定承載設定的方式(或者呼叫IWebHostBuilder的UseWebRoot擴充套件方法)。

Host.CreateDefaultBuilder()
    .UseEnvironment("dev")
    .UseContentRoot(Path.Combine(Directory.GetCurrentDirectory(), "resources"))
    .ConfigureHostConfiguration(config => config.AddInMemoryCollection(new Dictionary<string, string> {
        [WebHostDefaults.WebRootKey] = Path.Combine(Directory.GetCurrentDirectory(), "resources","web"),
        ["SubEnvironment"] = "dev1"
    }))
    .ConfigureAppConfiguration((context, configBuilder) => configBuilder
            .AddJsonFile(path: "settings.json", optional: false)
            .AddJsonFile(path: $"settings.{context.HostingEnvironment.EnvironmentName}.json", optional: true)
            .AddJsonFile(path: $"settings.{context.HostingEnvironment.EnvironmentName}.{context.Configuration["SubEnvironment"]}.json", optional: true))
    .ConfigureServices((context,services) => services
        .AddSingleton<IHandler, Handler>()
        .Configure<FoobarbazOptions>(context.Configuration))
    .ConfigureWebHost(webHostBuilder => webHostBuilder.Configure(app=>app.UseMiddleware<FoobarMiddleware>()))
    .Build()
    .Run();

針對IWebHostBuilder的適配

由於IHostBuilder利用擴充套件方法ConfigureWebHost/ConfigureWebHostDefaults提供了針對IWebHostBuilder的適配,意味著前面採用第一代應用承載方法編寫的程式碼可以直接移植過來。如下面的程式碼片段所示,靜態方法ConfigureWebHost完全依然利用IWebHostBuilder完成所有的初始化工作,我們只需要將指向該方法的Action<IWebHostBuilder>委託傳入IHostBuilder的ConfigureWebHostDefaults擴充套件方法就可以了。

Host.CreateDefaultBuilder(args)
    .ConfigureWebHostDefaults(ConfigureWebHost)
    .Build()
    .Run();

static void ConfigureWebHost(IWebHostBuilder webHostBuilder)
{
    webHostBuilder.UseEnvironment("dev")
        .UseContentRoot(Path.Combine(Directory.GetCurrentDirectory(), "resources"))
        .UseWebRoot(Path.Combine(Directory.GetCurrentDirectory(), "resources", "web"))
        .UseSetting("SubEnvironment", "dev1")
        .ConfigureAppConfiguration((context, configBuilder) => configBuilder
            .AddJsonFile(path: "settings.json", optional: false)
            .AddJsonFile(path: $"settings.{context.HostingEnvironment.EnvironmentName}.json", optional: true)
            .AddJsonFile(path: $"settings.{context.HostingEnvironment.EnvironmentName}.{context.Configuration["SubEnvironment"]}.json", optional: true))
        .UseStartup<Startup>();
}

public class Startup
{
    public IConfiguration Configuration { get; }
    public Startup(IConfiguration configuration) => Configuration = configuration;
    public void ConfigureServices(IServiceCollection services) => services
        .AddSingleton<IHandler, Handler>()
        .Configure<FoobarbazOptions>(Configuration);
    public void Configure(IApplicationBuilder app) => app.UseMiddleware<FoobarMiddleware>();
}

Startup建構函式注入的限制

第二代應用承載模型利用ConfigureWebHost/ConfigureWebHostDefaults擴充套件方法對之前定義在IWebHostBuilder上的API(絕大部分是擴充套件方法)提供了100%的支援(除了Build方法),但是針對Startup建構函式中注入的服務則不再那麼自由。如果採用基於IWebHostBuilder/IWebHost的應用承載方式,通過呼叫IWebHostBuilder的ConfigureServices方法註冊的服務都可以注入Startup的建構函式中,如果採用基於IHostBuilder/IHost的應用承載方式,只有與「承載設定(承載環境屬於承載設定的一部分)」相關的如下三個服務能夠注入到Startup的建構函式中。

  • IHostingEnvironment
  • IWebHostEnvironment
  • IHostEnvironment
  • IConfiguration

對於如下這段程式碼,雖然注入Startup建構函式的Foobar同時通過呼叫IHostBuilder和IWebHostBuilder的ConfigureServices方法中進行了註冊,但是在建立Startup範例的時候依然會丟擲異常。

Host.CreateDefaultBuilder(args)
    .ConfigureServices(sevices=>sevices.AddSingleton<Foobar>())
    .ConfigureWebHostDefaults(ConfigureWebHost)
    .Build()
    .Run();

static void ConfigureWebHost(IWebHostBuilder webHostBuilder)
{
    webHostBuilder.UseEnvironment("dev")
        .ConfigureServices(sevices => sevices.AddSingleton<Foobar>())
        .UseContentRoot(Path.Combine(Directory.GetCurrentDirectory(), "resources"))
        .UseWebRoot(Path.Combine(Directory.GetCurrentDirectory(), "resources", "web"))
        .UseSetting("SubEnvironment", "dev1")
        .ConfigureAppConfiguration((context, configBuilder) => configBuilder
            .AddJsonFile(path: "settings.json", optional: false)
            .AddJsonFile(path: $"settings.{context.HostingEnvironment.EnvironmentName}.json", optional: true)
            .AddJsonFile(path: $"settings.{context.HostingEnvironment.EnvironmentName}.{context.Configuration["SubEnvironment"]}.json", optional: true))
        .UseStartup<Startup>();
}

public class Startup
{
    public IConfiguration Configuration { get; }
    public Startup(IConfiguration configuration,Foobar foobar) => Configuration = configuration;
    public void ConfigureServices(IServiceCollection services) => services
        .AddSingleton<IHandler, Handler>()
        .Configure<FoobarbazOptions>(Configuration);
    public void Configure(IApplicationBuilder app) => app.UseMiddleware<FoobarMiddleware>();
}

public class Foobar
{ }

綜上所述,最初版本的ASP.NET Core由於只考慮到Web應用自身的承載,所以設計出了基於IWebHostBuilder/IWebHost模型。後來產生了基於後臺服務承載的需求,所以推出了基於IHostBuilder/IHost的服務承載模型,原本的Web應用作為一個「後臺服務(GenericWebHostService.)」進行承載。由於之前很多API都落在IWebHostBuilder(主要無數的擴充套件方法),出於相容性的需求,一個名為GenericWebHostBuilder的實現型別被定義出來,它將針對IWebHostBuilder的方法來電轉駁到IHostBuilder/IHost的服務承載模型中。

.NET 6在IHostBuilder/IHost服務承載模型基礎上推出了更加簡潔的Minimal API,此時又面臨相同的「抉擇」。這次它不僅需要相容IWebHostBuilder,還得相容IHostBuilder,在加上Minimal API自身提供的API,所以「一題多解」的現象就更多了。如果你對ASP.NET Core的歷史不甚瞭解,將會感到非常困惑。令你們更加感到困惑的時,此時定義在IWebHostBuilder和IHostBuilder的API並非全部可用,本文的下篇將為你一一解惑。