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

Running async tasks on app startup in ASP.NET Core (Part 2)

$
0
0
Running async tasks on app startup in ASP.NET Core (Part 2)

In my previous post I described the need to run one-off asynchronous tasks on app start up. This post follows on directly from the last one, so if you haven't already, I suggest you read that one first.

In this post I show a proposed adaptation of the "run the task manually in program.cs" approach from my last post. The implementation uses a few simple interfaces and classes to encapsulate the logic of running tasks on app start up. I also show an alternative approach that uses service decoration of IServer to execute tasks before Kestrel starts.

Running asynchronous tasks on app startup

As a recap, we're trying to find a solution that allows us to execute arbitrary asynchronous tasks on app start up. These tasks should be executed before the app starts accepting requests, but may require configuration and services, so should be executed after DI configuration is complete. Examples include things like database migrations, or populating a cache.

The solution I proposed at the end of my previous post involved running the task "manually" in Program.cs, between the calls to IWebHostBuilder.Build() and IWebHost.Run():

public class Program
{
    public static async Task Main(string[] args)
    {
        IWebHost webHost = CreateWebHostBuilder(args).Build();

        // Create a new scope
        using (var scope = webHost.Services.CreateScope())
        {
            // Get the DbContext instance
            var myDbContext = scope.ServiceProvider.GetRequiredService<MyDbContext>();

            //Do the migration asynchronously
            await myDbContext.Database.MigrateAsync();
        }

        // Run the WebHost, and start listeningaccepting requests
        // There's an async overload, so we may as well use it
        await webHost.RunAsync();
    }

    public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
        WebHost.CreateDefaultBuilder(args)
            .UseStartup<Startup>();
}

This approach works, but it's a bit messy. We're going to be bloating the Program.cs file with code that probably shouldn't be there, but we could easily extract that to another class. More of an issue is having to remember to invoke the task manually. If you're using the same pattern in multiple apps, then it would be nice to have this handled for you automatically.

Registering startup tasks with the DI container

The solution I'm proposing is based on the patterns used by IStartupFilter and IHostedService. These interfaces allow you to register classes with the dependency injection container which will be executed later.

First, we create a simple interface for the startup tasks:

public interface IStartupTask
{
    Task ExecuteAsync(CancellationToken cancellationToken = default);
}

And a convenience method for registering startup tasks with the DI container:

public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddStartupTask<T>(this IServiceCollection services)
        where T : class, IStartupTask
        => services.AddTransient<IStartupTask, T>();
}

Finally, we add an extension method that finds all the registered IStartupTasks on app startup, runs them in order, and then starts the IWebHost:

public static class StartupTaskWebHostExtensions
{
    public static async Task RunWithTasksAsync(this IWebHost webHost, CancellationToken cancellationToken = default)
    {
        // Load all tasks from DI
        var startupTasks = webHost.Services.GetServices<IStartupTask>();

        // Execute all the tasks
        foreach (var startupTask in startupTasks)
        {
            await startupTask.ExecuteAsync(cancellationToken);
        }

        // Start the tasks as normal
        await webHost.RunAsync(cancellationToken);
    }
}

That's all there is to it!

To see it in action, I'll use the EF Core database migration example from the previous post.

An example - async database migration

Implementing IStartupTask is very similar to implementing IStartupFilter. You can inject services from the DI container, but if you require access to Scoped services, you should inject an IServiceProvider and create a new scope manually.

Side note - this seems like something a lot of people will get wrong, so I considered automatically creating a new scope for every task in the RunWithTasksAsync extension method. That would let you directly inject scoped services into the IStartupTask. I decided against that to keep the behaviour consistent with IStartupFilter and IHostedService - I'd be interested in any thoughts people have in the comments.

The EF Core migration startup task would look something like the following:

