1. 程式人生 > >避免在ASP.NET Core 3.0中為啟動類注入服務

避免在ASP.NET Core 3.0中為啟動類注入服務

本篇是如何升級到ASP.NET Core 3.0系列文章的第二篇。

  • Part 1 - 將.NET Standard 2.0類庫轉換為.NET Core 3.0類庫
  • Part 2 - IHostingEnvironment VS IHostEnvironent - .NET Core 3.0中的廢棄型別
  • Part 3 - 避免在ASP.NET Core 3.0中為啟動類注入服務(本篇)
  • Part 4 - 將終端中介軟體轉換為ASP.NET Core 3.0中的端點路由
  • Part 5 - 將整合測試的轉換為NET Core 3.0

在本篇部落格中,我將描述從ASP.NET Core 2.x應用升級到.NET Core 3.0需要做的一個修改:你不在需要在Startup

建構函式中注入服務了。

在ASP.NET Core 3.0中遷移到通用主機

在.NET Core 3.0中, ASP.NET Core 3.0的託管基礎已經被重新設計為通用主機,而不再與之並行使用。那麼這對於那些正在使用ASP.NET Core 2.x開發應用的開發人員,這意味著什麼呢?在目前這個階段,我已經遷移了多個應用,到目前為止,一切都進展順利。官方的遷移指導文件可以很好的指導你完成所需的步驟,因此,我強烈建議你讀一下這篇文件。

在遷移過程中,我遇到的最多兩個問題是:

  • ASP.NET Core 3.0中配置中介軟體的推薦方式是使用端點路由(Endpoint Routing)。
  • 通用主機不允許為Startup
    類注入服務

其中第一點,我之前已經講解過了。端點路由(Endpoint Routing)是在ASP.NET Core 2.2中引入的,但是被限制只能在MVC中使用。在ASP.NET Core 3.0中,端點路由已經是推薦的終端中介軟體實現了,因為它提供了很多好處。其中最重要的是,它允許中介軟體獲取哪一個端點最終會被執行,並且可以檢索有關這個端點的元資料(metadata)。例如,你可以為健康檢查端點應用授權。

端點路由是在配置中介軟體順序時需要特別注意。我建議你再升級你的應用前,先閱讀一下官方遷移文件針對此處的說明,後續我將寫一篇部落格來介紹如何將終端中介軟體轉換為端點路由。

第二點,是已經提到了的將服務注入Startup

類,但是並沒有得到足夠的宣傳。我不太確定是不是因為這樣做的人不多,還是在一些場景下,它很容易解決。在本篇中,我將展示一些問題場景,並提供一些解決方案。

ASP.NET Core 2.x啟動類中注入服務

在ASP.NET Core 2.x版本中,有一個鮮為人知的特性,就是你可以在Program.cs檔案中配置你的依賴注入容器。以前我曾經使用這種方式來進行強型別選項,然後在配置依賴注入容器的其餘剩餘部分時使用這些配置。

下面我們來看一下ASP.NET Core 2.x的例子:

public class Program
{
    public static void Main(string[] args)
    {
        CreateWebHostBuilder(args).Build().Run();
    }

    public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
        WebHost.CreateDefaultBuilder(args)
            .UseStartup<Startup>()
            .ConfigureSettings(); // 配置服務,後續將在Startup中使用
}

這裡有沒有注意到在CreateWebHostBuilder中呼叫了一個ConfigureSettings()的方法?這是一個我用來配置應用強型別選項的擴充套件方法。例如,這個擴充套件方法可能看起來是這樣的:

public static class SettingsinstallerExtensions
{
    public static IWebHostBuilder ConfigureSettings(this IWebHostBuilder builder)
    {
        return builder.ConfigureServices((context, services) =>
        {
            var config = context.Configuration;

            services.Configure<ConnectionStrings>(config.GetSection("ConnectionStrings"));
            services.AddSingleton<ConnectionStrings>(
                ctx => ctx.GetService<IOptions<ConnectionStrings>>().Value)
        });
    }
}

所以這裡,ConfigureSettings()方法呼叫了IWebHostBuilder例項的ConfigureServices()方法,配置了一些設定。由於這些服務會在Startup初始化之前被配置到依賴注入容器,所以在Startup類的建構函式中,這些以配置的服務是可以被注入的。

public static class Startup
{
    public class Startup
    {
        public Startup(
            IConfiguration configuration, 
            ConnectionStrings ConnectionStrings) // 注入預配置服務
        {
            Configuration = configuration;
            ConnectionStrings = ConnectionStrings;
        }

