ASP.NET Core使用HostingStartup增強啟動操作

yi念之間發表於2020-11-23

概念

    在ASP.NET Core中我們可以使用一種機制來增強啟動時的操作,它就是HostingStartup。如何叫"增強"操作,相信瞭解過AOP概念的同學應該都非常的熟悉。我們常說AOP使用了關注點分離的方式,增強了對現有邏輯的操作。而我們今天要說的HostingStartup就是為了"增強"啟動操作,這種"增強"的操作甚至可以對現有的程式可以做到無改動的操作。例如,外部程式集可通過HostingStartup實現為應用提供配置服務、註冊服務或中介軟體管道操作等。

使用方式

    HostingStartup屬性表示要在執行時啟用的承載啟動程式集。大致分為兩種情況,一種是自動掃描當前Web程式集中通過HostingStartup指定的類,另一種是手動新增配置hostingstartupassembles指定外部的程式集中通過HostingStartup指定的類。第一種方式相對簡單,但是對Web程式本身有入侵,第二種方式稍微複雜一點點,但是可以做到對現有程式碼無入侵操作,接下來我們分別演示這兩種使用方式。

ASP.NET Core中直接定義

首先是在ASP.NET Core程式中直接使用HostingStartup,這種方式比較簡單首先在Web程式中隨便定義一個類,然後實現IHostingStartup介面,最後別忘了在程式集中新增HostingStartupAttribute指定要啟動的類的型別,具體程式碼如下所示

using System;
using System.Collections.Generic;
using System.Diagnostics;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
//通過HostingStartup指定要啟動的型別
[assembly: HostingStartup(typeof(HostStartupWeb.HostingStartupInWeb))]
namespace HostStartupWeb
{
    public class HostingStartupInWeb : IHostingStartup
    {
        public void Configure(IWebHostBuilder builder)
        {
            //程式啟動時列印依據話,代表執行到了這裡
            Debug.WriteLine("Web程式中HostingStartupInWeb類啟動");

            //可以新增配置
            builder.ConfigureAppConfiguration(config => {
                //模擬新增一個一個記憶體配置
                var datas = new List<KeyValuePair<string, string>>
                {
                    new KeyValuePair<string, string>("ServiceName", "HostStartupWeb")
                };
                config.AddInMemoryCollection(datas);
            });

            //可以新增ConfigureServices
            builder.ConfigureServices(services=> {
                //模擬註冊一個PersonDto
                services.AddScoped(provider=>new PersonDto { Id = 1, Name = "yi念之間", Age = 18 });
            });

            //可以新增Configure
            builder.Configure(app => {
                //模擬新增一箇中介軟體
                app.Use(async (context, next) =>
                {
                    await next();
                });
            });
        }
    }
}

僅僅使用上面所示的這些程式碼,便可在Web程式啟動的時候去自動執行HostingStartupInWeb的Configure方法,在這裡面我們幾乎可以使用所有針對ASP.NET Core程式配置的操作,而且不需要在Web程式中額外新增別的程式碼就可以自動呼叫HostingStartupInWeb的Configure方法。

外部程式集引入

我們之前也說過,上面的方式雖然使用起來相對簡單一點,僅僅是一點,那就是省去了指定啟動程式集的邏輯。但是,上面的方式需要在Web程式中新增,這樣的話還是會修改程式碼。而且,可能更多的時候我們是在外部的程式集中編寫HostingStartup邏輯,這時候就需要使用另一種方式在將外部程式集中引入HostingStartup。首先我們要在自定義的程式集中至少引入Microsoft.AspNetCore.Hosting包才能使用HostingStartup

<PackageReference Include="Microsoft.AspNetCore.Hosting" Version="2.2.7" />

如果你不需要使用註冊中介軟體的邏輯那麼僅僅引入Microsoft.AspNetCore.Hosting.Abstractions即可

<PackageReference Include="Microsoft.AspNetCore.Hosting.Abstractions" Version="2.2.0" />

