Avoid injecting services into startup classes in ASP.NET Core 3.0

Original: https://andrewlock.net/avoiding-startup-service-injection-in-asp-net-core-3/
Author: Andrew Lock
Translator: Lamond Lu

This is the second in a series on how to upgrade to ASP.NET Core 3.0.

In this blog post, I'll describe one modification you need to make to upgrade from an ASP.NET Core 2.x application to a.NET Core 3.0: you don't need to inject services into the Startup constructor.

Migrate to Universal Host in ASP.NET Core 3.0

In.NET Core 3.0, the hosting base of ASP.NET Core 3.0 has been redesigned to be a common host instead of being used in parallel with it.So what does this mean for developers who are developing applications using ASP.NET Core 2.x?At this stage, I've migrated several applications and everything has been going well so far.Official Migration Guidance Document It is a good guide to the steps you need to complete, so I strongly recommend that you read this document.

During the migration process, I encountered up to two issues:

  • The recommended way to configure Middleware in ASP.NET Core 3.0 is to use Endpoint Routing.
  • Generic host does not allow service injection for Startup class

The first one, which I've already explained before.Endpoint Routing was introduced in ASP.NET Core 2.2, but it is restricted to use only in MVC.In ASP.NET Core 3.0, endpoint routing has been implemented as a recommended terminal middleware because it offers many benefits.Most importantly, it allows the middleware to get which endpoint will eventually be executed, and it can retrieve metadata about this endpoint.For example, you can apply authorization for health check endpoints.

Endpoint routing requires special attention when configuring the order of middleware.I suggest you read it before upgrading your app Official Migration Document For the instructions here, I'll write a blog about how to convert terminal middleware to endpoint routing.

Second, injecting services into the Startup class has been mentioned but has not been adequately advertised.I'm not sure if it's because there aren't many people doing it or because it's easy to solve in some scenarios.In this article, I will present some problem scenarios and provide some solutions.

Injection Service in ASP.NET Core 2.x Startup Class

In ASP.NET Core 2.x, a little-known feature is that you can configure your dependency injection container in the Program.cs file.I used to use this approach for strongly typed options before configuring the rest of the Dependent Injection Container.

Let's take a look at an example of 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(); // Configure the service, which will be used later in Startup
}

Did you notice that a ConfigureSettings() method was called in CreateWebHostBuilder?This is an extension that I use to configure the application of strongly typed options.For example, this extension may look like this:

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)
        });
    }
}

So here, the ConfigureSettings() method calls the ConfigureServices() method of the IWebHostBuilder instance and configures some settings.Since these services are configured to depend on the injection container before Startup is initialized, these configured services can be injected in the constructor of the Startup class.

public static class Startup
{
    public class Startup
    {
        public Startup(
            IConfiguration configuration, 
            ConnectionStrings ConnectionStrings) // Injecting preconfigured services
        {
            Configuration = configuration;
            ConnectionStrings = ConnectionStrings;
        }

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

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

            // Use connection string in configuration
            services.AddDbContext<BloggingContext>(options =>
                options.UseSqlServer(ConnectionStrings.BloggingDatabase));
        }

        public void Configure(IApplicationBuilder app)
        {

        }
    }
}

I find this pattern useful when I first configure other services in the ConfigureServices method using strongly typed option objects.In my example above, the ConnectionStrings object is a strongly typed object and has been validated as non-null before the program entered Startup.This is not a formal basic technology, but real-time proof is very handy to use.

PS: How to add validation to strongly typed option objects for ASP.NET Core

However, if you switch to the ASP.NET Core 3.0 Universal Host, you will find that this implementation will receive the following error messages at runtime.

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

This approach is no longer supported in ASP.NET Core 3.0.You can inject IHostEnvironment and IConfiguration into the constructor of the Startup class, but that's all.As for the reason, it should be that the previous implementation will bring some problems, which I will describe in detail below.