        public IConfiguration Configuration { get; }
        public ConnectionStrings ConnectionStrings { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddControllers();

            // 使用配置中的連線字串
            services.AddDbContext<BloggingContext>(options =>
                options.UseSqlServer(ConnectionStrings.BloggingDatabase));
        }

        public void Configure(IApplicationBuilder app)
        {

        }
    }
}

我發現,當我先要在ConfigureServices方法中使用強型別選項物件配置其他服務時,這種模式非常的有用。在我上面的例子中,ConnectionStrings物件是一個強型別物件,並且這個物件在程式進入Startup之前,就已經進行非空驗證。這並不是一種正規的基礎技術,但是實時證明使用起來非常的順手。

PS: 如何為ASP.NET Core的強型別選項物件新增驗證

然而,如果切換到ASP.NET Core 3.0通用主機之後,你會發現這種實現方式在執行時會收到以下的錯誤資訊。

Unhandled exception. System.InvalidOperationException: Unable to resolve service for type 'ExampleProject.ConnectionStrings' while attempting to activate 'ExampleProject.Startup'.
   at Microsoft.Extensions.DependencyInjection.ActivatorUtilities.ConstructorMatcher.CreateInstance(IServiceProvider provider)
   at Microsoft.Extensions.DependencyInjection.ActivatorUtilities.CreateInstance(IServiceProvider provider, Type instanceType, Object[] parameters)
   at Microsoft.AspNetCore.Hosting.GenericWebHostBuilder.UseStartup(Type startupType, HostBuilderContext context, IServiceCollection services)
   at Microsoft.AspNetCore.Hosting.GenericWebHostBuilder.<>c__DisplayClass12_0.<UseStartup>b__0(HostBuilderContext context, IServiceCollection services)
   at Microsoft.Extensions.Hosting.HostBuilder.CreateServiceProvider()
   at Microsoft.Extensions.Hosting.HostBuilder.Build()
   at ExampleProject.Program.Main(String[] args) in C:\repos\ExampleProject\Program.cs:line 21

這種方式在ASP.NET Core 3.0中已經不再支援了。你可以在Startup類的建構函式注入IHostEnvironmentIConfiguration, 但是僅此而已。至於原因,應該是之前的實現方式會帶來一些問題,下面我將給大家詳細描述一下。

注意:如果你堅持在ASP.NET Core 3.0中使用IWebHostBuilder, 而不使用的通用主機的話,你依然可以使用之前的實現方式。但是我強烈建議你不要這樣做,並儘可能的嘗試遷移到通用主機的方式。

兩個單例?

注入服務到Startup類的根本問題是,它會導致系統需要構建依賴注入容器兩次。在我之前展示的例子中,ASP.NET Core知道你需要一個ConnectionStrings物件,但是唯一知道如何構建該物件的方法是基於“部分”配置構建IServiceProvider(在之前的例子中,我們使用ConfigureSettings()擴充套件方法提供了這個“部分”配置)。

那麼為什麼這個會是一個問題呢?問題是這個ServiceProvider是一個臨時的“根”ServiceProvider.它建立了服務並將服務注入到Startup中。然後,剩餘的依賴注入容器配置將作為ConfigureServices方法的一部分執行,並且臨時的ServiceProvider在這時就已經被丟棄了。然後一個新的ServiceProvider會被創建出來,在其中包含了應用程式“完整”的配置。

這樣,即使服務配置使用Singleton生命週期,也會被建立兩次:

  • 當使用“部分”ServiceProvider時,建立了一次,並針對Startup進行了注入
  • 當使用"完整"ServiceProvider時,建立了一次

對於我的用例,強型別選項,這可能是無關緊要的。系統並不是只可以有一個配置例項,這只是一個更好的選擇。但是這並非總是如此。服務的這種“洩露”似乎是更改通用主機行為的主要原因 - 它讓東西看起來更安全了。

那麼如果我需要ConfigureServices內部的服務怎麼辦?

雖然我們已經不能像以前那樣配置服務了,但是還是需要一種可以替換的方式來滿足一些場景的需要!

其中最常見的場景是通過注入服務到Startup,針對Startup.ConfigureServices方法中註冊的其他服務進行狀態控制。例如,以下是一個非常基本的例子。

public class Startup
{
    public Startup(IdentitySettings identitySettings)
    {
        IdentitySettings = identitySettings;
    }

    public IdentitySettings IdentitySettings { get; }