如果需要使用其他功能包,可以自行在定義的程式集中引入。比如我們定義了一個名為HostStartupLib的Standard類庫,並建立了名為HostStartupLib的類

using System;
using System.Collections.Generic;
using System.Diagnostics;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
[assembly: HostingStartup(typeof(HostStartupLib.HostingStartupInLib))]
namespace HostStartupLib
{
    public class HostingStartupInLib : IHostingStartup
    {
        public void Configure(IWebHostBuilder builder)
        {
            Debug.WriteLine("Lib程式中HostingStartupInLib類啟動");

            //新增配置
            builder.ConfigureAppConfiguration((context, config) => {
                var datas = new List<KeyValuePair<string, string>>
                {
                    new KeyValuePair<string, string>("ServiceName", "HostStartupLib")
                };
                config.AddInMemoryCollection(datas);
            });

            //新增ConfigureServices
            builder.ConfigureServices(services=> {
                services.AddScoped(provider=>new PersonDto { Id = 2, Name = "er念之間", Age = 19 });
            });

            //新增Configure
            builder.Configure(app => {
                app.Use(async (context, next) =>
                {
                    await next();
                });
            });

        }
    }
}

然後我們將自定義的HostStartupLib這個Standard類庫引入Web專案中,執行Web程式,發現HostingStartupInLib的Configure方法並不能被呼叫。其實我們上面說過了,將HostingStartup從外部程式集引入的話需要手動指定啟動程式集的名稱。指定啟動程式集的方式有兩種,一種是指定IWebHostBuilder的擴充套件UseSetting指定

public static IHostBuilder CreateHostBuilder(string[] args) =>
            Host.CreateDefaultBuilder(args)
                .ConfigureWebHostDefaults(webBuilder =>
                {
                    //通過UseSetting的方式指定程式集的名稱
                    webBuilder.UseSetting(WebHostDefaults.HostingStartupAssembliesKey, "HostStartupLib");
                    //如果HostingStartup存在多個程式集中可以使用;分隔,比如HostStartupLib;HostStartupLib2
                    //webBuilder.UseSetting(WebHostDefaults.HostingStartupAssembliesKey, "HostStartupLib;HostStartupLib2");
                    webBuilder.UseStartup<Startup>();
                });

另一種通過新增環境變數ASPNETCORE_HOSTINGSTARTUPASSEMBLIES的方式,可以通過設定launchSettings.json中

"environmentVariables": {
        "ASPNETCORE_HOSTINGSTARTUPASSEMBLIES": "HostStartupLib"
        //如果HostingStartup存在多個程式集中可以使用;分隔,比如HostStartupLib;HostStartupLib2
        //"ASPNETCORE_HOSTINGSTARTUPASSEMBLIES": "HostStartupLib;HostStartupLib2"
}

    可以引入多個包含HostingStartup的程式集,在設定WebHostDefaults.HostingStartupAssembliesKey或者ASPNETCORE_HOSTINGSTARTUPASSEMBLIES指定多個程式集名稱可以使用英文分號(;)隔開程式集名稱。雖然是兩種形似指定,但是其實本質是一樣的那就是設定配置key為hostingStartupAssemblie配置的值,下面我們會詳細講解。
    通過在程式中設定環境變數的方式等同於Window系統中Set的方式設定環境變數,或Linux系統中export的方式設定環境變數,亦或是直接設定系統環境變數,效果都是一致的。指定完成啟動程式集之後,再次執行程式便可以看到HostingStartupInLib的Configure方法被呼叫到了。在這裡我們可以看到如果是使用的環境變數的方式去指定啟動程式集的話,對現有程式碼可以做到完全無入侵。

原始碼探究