Note: If you insist on using IWebHostBuilder in ASP.NET Core 3.0 instead of using a generic host, you can still use the previous implementation.However, I strongly recommend that you do not do this and try to migrate to a common host as much as possible.

Two singletons?

The fundamental problem with injecting a service into the Startup class is that it causes the system to build a dependent injection container twice.In the example I showed earlier, ASP.NET Core knows you need a ConnectionStrings object, but the only way to know how to construct it is to build an IServiceProvider based on a partial configuration (in the previous example, we provided this partial configuration using the ConfigureSettings() extension method).

So why is this a question?The problem is that this ServiceProvider is a temporary "root" ServiceProvider. It creates services and injects them into Startup.The remaining dependency injection container configuration will then run as part of the ConfigureServices method, and the temporary ServiceProvider will be discarded by then.A new ServiceProvider is then created that contains the "complete" configuration of the application.

This way, even if the service configuration uses the Singleton lifecycle, it will be created twice:

  • When using the Partial ServiceProvider, it is created once and injected into Startup
  • Created once when using the Full ServiceProvider

For my use case, the strongly typed option may not matter.The system does not have only one configuration instance, it is only a better choice.But this is not always the case.This "leak" of services seems to be the main reason for changing the behavior of a common host - it makes things look safer.

So what if I need services inside ConfigureServices?

Although we can no longer configure the service as we used to, we still need an alternative way to meet the needs of some scenarios!

The most common scenario is to inject a service into Startup to control the state of other services registered in the Startup.ConfigureServices method.For example, here is a very basic example.

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)
    {
        // ...
    }
}

In this example, the code determines which implementation the IIdentityService interface uses to register by examining the Boolean property in the injected IdentitySettings object: either a fake service or a real service.

By converting static service registrations to factory functions, you can make the implementation of the IdentitySetting object that needs to be injected compatible with the generic host.For example:

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

    public IConfiguration Configuration { get; }

    public void ConfigureServices(IServiceCollection services)
    {
        // Configure IdentitySetting for Dependency Injection Container
        services.Configure<IdentitySettings>(Configuration.GetSection("Identity")); 

        // Register different implementations
        services.AddScoped<FakeIdentityService>();
        services.AddScoped<RealIdentityService>();

        // Return a correct implementation at run time according to the IdentitySetting configuration
        services.AddScoped<IIdentityService>(ctx => 
        {
            var identitySettings = ctx.GetRequiredService<IdentitySettings>();
            return identitySettings.UseFakeIdentity
                ? ctx.GetRequiredService<FakeIdentityService>()
                : ctx.GetRequiredService<RealIdentityService>();
            }
        });
    }

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

This implementation is obviously more complex than previous versions, but at least compatible with the way common hosts work.

In fact, if you only need a strongly typed option, this approach is a bit overwhelming.Instead, I might just rebind the configuration here:

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

    public IConfiguration Configuration { get; }

    public void ConfigureServices(IServiceCollection services)
    {
        // Configure IdentitySetting for Dependency Injection Container
        services.Configure<IdentitySettings>(Configuration.GetSection("Identity")); 

        // Re-create strongly typed option object and bind
        var identitySettings = new IdentitySettings();
        Configuration.GetSection("Identity").Bind(identitySettings)

        // Configure the correct service according to conditions
        if(identitySettings.UseFakeIdentity)
        {
            services.AddScoped<IIdentityService, FakeIdentityService>();
        }
        else
        {
            services.AddScoped<IIdentityService, RealIdentityService>();
        }
    }

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