    public void ConfigureServices(IServiceCollection services)
    {
        if(IdentitySettings.UseFakeIdentity)
        {
            services.AddScoped<IIdentityService, FakeIdentityService>();
        }
        else
        {
            services.AddScoped<IIdentityService, RealIdentityService>();
        }
    }

    public void Configure(IApplicationBuilder app)
    {
        // ...
    }
}

這個例子中,程式碼通過檢查注入的IdentitySettings物件中的布林值屬性,決定了IIdentityService介面使用哪個實現來註冊:或者使用假服務,或者使用真服務。

通過將靜態服務註冊轉換為工廠函式的方式,可以使需要注入IdentitySetting物件的實現方式與通用主機相容。例如:

public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public IConfiguration Configuration { get; }

    public void ConfigureServices(IServiceCollection services)
    {
        // 為依賴注入容器,配置IdentitySetting
        services.Configure<IdentitySettings>(Configuration.GetSection("Identity")); 

        // 註冊不同的實現
        services.AddScoped<FakeIdentityService>();
        services.AddScoped<RealIdentityService>();

        // 根據IdentitySetting配置,在執行時返回一個正確的實現
        services.AddScoped<IIdentityService>(ctx => 
        {
            var identitySettings = ctx.GetRequiredService<IdentitySettings>();
            return identitySettings.UseFakeIdentity
                ? ctx.GetRequiredService<FakeIdentityService>()
                : ctx.GetRequiredService<RealIdentityService>();
            }
        });
    }

    public void Configure(IApplicationBuilder app)
    {
        // ...
    }
}

這個實現顯然比之前的版本要複雜的多,但是至少可以相容通用主機的方式。

實際上,如果僅需要一個強型別選項,那麼這個方法就有點過頭了。相反的,這裡我可能只會重新繫結一下配置:

public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public IConfiguration Configuration { get; }

    public void ConfigureServices(IServiceCollection services)
    {
        // 為依賴注入容器,配置IdentitySetting
        services.Configure<IdentitySettings>(Configuration.GetSection("Identity")); 

        // 重新建立強型別選項物件,並繫結
        var identitySettings = new IdentitySettings();
        Configuration.GetSection("Identity").Bind(identitySettings)

        // 根據條件配置正確的服務
        if(identitySettings.UseFakeIdentity)
        {
            services.AddScoped<IIdentityService, FakeIdentityService>();
        }
        else
        {
            services.AddScoped<IIdentityService, RealIdentityService>();
        }
    }

    public void Configure(IApplicationBuilder app)
    {
        // ...
    }
}

除此之外,如果僅僅只需要從配置檔案中載入一個字串,我可能根本不會使用強型別選項。這是.NET Core預設模板中擁堵配置ASP.NET Core身份系統的方法 - 直接通過IConfiguration例項檢索連線字串。

public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public IConfiguration Configuration { get; }

    public void ConfigureServices(IServiceCollection services)
    {
        // 針對依賴注入容器,配置ConnectionStrings
        services.Configure<ConnectionStrings>(Configuration.GetSection("ConnectionStrings")); 

        // 直接獲取配置,不使用強型別選項
        var connectionString = Configuration["ConnectionString:BloggingDatabase"];

        services.AddDbContext<ApplicationDbContext>(options =>
                options.UseSqlite(connectionString));
    }

    public void Configure(IApplicationBuilder app)
    {
        // ...
    }
}

這個實現方式都不是最好的,但是他們都可以滿足我們的需求,以及大部分的場景。如果你以前不知道Startup的服務注入特性,那麼你肯定使用了以上方式中的一種。

使用IConfigureOptions來對IdentityServer進行配置

另外一個使用注入配置的常見場景是配置IdentityServer的驗證。

public class Startup
{
    public Startup(IdentitySettings identitySettings)
    {
        IdentitySettings = identitySettings;
    }

    public IdentitySettings IdentitySettings { get; }

    public void ConfigureServices(IServiceCollection services)
    {
        // 配置IdentityServer的驗證方式
        services
            .AddAuthentication(IdentityServerAuthenticationDefaults.AuthenticationScheme)
            .AddIdentityServerAuthentication(options =>
            {
                // 使用強型別選項來配置驗證處理器
                options.Authority = identitySettings.ServerFullPath;
                options.ApiName = identitySettings.ApiName;
            });
    }

    public void Configure(IApplicationBuilder app)
    {
        // ...
    }
}