在上面我們簡單的介紹了HostingStartup的概念及基本的使用方式,基於這些我們產生了幾個疑問

  • 首先是關於HostingStartup的基本工作方式是什麼
  • 其次是為什麼HostingStartup在Web程式中不需要配置程式集資訊就可以被呼叫到,而通過外部程式集引入HostingStartup需要手動指定程式集
  • 最後是通過外部程式集引入HostingStartup的指定方式為何只能是UseSetting和環境變數的方式
    基於以上幾個疑問,我們來探索一下HostingStartup的相關原始碼,來揭開它的神祕面紗。首先廢話不多說直接找到原始碼位置[點選檢視原始碼?]在GenericWebHostBuilder類中的ExecuteHostingStartups方法中,關於GenericWebHostBuilder類我們在上篇文章深入探究ASP.NET Core Startup初始化中主要就是分析這個類,因為這是構建WebHost的預設類,而我們接下來要說的ExecuteHostingStartups方法也是承載在這個類中,直接貼程式碼如下所示
private void ExecuteHostingStartups()
{
    //通過配置_config和當前程式集名稱構建WebHostOptions類
    var webHostOptions = new WebHostOptions(_config, Assembly.GetEntryAssembly()?.GetName().Name);
    //如果PreventHostingStartup屬性為true則直接返回
    //通過這個可以配置阻止啟動邏輯
    if (webHostOptions.PreventHostingStartup)
    {
        return;
    }

    var exceptions = new List<Exception>();
    //構建HostingStartupWebHostBuilder
    _hostingStartupWebHostBuilder = new HostingStartupWebHostBuilder(this);
    //GetFinalHostingStartupAssemblies獲取最終要執行的程式集名稱
    foreach (var assemblyName in webHostOptions.GetFinalHostingStartupAssemblies().Distinct(StringComparer.OrdinalIgnoreCase))
    {
        try
        {
            //通過程式集名稱載入程式集資訊,因為使用了AssemblyName所以只需要使用程式集名稱即可
            var assembly = Assembly.Load(new AssemblyName(assemblyName));
            //獲取包含HostingStartupAttribute的程式集
            foreach (var attribute in assembly.GetCustomAttributes<HostingStartupAttribute>())
            {
                //例項化HostingStartupAttribute的HostingStartupType屬性的物件例項
                //即我們上面宣告的[assembly: HostingStartup(typeof(HostStartupWeb.HostingStartupInWeb))]
                var hostingStartup = (IHostingStartup)Activator.CreateInstance(attribute.HostingStartupType);
                //呼叫HostingStartup的Configure方法
                hostingStartup.Configure(_hostingStartupWebHostBuilder);
            }
        }
        catch (Exception ex)
        {
            exceptions.Add(new InvalidOperationException($"Startup assembly {assemblyName} failed to execute. See the inner exception for more details.", ex));
        }
    }

    if (exceptions.Count > 0)
    {
        _hostingStartupErrors = new AggregateException(exceptions);
    }
}

    通過上面的原始碼我們就可以很清楚的瞭解到HostingStartup的基本工作方式。獲取的程式集中包含的HostingStartupAttribute,通過獲取HostingStartupAttribute的HostingStartupType屬性得到要執行的IHostingStartup例項,最後執行Configure方法,Configure方法需要傳遞IWebHostBuilder的例項,而HostingStartupWebHostBuilder正是實現了IWebHostBuilder介面。
    我們瞭解到了HostStartup的工作方式,接下來我們來探究一下為什麼HostingStartup在Web程式中不需要配置程式集資訊就可以被呼叫到,而通過外部程式集引入HostingStartup需要手動指定程式集。通過上面的原始碼我們可以得到一個資訊那就是所有需要啟動的程式集資訊都是來自WebHostOptions的GetFinalHostingStartupAssemblies方法,接下來我們就來檢視一下GetFinalHostingStartupAssemblies方法的實現原始碼[點選檢視原始碼?]

public IEnumerable<string> GetFinalHostingStartupAssemblies()
{
    return HostingStartupAssemblies.Except(HostingStartupExcludeAssemblies, StringComparer.OrdinalIgnoreCase);
}

