Console applications are back in vogue, hosting everything from input-focused command-line apps, worker services, and ASP.NET Core. As we all use current and future target frameworks, the upgraded console experience is the glow-up story of .NET.
This post will explore the relationship between the IHostingService
and IHostApplicationLifetime
interfaces and how we can utilize them to build a robust console experience.
Using IHostBuilder In Console Apps
In current versions of .NET 5+, we can write straightforward console applications or utilize the IHostBuilder
interface. When choosing to use an IHostBuilder
instance, we get access to infrastructural concepts that we would otherwise have to develop ourselves:
- Dependency Injection
- Logging
- Configuration
- Worker service and Host infrastructure
- Application Lifetime Management
The use of the IHostBuilder
interface is familiar to developers working with ASP.NET Core, but it applies to any console program.
public class Program
{
public static void Main(string[] args)
{
CreateHostBuilder(args).Build().Run();
}
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureServices((hostContext, services) => {
services.AddHostedService<Worker>();
});
}
The ConfigureServices
build method allows us to register IHostedService
implementations and the most common implementations inherit from the base class of BackgroundService
, found under the Microsoft.Extensions.Hosting
namespace.
When implementing a BackgroundService
, we only need to override the ExecuteAsync
method. The host builder infrastructure passes a CancellationToken
, which we can use in all our async
method calls.
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
_logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
await Task.Delay(1000, stoppingToken);
}
}
We can override other methods, like StartAsync
, StopAsync
, and Dispose
, but t we should be very cautious in doing so. Mainly because the BackgroundService
implementations perform important actions with the CancellationToken
. Here is BackgroundService.StartAsync
base method.
public virtual Task StartAsync(CancellationToken cancellationToken)
{
// Create linked token to allow cancelling executing task from provided token
_stoppingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
// Store the task we're executing
_executingTask = ExecuteAsync(_stoppingCts.Token);
// If the task is completed then return it, this will bubble cancellation and failure to the caller
if (_executingTask.IsCompleted)
{
return _executingTask;
}
// Otherwise it's running
return Task.CompletedTask;
}
Working With The Console Host’s Lifetime
We mentioned that two of the advantages of using the IHostBuilder
abstraction are access to Dependency Injection and Application Lifetime Management. It’s good practice not to know the exact hosting context of our workers, but we still might need to know about certain events:
- When does the host start?
- When is the host spinning down and getting ready to stop?
- When has the host completely stopped.
These events are crucial in gracefully disposing of dependencies, such as database connections, flushing the logs, or releasing handles on files. So how do we use these events?
The static class Host
found under the Microsoft.Extensions.Hosting
namespace registers the IHostApplicationLifetime
abstraction into the Dependency Injection container. The interface allows us to register methods for the following events:
ApplicationStarted
ApplicationStopping
ApplicationStopped
Let’s see what the implementation looks like in our code base.
private readonly ILogger<Worker> _logger;
private readonly IHostApplicationLifetime _lifetime;
public Worker(ILogger<Worker> logger, IHostApplicationLifetime lifetime)
{
_logger = logger;
_lifetime = lifetime;
}
public override Task StartAsync(CancellationToken cancellationToken)
{
_lifetime.ApplicationStarted.Register(OnStarted);
_lifetime.ApplicationStopping.Register(OnStopping);
_lifetime.ApplicationStopped.Register(OnStopped);
return base.StartAsync(cancellationToken);
}
It is essential to call the base class’ StartAsync
, or else the worker will never execute the ExecuteAsync
method and its actions. We could also invoke the ExecuteAsync
procedure ourselves, but the original implementation has some complexities, as we saw previously.
Ordering BackgroudWorkers Matters For Events
While writing this post, I found that workers and their order of execution depend on the registration order within the ConfigureServices
method.
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureServices((hostContext, services) => {
services.AddHostedService<Worker>();
services.AddHostedService<SecondWorker>();
});
Given that we have registered Worker
and then SecondWorker
, the infrastructure will first call the StartAsync
methods in the order of registration, but the events in the reverse order or registration. The console output from the workers makes this apparent.
// identical in both workers
private void OnStarted()
{
Program.Count++;
_logger.LogInformation($"OnStarted has been called: {Program.Count}");
// Perform post-startup activities here
}
We can see the resulting order of events by looking at the logger output.
info: WorkerService.Worker[0]
Worker running at: 03/05/2021 11:58:41 -05:00
info: WorkerService.SecondWorker[0]
Worker running at: 03/05/2021 11:58:41 -05:00
info: WorkerService.SecondWorker[0]
OnStarted has been called: 1
info: WorkerService.Worker[0]
OnStarted has been called: 2
info: WorkerService.SecondWorker[0]
OnStopping has been called.
info: WorkerService.Worker[0]
OnStopping has been called.
info: Microsoft.Hosting.Lifetime[0]
Application is shutting down...
info: WorkerService.SecondWorker[0]
OnStopped has been called.
info: WorkerService.Worker[0]
OnStopped has been called.
What’s interesting is the execution order of methods:
Worker.StartAsync
SecondWorker.StartAsync
SecondWorker.OnStarted
Worker.OnStarted
SecondWorker.OnStopping
Worker.OnStopping
Application.Stopped
SecondWorker.OnStopping
Worker.OnStopping
The predictable pattern to calls can present an opportunity for sharing responsibilities between workers, but it’s likely best to treat workers as independently as possible.
Conclusion
The IHostBuilder
abstraction can add much-needed infrastructure to a console application, helping us opt-in to concepts like dependency injection, logging, configuration, and more. Taking a dependency on IHostApplicationLifetime
gives us the additional capability to start and stop our external dependencies gracefully. It is essential to recognize that the order of worker registration will determine the execution of event registrations.
I hope you found this post informational and consider using it in your next console application. If you have any thoughts or an experience you’d like to share, leave it in the comments.