在這個例子中,IdentityServer例項的基本地址和API資源名都是通過強型別選項選項IdentitySettings設定的. 這種實現方式在.NET Core 3.0中已經不再適用了,所以我們需要一個可替換的方案。我們可以使用之前提到的方式 - 重新繫結強型別選項或者直接使用IConfiguration物件檢索配置。

除此之外,第三種選擇是使用IConfigureOptions, 這是我通過檢視AddIdentityServerAuthentication方法的底層程式碼發現的。

事實證明,AddIdentityServerAuthentication()方法可以做一些不同的事情。首先,它配置了JWT Bearer驗證,並且通過強型別選項指定了驗證的方式。我們可以利用它來延遲配置命名選項(named options), 改為使用IConfigureOptions例項。

IConfigureOptions介面允許你使用Service Provider中的其他依賴項延遲配置強型別選項物件。例如,如果要配置我的TestSettings服務時,我需要呼叫TestService類中的一個方法,我可以建立一個IConfigureOptions物件例項,程式碼如下:

public class MyTestSettingsConfigureOptions : IConfigureOptions<TestSettings>
{
    private readonly TestService _testService;
    public MyTestSettingsConfigureOptions(TestService testService)
    {
        _testService = testService;
    }

    public void Configure(TestSettings options)
    {
        options.MyTestValue = _testService.GetValue();
    }
}

TestServiceIConfigureOptions<TestSettings>都是在Startup.ConfigureServices方法中同時配置的。

public void ConfigureServices(IServiceCollection services)
{
    services.AddScoped<TestService>();
    services.ConfigureOptions<MyTestSettingsConfigureOptions>();
}

這裡最重要的一點是,你可以使用標準的建構函式依賴注入一個IOptions<TestSettings>物件。這裡不再需要在ConfigureServices方法中“部分構建”Service Provider, 即可配置TestSettings. 相反的,我們註冊了配置TestSettings的意圖,但是真正的配置會被推遲到配置物件被使用的時候。

那麼這對於我們配置IdentityServer,有什麼幫助呢?

AddIdentityServerAuthentication使用了強型別選項的一種變體,我們稱之為命名選項(named options). 這種方式在驗證配置的時候非常常見,就像我們上面的例子一樣。

簡而言之,你可以使用IConfigureOptions方式將驗證處理程式使用的命名選項IdentityServerAuthenticationOptions的配置延遲。因此,你可以建立一個將IdentitySettings作為構造引數的ConfigureIdentityServerOptions物件。

public class ConfigureIdentityServerOptions : IConfigureNamedOptions<IdentityServerAuthenticationOptions>
{
    readonly IdentitySettings _identitySettings;
    public ConfigureIdentityServerOptions(IdentitySettings identitySettings)
    {
        _identitySettings = identitySettings;
        _hostingEnvironment = hostingEnvironment;
    }

    public void Configure(string name, IdentityServerAuthenticationOptions options)
    { 
        // Only configure the options if this is the correct instance
        if (name == IdentityServerAuthenticationDefaults.AuthenticationScheme)
        {
            // 使用強型別IdentitySettings物件中的值
            options.Authority = _identitySettings.ServerFullPath; 
            options.ApiName = _identitySettings.ApiName;
        }
    }

    // This won't be called, but is required for the IConfigureNamedOptions interface
    public void Configure(IdentityServerAuthenticationOptions options) => Configure(Options.DefaultName, options);
}

Startup.cs檔案中,你需要配置強型別IdentitySettings物件,新增所需的IdentityServer服務,並註冊ConfigureIdentityServerOptions類,以便當需要時,它可以配置IdentityServerAuthenticationOptions.

public void ConfigureServices(IServiceCollection services)
{
    // 配置強型別IdentitySettings選項
    services.Configure<IdentitySettings>(Configuration.GetSection("Identity"));

    // 配置IdentityServer驗證方式
    services
        .AddAuthentication(IdentityServerAuthenticationDefaults.AuthenticationScheme)
        .AddIdentityServerAuthentication();

    // 新增其他配置
    services.ConfigureOptions<ConfigureIdentityServerOptions>();
}

這裡,我們無需向Startup類中注入任何內容,但是你依然可以獲得強型別選項的好處。所以這裡我們得到一個雙贏的結果。

總結

在本文中,我描述了升級到ASP.NET Core 3.0時,可以需要對Startup 類進行的一些修改。我通過在Startup類中注入服務,描述了ASP.NET Core 2.x中的問題,以及如何在ASP.NET Core 3.0中移除這個功能。最後我展示了,當需要這種實現方式的時候改如何去做