public class MigratorStartupFilter: IStartupTask
{
    // We need to inject the IServiceProvider so we can create 
    // the scoped service, MyDbContext
    private readonly IServiceProvider _serviceProvider;
    public MigratorStartupFilter(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public Task ExecuteAsync(CancellationToken cancellationToken = default)
    {
        // Create a new scope to retrieve scoped services
        using(var scope = _seviceProvider.CreateScope())
        {
            // Get the DbContext instance
            var myDbContext = scope.ServiceProvider.GetRequiredService<MyDbContext>();

            //Do the migration 
            await myDbContext.Database.MigrateAsync();
        }
    }
}

And we'd add the startup task to the DI container in ConfigureServices():

public void ConfigureServices(IServiceCollection services)
{
    services.MyDbContext<ApplicationDbContext>(options =>
        options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));

    services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1);

    // Add migration task
    services.AddStartupTask<MigrationStartupTask>();
}

Finally, we need to update Program.cs to call RunWithTasksAsync() instead of Run()

public class Program
{
    // Change return type from void to async Task
    public static async Task Main(string[] args)
    {
        // CreateWebHostBuilder(args).Build().Run();
        await CreateWebHostBuilder(args).Build().RunWithTasksAsync();
    }

    public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
        WebHost.CreateDefaultBuilder(args)
            .UseStartup<Startup>();
}

This takes advantage of the async Task Main feature of C# 7.1. Overall this code is functionally equivalent to the "manual" equivalent from my last post and above, but it has a few advantages.

  • It keeps the task implementation code out of Program.cs
  • I think it's easier to understand what's happening in Program.cs - we're running startup tasks and then running the application. Most of that is due to simply moving implementation code out of Program.cs
  • It allows you to easily add extra tasks by adding them to the DI container.
  • If you don't have any tasks, the behaviour is the same as calling RunAsync()

For me, the biggest advantage is that once you have added RunWithTasksAsync(), you can easily add additional tasks by adding them to the DI container, without having to make any other changes.

Thomas Levesque recently wrote a similar post tackling the same problem, and came to a similar solution. He has a NuGet package available for the approach.

It's not entirely sunshine and roses though…

The small print - we haven't quite finished building the app yet

Other than the fact this isn't baked into the framework (so people have to customize their Program.cs classes), there's one tiny caveat I see to the approach shown above. Even though the tasks run after the IConfiguration and DI container configuration has completed, they run before the IStartupFilters have run and the middleware pipeline has been configured.

Personally, this hasn't been a problem for me, and I can't think of any cases where it would be. None of the tasks I've written have a dependency on the IStartupFilters having run. That doesn't mean it won't happen though.

Unfortunately, there's not an easy way round this with the current WebHost code (though that may change in 3.0 when ASP.NET Core runs as an IHostedService). The problem is that the application is bootstrapped (by configuring the middleware pipeline and running IStartupFilters) and started in the same function. When you call WebHost.Run() in Program.Cs, this internally calls WebHost.StartAsync which is shown below with logging and some other minor code removed for brevity:

public virtual async Task StartAsync(CancellationToken cancellationToken = default)
{
    _logger = _applicationServices.GetRequiredService<ILogger<WebHost>>();

    // Build the middleware pipeline and execute IStartupFilters
    var application = BuildApplication();

    _applicationLifetime = _applicationServices.GetRequiredService<IApplicationLifetime>() as ApplicationLifetime;
    _hostedServiceExecutor = _applicationServices.GetRequiredService<HostedServiceExecutor>();
    var diagnosticSource = _applicationServices.GetRequiredService<DiagnosticListener>();
    var httpContextFactory = _applicationServices.GetRequiredService<IHttpContextFactory>();
    var hostingApp = new HostingApplication(application, _logger, diagnosticSource, httpContextFactory);

    // Start Kestrel (and start accepting connections)
    await Server.StartAsync(hostingApp, cancellationToken).ConfigureAwait(false);

    // Fire IApplicationLifetime.Started
    _applicationLifetime?.NotifyStarted();

    // Fire IHostedService.Start
    await _hostedServiceExecutor.StartAsync(cancellationToken).ConfigureAwait(false);
}

The problem is that we want to insert code between the call to BuildApplication() and the call to Server.StartAsync(), but there's no mechanism for doing so.

I'm not sure if the solution I settled on feels hacky or elegant, but it works, and gives an even nicer experience for consumers, as they don't need to modify Program.cs

An alternative approach by decorating IServer

The only way I could see to run async code between BuildApplication() and Server.StartAsync() is to replace the IServer implementation (Kestrel) with our own! This isn't quite as horrendous as it sounds at first - we're not really going to replace the server, we're just going to decorate it:

public class TaskExecutingServer : IServer
{
    // Inject the original IServer implementation (KestrelServer) and
    // the list of IStartupTasks to execute
    private readonly IServer _server;
    private readonly IEnumerable<IStartupTask> _startupTasks;
    public TaskExecutingServer(IServer server, IEnumerable<IStartupTask> startupTasks)
    {
        _server = server;
        _startupTasks = startupTasks;
    }

    public async Task StartAsync<TContext>(IHttpApplication<TContext> application, CancellationToken cancellationToken)
    {
        // Run the tasks first
        foreach (var startupTask in _startupTasks)
        {
            await startupTask.ExecuteAsync(cancellationToken);
        }

        // Now start the Kestrel server properly
        await _server.StartAsync(application, cancellationToken);
    }

    // Delegate implementation to default IServer
    public IFeatureCollection Features => _server.Features;
    public void Dispose() => _server.Dispose();
    public Task StopAsync(CancellationToken cancellationToken) => _server.StopAsync(cancellationToken);
}

The TaskExecutingServer takes an instance of IServer in its constructor - this the original KestrelServer registered by ASP.NET Core. We delegate most of the IServer implementation directly to Kestrel, we just intercept the call to StartAsync and run the injected tasks first.

The difficult part of the implementation is getting the decoration working properly. As I discussed in a previous post, using decoration with the default ASP.NET Core container can be tricky. I typically use Scrutor to create decorators, but you could always do the decoration manually if you don't want to take a dependency on another library. Be sure to look at how Scrutor does it for guidance!

The extension method shown below for adding an IStartupTask does two things - it registers an IStartupTask with the DI container, and it decorates a previously-registered IServer instance (I've left out the Decorate() implementation for brevity) . If it finds that the IServer is already decorated, it skips the second step. That way you can call AddStartupTask<T>() safely any number of times:

public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddStartupTask<TStartupTask>(this IServiceCollection services)
        where TStartupTask : class, IStartupTask
        => services
            .AddTransient<IStartupTask, TStartupTask>()
            .AddTaskExecutingServer();

    private static IServiceCollection AddTaskExecutingServer(this IServiceCollection services)
    {
        var decoratorType = typeof(TaskExecutingServer);
        if (services.Any(service => service.ImplementationType == decoratorType))
        {
            // We've already decorated the IServer
            return services;
        }

        // Decorate the IServer with our TaskExecutingServer
        return services.Decorate<IServer, TaskExecutingServer>();
    }
}

With these two pieces of code we no longer require users to make any changes to their Program.cs file plus we execute our tasks after the application has been fully built, including IStartupFilters and the middleware pipeline.

The sequence diagram of the startup process now looks a bit like this:

Sequence diagram of startup process

That's pretty much all there is to it. It's such a small amount of code that I wasn't sure it was worth making into a library, but it's out on GitHub and NuGet nonetheless!

I decided to only write a package for the latter approach as it's so easier to consume, and Thomas Levesque already has a NuGet package available for the first approach.

In the implementation on GitHub I manually constructed the decoration (heavily borrowing from Scrutor), to avoid forcing a dependency on Scrutor. But the best approach is probably to just copy and paste the code into your own projects 🙂 and go from there!

Summary

In this post I showed two possible ways to run tasks asynchronously on app start up while blocking the actual startup process. The first approach requires modifying your Program.cs slightly, but is "safer" in that it doesn't require messing with internal implementation details like IServer. The second approach, decorating IServer, gives a better user experience, but feels more heavy-handed. I'd be interested in which approach people feel is the better one and why, so do let me know in the comments!


Viewing all articles
Browse latest Browse all 743

Trending Articles