Quantcast
Channel: Andrew Lock | .NET Escapades
Viewing all articles
Browse latest Browse all 743

Avoiding Startup service injection in ASP.NET Core 3: Upgrading to ASP.NET Core 3.0 - Part 3

$
0
0
Avoiding Startup service injection in ASP.NET Core 3

In this post I describe one of the changes to Startup when moving from an ASP.NET Core 2.x app to .NET Core 3; you can not longer inject arbitrary services into the Startup constructor.

Migrating to the generic host in ASP.NET Core 3.0

In .NET Core 3.0 the ASP.NET Core 3.0 hosting infrastructure has been redesigned to build on top of the generic host infrastructure, instead of running in parallel to it. But what does that mean for the average developer that has an ASP.NET Core 2.x app, and wants to update to 3.0? I've migrated several apps at this stage, and it's gone pretty smoothly so far. The migration guide document does a good job of walking you through the required steps, so I strongly suggest working your way through that document.

For the most part I only had to address two issues:

  • The canonical way to configure middleware in ASP.NET Core 3.0 is to use endpoint routing
  • The generic host does not allow injecting services into the Startup class.

The first point has been pretty well publicised. Endpoint routing was introduced in ASP.NET Core 2.2, but was restricted to MVC only. In ASP.NET Core 3.0, endpoint routing is the suggested approach for terminal middleware (also called "endpoints") as it provides a few benefits. Most importantly, it allows middleware to know which endpoint will ultimately be executed, and can retrieve metadata about that endpoint. This allows you to apply authorization to health check endpoints for example.

Endpoint routing is very particular about the order of middleware. I suggest reading this section of the migration document carefully when upgrading your apps. In a later post I'll show how to convert a terminal middleware to an endpoint.

The second point, injecting services into the Startup class has been mentioned, but it's not been very highly publicised. I'm not sure if that's because not many people are doing it, or because in many cases it's easy to work around. In this post I'll show the problem, and some ways to handle it.

Injecting services into Startup in ASP.NET Core 2.x

A little known feature in ASP.NET core 2.x was that you could partially configure your dependency injection container in Program.cs, and inject the configured classes into Startup.cs. I used this approach to configure strongly typed settings, and then use those settings when configuring the remainder of the dependency injection container.

Lets take the following ASP.NET Core 2.x example:

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

    public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
        WebHost.CreateDefaultBuilder(args)
            .UseStartup<Startup>()
            .ConfigureSettings(); // <- Configure services we'll inject into Startup later
}

Notice the ConfigureSettings() call in CreateWebHostBuilder? That's an extension method that I use to configure the application's strongly-typed settings. For example:

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 the ConfigureSettings() method calls ConfigureServices() on the IWebHostBuilder instance, and configures some settings. As these services are configured in the DI container before Startup is instantiated, they can be injected into the Startup constructor:

public static class Startup
{
    public class Startup
    {
        public Startup(
            IConfiguration configuration, 
            ConnectionStrings ConnectionStrings) // Inject pre-configured service
        {
            Configuration = configuration;
            ConnectionStrings = ConnectionStrings;
        }

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

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

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

        public void Configure(IApplicationBuilder app)
        {

        }
    }
}

I found this pattern useful when I wanted to use strongly-typed configuration objects inside ConfigureServices for configuring other services. In the example above the ConnectionStrings object is a strongly-typed settings object, and the properties are validated on startup to ensure they're not null (indicating a configuration error). It's not a fundamental technique, but it's proven handy.

However if you try and take this approach after you switch to using the generic host in ASP.NET Core 3.0, you'll get an error 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 Startup constructor, but that's it. And for a good reason - the previous approach has several issues, as I'll describe below.

Note that you can actually keep using this approach if you stick to using IWebHostBuilder in ASP.NET Core 3.0, instead of the new generic host. I strongly suggest you don't though, and attempt to migrate where possible!

Two singletons?

The fundamental problem with injecting services into Startup is that it requires building the dependency injection container twice. In the example shown previously ASP.NET Core knows you need an ConnectionStrings object, but the only way for it to know how to create one is to build an IServiceProvider based on the "partial" configuration (that we supplied in the ConfigureSettings() extension method).

But why is this a problem? The problem is that the service provider is a temporary "root" service provider. It creates the services and injects them into Startup. The remainder of the dependency injection container configuration then runs as part of ConfigureServices, and the temporary service provider is thrown away. A new service provider is then created which now contains the "full" configuration for the application.

The upshot of this is that even if a service is configured with a Singleton lifetime, it will be created twice:

  • Once using the "partial" service provider, to inject into Startup
  • Once using the "full" service provider, for use more generally in the application

For my use case, strongly typed settings, that really didn't matter. It's not essential that there's only one instance of the settings, it's just preferable. But that might not always be the case. This "leaking" of services seems to be the main reason for changing the behaviour with the generic host - it makes things safer.

But what if I need the service inside ConfigureServices?

Knowing that you can't do this anymore is one thing, but you also need to work around it! One use case for injecting services into Startup is to be able to conditionally control how you register other services in Startup.ConfigureServices. For example, the following is a very rudimentary 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)
    {
        // ...
    }
}

This (obviously contrived) example checks a boolean property on the injected IdentitySettings to decide which IIdentityService implementation to register: either the Fake service or the Real service.

