Login With Github

The Background Tasks Based On Generic Host In .NET Core

Foreword

There is no doubt that background tasks can help us to deal with thousands of things.

In the world of .NET Framework, there may be one or more corresponding Windows services to one project. These Windows services can be regarded as what we call background tasks.

I tend to divide the background tasks into two categories: one is running without stopping, such as the consumer of MQ and the server of RPC; the other is running periodically, such as a timed task.

So is there a different solution in the .NET Core world? The answer is yes.

Generic Host is one of the solutions.

What's Generic Host?

Generic Host is a new feature in ASP.NET Core 2.1. It's designed to separate the HTTP pipeline from the Web Host API to enable more Host schemes.

Thus some basic functionalities, such as: configuration, dependency injection and logging, are allowed to be extended in some of the features based on Generic Host.

Generic Host tends to versatility, in other words, we can use it not only in a web project but also in a non-web project!

Although sometimes mixing background tasks into web projects is not a good choice, it is still a solution, especially when resources are not sufficient.

Of course, it's better to separate it out to keep its responsibility single.

Next let's take a look at how to create a background task.

An Example for Background Task

Let's first write two background tasks (one running all the time, one running periodically) and learn how to get started with these background tasks.

These two tasks inherit the abstract class of BackgroundService uniformly, not the interface of IHostedService. The difference between the two will be talked about later.

1. A background task that is running all the time

Code:

public class PrinterHostedService2 : BackgroundService
{
    private readonly ILogger _logger;
    private readonly AppSettings _settings;

    public PrinterHostedService2(ILoggerFactory loggerFactory, IOptionsSnapshot<AppSettings> options)
    {
        this._logger = loggerFactory.CreateLogger<PrinterHostedService2>();
        this._settings = options.Value;
    }

    public override Task StopAsync(CancellationToken cancellationToken)
    {
        _logger.LogInformation("Printer2 is stopped");
        return Task.CompletedTask;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            _logger.LogInformation($"Printer2 is working. {_settings.PrinterDelaySecond}");
            await Task.Delay(TimeSpan.FromSeconds(_settings.PrinterDelaySecond), stoppingToken);
        }
    }
}

Take a look at the code carefully.

Since the service inherits the BackgroundService, so we must implement the ExecuteAsync. As for the methods such as StartAsync and StopAsync, we can override them selectively.

The ExecuteAsync here is used to output the log and then sleeps according to the number of seconds specified in the configuration file.

This task can be said to be a simplest example, which also touches upon the dependency injection. So there is no need to say more if you want to inject data warehousing and the like into the task.

Write a timed one in the same way.

2. A timed running background task

Here, we implement the function of timed running with the help of Timer, and the function can also be implemented by combining with Quartz.

public class TimerHostedService : BackgroundService
{
    //other ...
   
    private Timer _timer;

    protected override Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _timer = new Timer(DoWork, null, TimeSpan.Zero, TimeSpan.FromSeconds(_settings.TimerPeriod));
        return Task.CompletedTask;
    }

    private void DoWork(object state)
    {
        _logger.LogInformation("Timer is working");
    }

    public override Task StopAsync(CancellationToken cancellationToken)
    {
        _logger.LogInformation("Timer is stopping");
        _timer?.Change(Timeout.Infinite, 0);
        return base.StopAsync(cancellationToken);
    }

    public override void Dispose()
    {
        _timer?.Dispose();
        base.Dispose();
    }
}

It seems that there is not much difference compared to the first background task.

Let's first take a look at how to start up these two tasks in the form of a console.

Use The Way of The Console

Here, we also use NLog to record the log of the task running, which is convenient for us to observe.

The code for the Main function is as follows:

class Program
{
    static async Task Main(string[] args)
    {
        var builder = new HostBuilder()
            //logging
            .ConfigureLogging(factory =>
            {
                //use nlog
                factory.AddNLog(new NLogProviderOptions { CaptureMessageTemplates = true, CaptureMessageProperties = true });
                NLog.LogManager.LoadConfiguration("nlog.config");
            })
            //host config
            .ConfigureHostConfiguration(config =>
            {
                //command line
                if (args != null)
                {
                    config.AddCommandLine(args);
                }
            })
            //app config
            .ConfigureAppConfiguration((hostContext, config) =>
            {
                var env = hostContext.HostingEnvironment;
                config.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
                    .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true, reloadOnChange: true);

                config.AddEnvironmentVariables();

                if (args != null)
                {
                    config.AddCommandLine(args);
                }
            })
            //service
            .ConfigureServices((hostContext, services) =>
            {
                services.AddOptions();
                services.Configure<AppSettings>(hostContext.Configuration.GetSection("AppSettings"));

                //basic usage
                services.AddHostedService<PrinterHostedService2>();
                services.AddHostedService<TimerHostedService>();
            }) ;

        //console 
        await builder.RunConsoleAsync();

        ////start and wait for shutdown
        //var host = builder.Build();
        //using (host)
        //{
        //    await host.StartAsync();

        //    await host.WaitForShutdownAsync();
        //}
    }
}

When using the way of the console, we need to have some understanding for HostBuilder, although it has similarities with WebHostBuild. Most of the time, we use WebHost.CreateDefaultBuilder(args) directly to construct it, so if you don't get the understanding of CreateDefaultBuilder, then the above code may not be very easy to understand.

The general flow of the above code is as follows:

  1. New a HostBuilder object
  2. Configure logs, which is mainly injecting NLog.
  3. Configure Host, which here mainly refers to introduce CommandLine, because you need to pass parameters to the program.
  4. Configure the application, which refers to specify the configuration file and introduce CommandLine.
  5. Configure the service, which is almost the same as what we wrote in Startup, and the most important thing is that our background service should be injected here.
  6. Start up.

And,

the order of 2-5 can be sorted according to your own habits, and the contents of them are similar to the ones we write Startup.

There are many ways can be used to start up in the step 6, and here are two ways that are behavior equivalence.

a. Startup through the way of RunConsoleAsync.

b. Use StartAsync first and then WaitForShutdownAsync.

As for RunConsoleAsync, I think it is easier to understand it by checking the code below.

/// <summary>
/// Listens for Ctrl+C or SIGTERM and calls <see cref="IApplicationLifetime.StopApplication"/> to start the shutdown process.
/// This will unblock extensions like RunAsync and WaitForShutdownAsync.
/// </summary>
/// <param name="hostBuilder">The <see cref="IHostBuilder" /> to configure.</param>
/// <returns>The same instance of the <see cref="IHostBuilder"/> for chaining.</returns>
public static IHostBuilder UseConsoleLifetime(this IHostBuilder hostBuilder)
{
    return hostBuilder.ConfigureServices((context, collection) => collection.AddSingleton<IHostLifetime, ConsoleLifetime>());
}

/// <summary>
/// Enables console support, builds and starts the host, and waits for Ctrl+C or SIGTERM to shut down.
/// </summary>
/// <param name="hostBuilder">The <see cref="IHostBuilder" /> to configure.</param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public static Task RunConsoleAsync(this IHostBuilder hostBuilder, CancellationToken cancellationToken = default)
{
    return hostBuilder.UseConsoleLifetime().Build().RunAsync(cancellationToken);
}

It involves the IHostLifetime, the life cycle of the Host. And ConsoleLifeTime is the default one, and it can be understood as when it receives an instruction such as ctrl+c, it will trigger a stop.

Next, let's write the nlog configuration file.

<?xml version="1.0" encoding="utf-8" ?>
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd" xsi:schemaLocation="NLog NLog.xsd"
      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      autoReload="true"
      internalLogLevel="Info" >

  <targets>
    <target xsi:type="File"
            name="ghost"
            fileName="logs/ghost.log"
            layout="${date}|${level:uppercase=true}|${message}" />
  </targets>

  <rules>
    <logger name="GHost.*" minlevel="Info" writeTo="ghost" />
    <logger name="Microsoft.*" minlevel="Info" writeTo="ghost" />
  </rules>
</nlog>

Now we can startup our application through the command:

dotnet run -- --environment Staging

It specifies that the running environment is Staging, not the default Production.

When constructing HostBuilder, you can specify the running environment directly through UseEnvironment or ConfigureHostConfiguration, but I'm more inclined to specify it in the startup command to avoid some uncontrollable factors.

The probable effect is as follows:

You may think it is too simple. Let's take a look at a slightly more complicated background task which is used to monitor and consume RabbitMQ messages.

The Background Task for Consuming MQ Messages

public class ComsumeRabbitMQHostedService : BackgroundService
{
    private readonly ILogger _logger;
    private readonly AppSettings _settings;
    private IConnection _connection;
    private IModel _channel;

    public ComsumeRabbitMQHostedService(ILoggerFactory loggerFactory, IOptionsSnapshot<AppSettings> options)
    {
        this._logger = loggerFactory.CreateLogger<ComsumeRabbitMQHostedService>();
        this._settings = options.Value;
        InitRabbitMQ(this._settings);
    }

    private void InitRabbitMQ(AppSettings settings)
    {
        var factory = new ConnectionFactory { HostName = settings.HostName, };
        _connection = factory.CreateConnection();
        _channel = _connection.CreateModel();

        _channel.ExchangeDeclare(_settings.ExchangeName, ExchangeType.Topic);
        _channel.QueueDeclare(_settings.QueueName, false, false, false, null);
        _channel.QueueBind(_settings.QueueName, _settings.ExchangeName, _settings.RoutingKey, null);
        _channel.BasicQos(0, 1, false);

        _connection.ConnectionShutdown += RabbitMQ_ConnectionShutdown;
    }

    protected override Task ExecuteAsync(CancellationToken stoppingToken)
    {
        stoppingToken.ThrowIfCancellationRequested();

        var consumer = new EventingBasicConsumer(_channel);
        consumer.Received += (ch, ea) =>
        {
            var content = System.Text.Encoding.UTF8.GetString(ea.Body);
            HandleMessage(content);
            _channel.BasicAck(ea.DeliveryTag, false);
        };

        consumer.Shutdown += OnConsumerShutdown;
        consumer.Registered += OnConsumerRegistered;
        consumer.Unregistered += OnConsumerUnregistered;
        consumer.ConsumerCancelled += OnConsumerConsumerCancelled;

        _channel.BasicConsume(_settings.QueueName, false, consumer);
        return Task.CompletedTask;
    }

    private void HandleMessage(string content)
    {
        _logger.LogInformation($"consumer received {content}");
    }
    
    private void OnConsumerConsumerCancelled(object sender, ConsumerEventArgs e)  { ... }
    private void OnConsumerUnregistered(object sender, ConsumerEventArgs e) { ... }
    private void OnConsumerRegistered(object sender, ConsumerEventArgs e) { ... }
    private void OnConsumerShutdown(object sender, ShutdownEventArgs e) { ... }
    private void RabbitMQ_ConnectionShutdown(object sender, ShutdownEventArgs e)  { ... }

    public override void Dispose()
    {
        _channel.Close();
        _connection.Close();
        base.Dispose();
    }
}

Here we won't go into the code details. Next let's startup the MQ sending program to simulate the sending of the message.

Look at the log output of our task.

The results are are as expected.

Next let's take a look at how the background task in the form of Web is handled.

In The Form of Web

The background task under this mode is actually very simple.

We just need to register some of our background tasks in the Startup's ConfigureServices method.

public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1);
    services.AddHostedService<PrinterHostedService2>();
    services.AddHostedService<TimerHostedService>();
    services.AddHostedService<ComsumeRabbitMQHostedService>();
}

After launching the Website, we sent 20 MQ messages, then visited the home page of the Web site, and stopped the site at the end.

Below are the log results, which are as expected.

Maybe you will doubt why these three background tasks can be mixed in the Web project.

You can click the two links for the answer.

https://github.com/aspnet/Hosting/blob/2.1.1/src/Microsoft.AspNetCore.Hosting/Internal/WebHost.cs#L153

https://github.com/aspnet/Hosting/blob/2.1.1/src/Microsoft.AspNetCore.Hosting/Internal/HostedServiceExecutor.cs

The programs mentioned above all run directly on the local machine. If you pay more attention to how to deploy, then go on reading.

Deployment

There are different options for different situations (web and non-web) when deploying.

Normally, if it is a web application itself, we can just deploy as we usually do.

But we need to take a moment to have a look at how to deploy non-web scenarios.

In fact, the deployment here is equivalent to let the program run in the background.

There are many ways can be used to make the program run in the background under Linux, such as Supervisor, Screen, pm2, systemctl, etc..

We'll mainly introduce systemctl here, and use the above example to deploy. Since my personal server does not have an MQ environment, I won't enable the background task of consuming MQ.

Create a service file first.

vim /etc/systemd/system/ghostdemo.service

The content is as follows:

[Unit]
Description=Generic Host Demo

[Service]
WorkingDirectory=/var/www/ghost
ExecStart=/usr/bin/dotnet /var/www/ghost/ConsoleGHost.dll --environment Staging
KillSignal=SIGINT
SyslogIdentifier=ghost-example

[Install]
WantedBy=multi-user.target

You can find the meaning of each configuration by yourself, which is not explained here.

Then you can start and stop the service with the following command.

service ghostdemo start
service ghostdemo stop

Once the test is passed, it can be set to self-start.