從這裡我們可以看出程式集資訊來自於HostingStartupAssemblies屬性,而且還要排除掉HostingStartupExcludeAssemblies包含的程式集。我們找到他們初始化的相關邏輯大致如下

//承載啟動是需要呼叫的HostingStartup程式集
public IReadOnlyList<string> HostingStartupAssemblies { get; set; }
//承載啟動時排除掉不不要執行的程式集
public IReadOnlyList<string> HostingStartupExcludeAssemblies { get; set; }
//是否阻止HostingStartup啟動執行功能,如果設定為false則HostingStartup功能失效
//通過上面的ExecuteHostingStartups方法原始碼可知
public bool PreventHostingStartup { get; set; }
//應用程式名稱
public string ApplicationName { get; set; }

public WebHostOptions(IConfiguration configuration, string applicationNameFallback)
{
    ApplicationName = configuration[WebHostDefaults.ApplicationKey] ?? applicationNameFallback;
    HostingStartupAssemblies = Split($"{ApplicationName};{configuration[WebHostDefaults.HostingStartupAssembliesKey]}");
    HostingStartupExcludeAssemblies = Split(configuration[WebHostDefaults.HostingStartupExcludeAssembliesKey]);
    PreventHostingStartup = WebHostUtilities.ParseBool(configuration, WebHostDefaults.PreventHostingStartupKey);
}

//分隔配置的程式集資訊,分隔依據為";"分號,這也是我們上面說過配置多程式集的時候採用分號分隔的原因
private IReadOnlyList<string> Split(string value)
{
    return value?.Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
        ?? Array.Empty<string>();
}

    首先,通過HostingStartupAssemblies的初始化邏輯我們可以得出,預設會是有兩個資料來源,一個是當前的ApplicationName,另一個是通過HostingStartupAssembliesKey配置的程式集資訊。這也解答了我們上面說過的為什麼HostingStartup在Web程式中不需要配置程式集資訊就可以被呼叫到,而通過外部程式集引入HostingStartup需要手動指定程式集。其次,我們可以瞭解到通過配置HostingStartupExcludeAssemblies資訊排除你不想啟動的HostingStartup程式集,而且還可以通過配置PreventHostingStartup值來禁止使用HostingStartup的功能。
通過上面的程式碼我們還了解到這三個屬性的來源的配置名稱都是來自WebHostDefaults這個常量類,接下來我們檢視一下這三個屬性對應的配置名稱

public static readonly string HostingStartupAssembliesKey = "hostingStartupAssemblies";
public static readonly string HostingStartupExcludeAssembliesKey = "hostingStartupExcludeAssemblies";
public static readonly string PreventHostingStartupKey = "preventHostingStartup";

也就是說,我們可以可以通過配置這三個名稱的配置,來完成HostingStartup相關的功能比如

public static IHostBuilder CreateHostBuilder(string[] args) =>
            Host.CreateDefaultBuilder(args)
                .ConfigureWebHostDefaults(webBuilder =>
                {
                    //通過UseSetting的方式指定程式集的名稱
                    webBuilder.UseSetting(WebHostDefaults.HostingStartupAssembliesKey, "HostStartupLib");
                    //如果HostingStartup存在多個程式集中可以使用;分隔,比如HostStartupLib;HostStartupLib2
                    //webBuilder.UseSetting(WebHostDefaults.HostingStartupAssembliesKey, "HostStartupLib;HostStartupLib2");

                    //排除執行HostStartupLib2程式集執行HostingStartup邏輯
                    webBuilder.UseSetting(WebHostDefaults.HostingStartupExcludeAssembliesKey, "HostStartupLib2");
                    //禁用HostingStartup功能
                    webBuilder.UseSetting(WebHostDefaults.PreventHostingStartupKey, "true");
                    webBuilder.UseStartup<Startup>();
                });

或通過環境變數的方式去操作

