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


原文: https://andrewlock.net/avoiding-startup-service-injection-in-asp-net-core-3/
作者: Andrew Lock
譯者: Lamond Lu

本篇是如何升級到ASP.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中移除這個功能。最后我展示了,當需要這種實現方式的時候改如何去做。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM