In the last post we refactored the default Azure IoT Edge Module C# template code with a couple of dependency abstractions. In this post we will add a dependency injection mechanism in the mix and we will evolve this application example to a level that can be a good starting point for every Azure IoT Edge application.
In the final version of the Program.cs
of the previous post, we reached a point where we could compose the dependencies together and create a business instance of the PipeModule
. A simplified summarization of this composition is:
IConfiguration configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables()
.AddCommandLine(args)
.Build();
var cancellationTokenSource = new CancellationTokenSource();
IModuleClient moduleClient = new ModuleClientWrapper();
IDatabaseClient databaseClient =
new DatabaseClientWrapper(configuration);
PipeModule pipeModule = new PipeModule(moduleClient, databaseClient);
In this dependency hierarchy, we had to explicitly pass the previously created instances to create more complex ones, all the way to creating the PipeModule
. Although this approach is far better than the monolithic approach of the original template code, we still have hardcoded dependencies: to replace a dependency, we would still need to change this code, recompile and redeploy.
Wouldn't be nice if there was a mechanism that satisfied this hierarchy dependency composition dynamically, so that we can drive our changes via the configuration?
The Microsoft.Extensions.Hosting
namespace contains many utility classes like the HostBuilder
. This library is a good starting point because it references all other required libraries like the Microsoft.Extensions.Configuration
, Microsoft.Extension.DependencyInjection
and Microsoft.Extension.Logging
and brings together all these abstractions and functionality under a few simple interfaces.
Note: to install this extension library, run:dotnet add package Microsoft.Extensions.Hosting
In contrast to the Azure IoT SDK, this library excels in distinguishing the application from the host. The HostBuilder
is a very useful utility that helps us to compose together our application services and our hosting infrastructure. A service is a component of our application, an element in our application dependency hierarchy, and the hosting infrastructure is anything specific to the hosting environment, like for example the environment variables configuration.
Let's now refactor our previous example by using the HostBuilder
to configure our dependencies. We'll start first by implementing the IHostedService
interface in our PipeModule
class. This interface defines just two methods:
public interface IHostedService
{
Task StartAsync(CancellationToken cancellationToken);
Task StopAsync(CancellationToken cancellationToken);
}
And yes, the PipeModule
from the last post already implements these two methods, so we should just add this interface in the class signature:
public class PipeModule : IHostedService
Now, we can replace the entire Program
class with this code:
class Program
{
static async Task Main(string[] args)
{
// Read the configuration
IConfiguration configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables()
.AddCommandLine(args)
.Build();
// Create the cancelation token source
var cancellationTokenSource = new CancellationTokenSource();
// Register to the application lifetime events
AssemblyLoadContext.Default.Unloading +=
(cts) => cancellationTokenSource.Cancel();
Console.CancelKeyPress +=
(sender, cts) =>
{
Console.WriteLine("Ctrl+C detected.");
cts.Cancel = true;
cancellationTokenSource.Cancel();
};
// Build the host
using (var host = new HostBuilder()
.ConfigureServices((hostContext, services) =>
{
services.AddSingleton(cancellationTokenSource);
services.AddSingleton<IModuleClient, ModuleClientWrapper>();
services.AddSingleton<IDatabaseClient, DatabaseClientWrapper>();
services.AddHostedService<PipeModule>();
})
.Build())
{
// Start the application
host.Start();
// Wait until the app unloads or is cancelled
await WhenCancelled(cancellationTokenSource.Token);
}
}
public static Task WhenCancelled(CancellationToken cancellationToken)
{
var taskCompletionSource = new TaskCompletionSource<bool>();
cancellationToken.Register(
s => ((TaskCompletionSource<bool>)s).SetResult(true),
taskCompletionSource);
return taskCompletionSource.Task;
}
}
Note: theAddSingleton
is used when we want to add a service that will be unique for the entire application. Singletons become the classes that carry state or identity, like thecancellationTokenSource
andIModuleClient
respectively.
Note: the AddHostedService
is used for services that need to be started by the host.
We already saw how to control the application lifetime with the CancellationTokenSource
in this post. The truth is that in the early versions of .NET Core, the AppDomain.CurrentDomain.ProcessExit
event was replaced by the AssemblyLoadContext.Default.Unloading
event, but now both of them exist and can be used interchangeably.
In the IHostBuilder
we can find the UseConsoleLifetime()
extension method, which simply adds an IHostLifetime
service which is suitable for console apps, the Microsoft.Extensions.Hosting.Internal.ConsoleLifetime
service. The IHostLifetime
services control when the host starts and when it stops.
This ConsoleLifetime
service:
- Listens for
Ctrl+C
/SIGINT
orSIGTERM
and callsStopApplication
to start the shutdown process. - Unblocks extensions such as
RunAsync
andWaitForShutdownAsync
.
Let's refactor the above code now using this lifecycle service:
class Program
{
static async Task Main(string[] args)
{
// Read the configuration
IConfiguration configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables()
.AddCommandLine(args)
.Build();
// Build the host
using (var host = new HostBuilder()
.ConfigureServices((hostContext, services) =>
{
services.AddSingleton<IModuleClient, ModuleClientWrapper>();
services.AddSingleton<IDatabaseClient, DatabaseClientWrapper>();
services.AddHostedService<PipeModule>();
})
.UseConsoleLifetime()
.Build())
{
// Run the application and wait for the application to exit
await host.RunAsync();
}
}
}
What do you know! After seven posts, we're back to a single-page long Main
!
In the above version, instead of passing the CancellationTokenSource
to our services to signal an application termination, we can inject the IHostApplicationLifetime
service:
public DatabaseClientWrapper(IHostApplicationLifetime hostApplicationLifetime)
Then, we can register to the ApplicationStopping
event to gracefully stop the service, and call the StopApplication()
to trigger an application termination from inside the service.
A very good description of the startup and shutdown sequence can be found here
We've also analyzed the difference between the application and the system configuration, and we examined a few best practices on managing this configuration. Similarly to the UseConsoleLifetime()
extension, the ConfigureHostConfiguration
and ConfigureAppConfiguration
extension methods are there to help us read the application and the host configuration.
Let's see how this looks like:
class Program
{
static async Task Main(string[] args)
{
// Build the host
using (var host = new HostBuilder()
.ConfigureHostConfiguration(configHost =>
{
configHost.SetBasePath(Directory.GetCurrentDirectory());
configHost.AddJsonFile("hostsettings.json", optional: true);
configHost.AddEnvironmentVariables(prefix: "PREFIX_");
configHost.AddCommandLine(args);
})
.ConfigureAppConfiguration((hostContext, configApp) =>
{
configApp.AddJsonFile("appsettings.json", optional: true);
configApp.AddJsonFile(
$"appsettings.{hostContext.HostingEnvironment.EnvironmentName}.json",
optional: true);
configApp.AddEnvironmentVariables();
configApp.AddCommandLine(args);
})
.ConfigureServices((hostContext, services) =>
{
services.AddSingleton<IModuleClient, ModuleClientWrapper>();
services.AddSingleton<IDatabaseClient, DatabaseClientWrapper>();
services.AddHostedService<PipeModule>();
})
.UseConsoleLifetime()
.Build())
{
// Run the application and wait for the application to exit
await host.RunAsync();
}
}
}
Note that when we read the application configuration in theConfigureAppConfiguration
, we have access to the host configuration via thehostContext.Configuration
property.
The application configuration now will automatically be injected to any service that depends on it, like the DatabaseClientWrapper
we implemented in the last post.
A final step is to configure and hook up our logging logic. One alternative is to use the corresponding extension method, like for example:
.ConfigureLogging((logging) =>
{
logging.AddFilter("Microsoft", LogLevel.Warning);
logging.AddFilter("System", LogLevel.Warning);
logging.AddFilter("DependencyInjection.Program", LogLevel.Debug);
logging.AddConsole();
logging.AddDebug();
logging.AddEventSourceLogger();
logging.AddEventLog();
})
If we do this, we've just replicated the behavior of the .NET Generic Host, which is just a pre-defined host configuration, as described here.
Then our code can be simplified to:
class Program
{
static async Task Main(string[] args)
{
// Build default host
using (var host = Host.CreateDefaultBuilder(args)
.ConfigureServices((hostContext, services) =>
{
services.AddSingleton<IModuleClient, ModuleClientWrapper>();
services.AddSingleton<IDatabaseClient, DatabaseClientWrapper>();
services.AddHostedService<PipeModule>();
})
.UseConsoleLifetime()
.Build())
{
// Run the application and wait for the application to exit
await host.RunAsync();
}
}
}
Alternatively, we can use a Serilog
logger, driven by configuration. To do this, we will add the Serilog.Extensions.Hosting
nuget package:
class Program
{
static async Task Main(string[] args)
{
// Build default host
using (var host = Host.CreateDefaultBuilder(args)
.ConfigureServices((hostContext, services) =>
{
// Configure our services
services.AddSingleton<IModuleClient, ModuleClientWrapper>();
services.AddSingleton<IDatabaseClient, DatabaseClientWrapper>();
services.AddHostedService<PipeModule>();
})
.UseSerilog((hostingContext, log) =>
{
log.ReadFrom.Configuration(hostingContext.Configuration);
})
.UseConsoleLifetime()
.Build())
{
// Run the application and wait for the application to exit
await host.RunAsync();
}
}
}
By doing so, we get the expected logger injection behavior by adding a logger dependency in a constructor:
public DatabaseClientWrapper(ILogger<DatabaseClientWrapper> logger)
Also, the Serilog.Log.Logger
is set implicitly, and can be accessed from everywhere by the static Serilog.Log
property.
It is impressive how much plumbing code is hidden in this last version, but without losing the ability to override the default host builder if we want to, as we did with the Serilog logger.
Recap
In these mini-series blog posts we have explored various patterns and best practices and we managed to evolve the monolithic Azure IoT Edge module C# template code to a loosely coupled application that allows us have a true F5 development experience and define unit tests.
In the next post we will explore how we can use this loosely coupled design to setup our development environment.