"environmentVariables": {
        "ASPNETCORE_HOSTINGSTARTUPASSEMBLIES": "HostStartupLib",
        //如果HostingStartup存在多個程式集中可以使用;分隔,比如HostStartupLib;HostStartupLib2
        //"ASPNETCORE_HOSTINGSTARTUPASSEMBLIES": "HostStartupLib;HostStartupLib2"

       //排除執行HostStartupLib2程式集執行HostingStartup邏輯
       "ASPNETCORE_HOSTINGSTARTUPEXCLUDEASSEMBLIES":"HostStartupLib2",
       //禁用HostingStartup功能
       "ASPNETCORE_PREVENTHOSTINGSTARTUP":"true"
}

其實這兩種配置方式是完全等價的,為什麼這麼說呢?首先是在Configuration中獲取配置是忽略大小寫的,其實是使用ConfigureWebHostDefaults配置WebHost相關資訊的時候會新增configBuilder.AddEnvironmentVariables(prefix: "ASPNETCORE_")邏輯這樣的話獲取環境變數的時候可以忽略ASPNETCORE_字首。
那麼到目前為止,還有一個疑問尚未解決,那就是為何只能通過UseSetting和環境變數的方式去配置HostingStartup相關配置,解鈴還須繫鈴人,我們在上面的ExecuteHostingStartups方法中看到了這個邏輯

//這裡傳遞了一個_config
var webHostOptions = new WebHostOptions(_config, Assembly.GetEntryAssembly()?.GetName().Name);

我們可以看到傳遞了配置Configuration的例項_config,我們到初始化_config地方有如下邏輯

var configBuilder = new ConfigurationBuilder()
                .AddInMemoryCollection();
if (!options.SuppressEnvironmentConfiguration)
{
    //新增環境變數
    configBuilder.AddEnvironmentVariables(prefix: "ASPNETCORE_");
}
//構建了_config例項
private readonly IConfiguration _config = configBuilder.Build();

也就可以解釋為何我們可以通過環境變數去配置HostingStartup,然後我們再來看UseSetting方法的邏輯

public IWebHostBuilder UseSetting(string key, string value)
{
    _config[key] = value;
    return this;
}

原來UseSetting也是給_config例項設定值,所以無論通過UseSetting或環境環境變數的方式去配置,本質都是在操作_config這個配置例項,到此為止所有謎團均以解開。

在SkyAPM中的使用

我們上面說了HostingStartup可以增強啟動時候的操作,可以通過對現有程式碼無入侵的方式增強程式功能。而SkyAPM-dotnet也正是使用了這個功能,實現了無入侵啟動APM監控。我們來回顧一下SkyAPM-dotnet的使用方式

  • 首先是使用Nuget新增SkyAPM.Agent.AspNetCore程式集引用。
  • 其次是在launchSettings.json檔案中新增ASPNETCORE_HOSTINGSTARTUPASSEMBLIES:"SkyAPM.Agent.AspNetCore"環境變數配置(等同於set ASPNETCORE_HOSTINGSTARTUPASSEMBLIES=SkyAPM.Agent.AspNetCore或export ASPNETCORE_HOSTINGSTARTUPASSEMBLIES=SkyAPM.Agent.AspNetCore
    的方式,本質都是在配置環境變數)
  • 最後通過SKYWALKING__SERVICENAME設定程式名稱
    這裡我們通過需要配置ASPNETCORE_HOSTINGSTARTUPASSEMBLIES名稱可以看出確實是使用了HostingStartup功能,而通過HostingStartup增強的操作入口肯定就在SkyAPM.Agent.AspNetCore程式集中,我們找到SkyAPM.Agent.AspNetCore程式集的原始碼[點選檢視原始碼?]看到了SkyApmHostingStartup類實現如下
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using SkyApm.Agent.AspNetCore;
using SkyApm.AspNetCore.Diagnostics;

[assembly: HostingStartup(typeof(SkyApmHostingStartup))]

namespace SkyApm.Agent.AspNetCore
{
    internal class SkyApmHostingStartup : IHostingStartup
    {
        public void Configure(IWebHostBuilder builder)
        {
            builder.ConfigureServices(services => services.AddSkyAPM(ext => ext.AddAspNetCoreHosting()));
        }
    }
}

通過這個我們可以看出確實如此,當然也是等同於我們通過UseSetting(WebHostDefaults.HostingStartupAssembliesKey, "SkyApm.Agent.AspNetCore")去配置,我們甚至可使用如下的方式去使用SkyAPM-dotnet

public void ConfigureServices(IServiceCollection services)
{
   services.AddSkyAPM(ext => ext.AddAspNetCoreHosting())
}

這些寫法其實是完全等價的,但是通過環境變數的方式配置HostingStartup啟動程式集的方式無疑是最優雅的。所以我們在日常的學習開發中,最好還是通過這種方式去操作。

改造Zipkin使用

我們在之前的文章ASP.NET Core整合Zipkin鏈路跟蹤中曾演示過基於診斷日誌DiagnosticSource改進Zipkin的整合方式,通過本篇文章講述的HostingStartup我們可以進步一改進Zipkin的整合方式,可以讓它使用起來和SkyAPM-dotnet類似的方式,我們基於之前的示例中的ZipkinExtensions程式集中新增一個ZipkinHostingStartup類,用於承載整合Zipkin的操作,程式碼如下

using System;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.DependencyInjection;

namespace ZipkinExtensions
{
    public class ZipkinHostingStartup: IHostingStartup
    {

        public void Configure(IWebHostBuilder builder)
        {
            builder.ConfigureServices(services=> {
                services.AddZipkin();
                services.AddSingleton<ITraceDiagnosticListener, HttpDiagnosticListener>();
            });

            builder.Configure(app=> {
                IHostApplicationLifetime lifetime = app.ApplicationServices.GetService<IHostApplicationLifetime>();
                ILoggerFactory loggerFactory = app.ApplicationServices.GetService<ILoggerFactory>();
                IConfiguration configuration = app.ApplicationServices.GetService<IConfiguration>();
                string serivceName = configuration.GetValue<string>("ServiceName");
                string zipKinUrl = configuration.GetValue<string>("ASPNETCORE_ZIPKINADDRESS");

                app.UseZipkin(lifetime, loggerFactory, serivceName, zipKinUrl);
            });
        }
    }
}

然後在每個專案的launchSettings.json檔案中新增如下所示的配置即可,這樣的話就可以做到對現有業務程式碼無任何入侵。

 "environmentVariables": {
    "ASPNETCORE_HOSTINGSTARTUPASSEMBLIES": "ZipkinExtensions",
    "ASPNETCORE_ZIPKINADDRESS": "http://localhost:9411/"
  }

總結

    本文介紹了HostingStartup的基本概念,基礎使用以及對其原始碼的分析和在SkyAPM-dotnet中的應用,最後我們改造了Zipkin的整合方式。HostingStartup在一些整合APM或者鏈路跟蹤的類似場景還是非常實用的,或者如果我們有整合一些基礎元件或者三方的元件,但是我們的程式碼中並不需要直接的使用這些元件中的類或者直接的程式碼關係,均可以使用HostingStartup的方式去整合,為我們實現對現有程式碼提供無入侵增強提供了強大的支援。關於HostingStartup我也是在看原始碼中無意發現的,後來發現微軟ASP.NET Core官方文件
Use hosting startup assemblies in ASP.NET Core一文中有講解,然後聯想到自己使用過的SkyAPM-dotnet正是使用了HostingStartup+診斷日誌DiagnosticSource的方式實現了對程式碼無入侵的方式進行監控和鏈路跟蹤。於是決定深入研究一下,可謂收穫滿滿,便寫下這篇文章希望更多的人能夠了解使用這個功能。

?歡迎掃碼關注我的公眾號? ASP.NET Core使用HostingStartup增強啟動操作

相關文章