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

概念

    在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的方式實現了對代碼無入侵的方式進行監控和鏈路跟蹤。於是決定深入研究一下,可謂收穫滿滿,便寫下這篇文章希望更多的人能夠了解使用這個功能。

👇歡迎掃碼關注我的公衆號👇
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章