systemctl enable ghostdemo.service

Let's take a look at the effect of running.

We start the service first, and then go to the real-time log. You can see the application's log outputting continuously.

When we stop the service and then look at the real-time log, we find that our two background tasks has stopped and there is no log outputting.

Go to check the service system log.

sudo journalctl -fu ghostdemo.service

It also stops.

We can also find the current environment and root path of the service here.

The Difference Between IHostedService and BackgroundService

In all of the examples above, we use BackgroundService instead of the IHostedService.

What's the difference between the two?

It can be understood as simply as that IHostedService is a raw material while BackgroundService is a semi-finished product.

Both of these two can't be used as finished products directly, and both need to be processed so as to become usable finished products.

It also means that you may need to do more if you use IHostedService.

Based on the previous print background task, here IHostedService is used to implement the code.

If we just put the implementation code in the StartAsync method simply, then there might be a surprise.

public class PrinterHostedService : IHostedService, IDisposable
{
    //other ....
    
    public async Task StartAsync(CancellationToken cancellationToken)
    {
        while (!cancellationToken.IsCancellationRequested)
        {
            Console.WriteLine("Printer is working.");
            await Task.Delay(TimeSpan.FromSeconds(_settings.PrinterDelaySecond), cancellationToken);
        }
    }

    public Task StopAsync(CancellationToken cancellationToken)
    {
        Console.WriteLine("Printer is stopped");
        return Task.CompletedTask;
    }
}

After running it, you can find that it has been running when you want to stop it with ctrl+c.

The process is still on when you check the code through the ps command. Only after killing the process will it not continue outputting.

Where's the problem? The reason is actually quite obvious: The task has not been started successfully, so it has been in the state of being started up!

In other words, the StartAsync method has not been finished executing yet. So you must be careful with this issue.

How to deal with the problem? The solution is that you can free it from the StartAsync method by referencing a variable to record the task which is to run. So simple!

public class PrinterHostedService3 : IHostedService, IDisposable
{
    //others .....
    private bool _stopping;
    private Task _backgroundTask;

    public Task StartAsync(CancellationToken cancellationToken)
    {
        Console.WriteLine("Printer3 is starting.");
        _backgroundTask = BackgroundTask(cancellationToken);
        return Task.CompletedTask;
    }

    private async Task BackgroundTask(CancellationToken cancellationToken)
    {
        while (!_stopping)
        {
            await Task.Delay(TimeSpan.FromSeconds(_settings.PrinterDelaySecond),cancellationToken);
            Console.WriteLine("Printer3 is doing background work.");
        }
    }

    public Task StopAsync(CancellationToken cancellationToken)
    {
        Console.WriteLine("Printer3 is stopping.");
        _stopping = true;
        return Task.CompletedTask;
    }

    public void Dispose()
    {
        Console.WriteLine("Printer3 is disposing.");
    }
}

Thus the task can be started up successfully!

Relatively speaking, BackgroundService is simpler to use; the abstract method, ExecuteAsync, which is used to implement the core is almost enough, and the probability of error is relatively low.

Extension of IHostBuilder

We can also register for a service by writing an extension to IHostBuilder.

public static class Extensions
{
    public static IHostBuilder UseHostedService<T>(this IHostBuilder hostBuilder)
        where T : class, IHostedService, IDisposable
    {
        return hostBuilder.ConfigureServices(services =>
            services.AddHostedService<T>());
    }

    public static IHostBuilder UseComsumeRabbitMQ(this IHostBuilder hostBuilder)
    {
        return hostBuilder.ConfigureServices(services =>
                 services.AddHostedService<ComsumeRabbitMQHostedService>());
    }
}

And you can use it as follows.

var builder = new HostBuilder()
        //others ...
        .ConfigureServices((hostContext, services) =>
        {
            services.AddOptions();
            services.Configure<AppSettings>(hostContext.Configuration.GetSection("AppSettings"));

            //basic usage
            //services.AddHostedService<PrinterHostedService2>();
            //services.AddHostedService<TimerHostedService>();
            //services.AddHostedService<ComsumeRabbitMQHostedService>();
        })
        //extensions usage
        .UseComsumeRabbitMQ()
        .UseHostedService<TimerHostedService>()
        .UseHostedService<PrinterHostedService2>()
        //.UseHostedService<ComsumeRabbitMQHostedService>()
        ;

Summary

Generic Host allows us to handle background tasks in a familiar way, so it is really a very excellent feature.

Here is the sample code used in this article. GenericHostDemo

0 Comment

temp