This approach, which requires injecting IdentitySettings, can be made compatible with the generic host by converting the static service registrations to use a factory function instead. For example:

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

    public IConfiguration Configuration { get; }

    public void ConfigureServices(IServiceCollection services)
    {
        // configure the IdentitySettings for the DI container
        services.Configure<IdentitySettings>(Configuration.GetSection("Identity")); 

        // Register the implementations using their implementation name
        services.AddScoped<FakeIdentityService>();
        services.AddScoped<RealIdentityService>();

        // Retrieve the IdentitySettings at runtime, and return the correct implementation
        services.AddScoped<IIdentityService>(ctx => 
        {
            var identitySettings = ctx.GetRequiredService<IdentitySettings>();
            return identitySettings.UseFakeIdentity
                ? ctx.GetRequiredService<FakeIdentityService>()
                : ctx.GetRequiredService<RealIdentityService>();
            }
        });
    }

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

This approach is obviously a lot more complicated than the previous version, but it's at least compatible with the generic host!

In reality, if it's only strongly typed settings that are needed (as in this case), then this approach is somewhat overkill. Instead, I'd probably just "rebind" the settings instead:

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

    public IConfiguration Configuration { get; }

    public void ConfigureServices(IServiceCollection services)
    {
        // configure the IdentitySettings for the DI container
        services.Configure<IdentitySettings>(Configuration.GetSection("Identity")); 

        // "recreate" the strongly typed settings and manually bind them
        var identitySettings = new IdentitySettings();
        Configuration.GetSection("Identity").Bind(identitySettings)

        // conditionally register the correct service
        if(identitySettings.UseFakeIdentity)
        {
            services.AddScoped<IIdentityService, FakeIdentityService>();
        }
        else
        {
            services.AddScoped<IIdentityService, RealIdentityService>();
        }
    }

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

Alternatively, I might not bother with the strongly-typed aspect at all, especially if the required setting is a string. That's the approach used in the default .NET Core templates for configuring ASP.NET Core identity - 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 the ConnectionStrings for the DI container
        services.Configure<ConnectionStrings>(Configuration.GetSection("ConnectionStrings")); 

        // directly retrieve setting instead of using strongly-typed options
        var connectionString = Configuration["ConnectionString:BloggingDatabase"];

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

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

These approaches aren't the nicest, but they get the job done, and they will probably be fine for most cases. If you didn't know about the Startup injection feature, then you're probably using one of these approaches already anyway!

Sometimes I was injecting services into Startup to configure other strongly typed option objects. For these cases there's a better approach, using IConfigureOptions.

Using IConfigureOptions to configure options for IdentityServer

A common case where I used injected settings was in configuring IdentityServer authentication, as described in their documentation:

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

    public IdentitySettings IdentitySettings { get; }

    public void ConfigureServices(IServiceCollection services)
    {
        // Configure IdentityServer Auth
        services
            .AddAuthentication(IdentityServerAuthenticationDefaults.AuthenticationScheme)
            .AddIdentityServerAuthentication(options =>
            {
                // Configure the authentication handler settings using strongly typed options
                options.Authority = identitySettings.ServerFullPath;
                options.ApiName = identitySettings.ApiName;
            });
    }

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

In this example, the base-address of our IdentityServer instance and the name of the API resource are set based on the strongly typed configuration object, IdentitySettings. This setup doesn't work in .NET Core 3.0, so we need an alternative. We could re-bind the strongly-typed configuration as I showed previously. Or we could use the IConfiguration object directly to retrieve the settings.

A third option involves looking under the hood of the AddIdentityServerAuthentication method, and making use of IConfigureOptions.

As it turns out, the AddIdentityServerAuthentication() method does a few different things. Primarily, it configures JWT bearer authentication, and configures some strongly-typed settings for the specified authentication scheme (IdentityServerAuthenticationDefaults.AuthenticationScheme). We can use that fact to delay configuring the named options and use an IConfigureOptions instance instead.

The IConfigureOptions interface allows you to "late-configure" a strongly-typed options object using other dependencies from the service provider. For example, if to configure my TestSettings I needed to call a method on TestService, I could create an IConfigureOptions implementation like the following:

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

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

The TestService and IConfigureOptions<TestSettings> are configured in DI at the same time inside Startup.ConfigureServices:

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

The important point is you can use standard constructor dependency injection with IOptions<TestSettings>. There's no need to "partially build" the service provider inside ConfigureServices just to configure the TestSettings. Instead we register the intent to configure TestSettings, and delay the configuration until the settings object is required.

So how does this help us configuring IdentityServer?

The AddIdentityServerAuthentication uses a variant of strongly-typed settings called named options (I've discussed these several times before). They're most commonly used for configuring authentication, as they are in this example.

To cut a long story short, you can use the IConfigureOptions approach to delay configuring the named options IdentityServerAuthenticationOptions used by the authentication handler until after we've already configured the strongly-typed IdentitySettings object. So you can create an ConfigureIdentityServerOptions object that takes the IdentitySettings as a constructor 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 the values from strongly-typed IdentitySettings object
            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 Startup.cs you configure the strongly-typed IdentitySettings object, add the required IdentityServer services, and register the ConfigureIdentityServerOptions class so that it can configure the IdentityServerAuthenticationOptions when required:

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

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

    // Add the extra configuration;
    services.ConfigureOptions<ConfigureIdentityServerOptions>();
}

No need to inject anything into Startup, but you still get the benefits of strongly-typed settings. Win-win!

Summary

In this post I described some of the changes you may need to make to Startup.cs when upgrading to ASP.NET Core 3.0. I described the problem in ASP.NET Core 2.x with injecting services into your Startup class, and how this feature has been removed in ASP.NET Core 3.0. I then showed how to work around some of the reasons that you may have been using this approach in the first place.


Viewing all articles
Browse latest Browse all 743

Trending Articles