In addition, if I only need to load a string from the configuration file, I probably won't use the strongly typed option at all.This is how the ASP.NET Core identity system is congested in the.NET Core default template - the connection string is retrieved directly from the IConfiguration instance.

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

    public IConfiguration Configuration { get; }

    public void ConfigureServices(IServiceCollection services)
    {
        // Configure ConnectionStrings for Dependency Injection Containers
        services.Configure<ConnectionStrings>(Configuration.GetSection("ConnectionStrings")); 

        // Get the configuration directly without using the strongly typed option
        var connectionString = Configuration["ConnectionString:BloggingDatabase"];

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

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

This is not the best way to do it, but they can meet our needs and most scenarios.If you didn't know about Startup's service injection features before, you must have used one of these approaches.

Use IConfigureOptions to configure IdentityServer

Another common scenario for using injection configurations is configuring authentication for IdentityServer.

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

    public IdentitySettings IdentitySettings { get; }

    public void ConfigureServices(IServiceCollection services)
    {
        // Configure how IdentityServer is authenticated
        services
            .AddAuthentication(IdentityServerAuthenticationDefaults.AuthenticationScheme)
            .AddIdentityServerAuthentication(options =>
            {
                // Configuring the validation processor with the strongly typed option
                options.Authority = identitySettings.ServerFullPath;
                options.ApiName = identitySettings.ApiName;
            });
    }

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

In this example, both the base address and API resource name of the IdentityServer instance are set through the strongly typed option IdentitySettings. This implementation is no longer applicable in.NET Core 3.0, so we need a replaceable scheme.We can use the way mentioned earlier - rebind the strongly typed option or retrieve the configuration directly using the IConfiguration object.

In addition, the third option is to use IConfigureOptions, which I discovered by looking at the underlying code of the AddIdentityServerAuthentication method.

The AddIdentityServerAuthentication() method has proven to do something different.First, it configures JWT Bearer validation and specifies how to validate with the strongly typed option.We can use it to defer configuring named options and use IConfigureOptions instances instead.

The IConfigureOptions interface allows you to defer the configuration of strongly typed option objects using other dependencies in the Service Provider.For example, if I need to call a method in the TestService class to configure my TestSettings service, I can create an instance of the IConfigureOptions object with the following code:

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

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

TestService and IConfigureOptions <TestSettings>are both configured in the Startup.ConfigureServices method.

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

The most important thing here is that you can inject an IOptions <TestSettings>object using standard constructor dependencies.You no longer need to "partially build" the Service Provider in the ConfigureServices method to configure TestSettings. Instead, we registered the intent to configure TestSettings, but the actual configuration will be deferred until the configuration object is used.

So how can this help us configure IdentityServer?

AddIdentityServerAuthentication uses a variant of the strongly typed option, which we call named options. This is very common when validating configurations, as in the example above.

In short, you can use IConfigureOptions to delay the configuration of the naming option IdentityServerAuthenticationOptions used by the validation handler.Therefore, you can create a ConfigureIdentityServerOptions object with IdentitySettings as the construction parameter.

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)
        {
            // Use values in strongly typed IdentitySettings objects
            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);
}

In the Startup.cs file, you need to configure strongly typed IdentitySettings objects, add the required IdentityServer services, and register the ConfigureIdentityServerOptions class so that it can configure IdentityServerAuthenticationOptions when needed.

public void ConfigureServices(IServiceCollection services)
{
    // Configure the strongly typed IdentitySettings option
    services.Configure<IdentitySettings>(Configuration.GetSection("Identity"));

    // Configure IdentityServer authentication
    services
        .AddAuthentication(IdentityServerAuthenticationDefaults.AuthenticationScheme)
        .AddIdentityServerAuthentication();

    // Adding additional configurations
    services.ConfigureOptions<ConfigureIdentityServerOptions>();
}

Here, we don't need to inject anything into the Startup class, but you can still get the benefit of the strongly typed option.So here we get a win-win result.

summary

In this article, I describe some modifications that can be made to the Startup class when upgrading to ASP.NET Core 3.0.I describe the issues in ASP.NET Core 2.x by injecting services into the Startup class and how to remove this functionality in ASP.NET Core 3.0.Finally, I show you how to do this when you need it.

61 original articles published, 7 praised, 8800 visits
Private letter follow

Tags: REST

Posted on Tue, 14 Jan 2020 19:56:05 -0500 by Valect