談談.NET Core中基於Generic Host來實現後臺任務

前言

不少時候,後臺任務對咱們來講是一個利器,幫咱們在後面處理了成千上萬的事情。github

在.NET Framework時代,咱們可能比較多的就是一個項目,會有一到多個對應的Windows服務,這些Windows服務就能夠看成是咱們所說的後臺任務了。web

我喜歡將後臺任務分爲兩大類,一類是不停的跑,比如MQ的消費者,RPC的服務端。另外一類是定時的跑,比如定時任務。shell

那麼在.NET Core時代是否是有一些不一樣的解決方案呢?答案是確定的。json

Generic Host就是其中一種方案,也是本文的主角。vim

什麼是Generic Host

Generic Host是ASP.NET Core 2.1中的新增功能,它的目的是將HTTP管道從Web Host的API中分離出來,從而啓用更多的Host方案。服務器

這樣可讓基於Generic Host的一些特性延用一些基礎的功能。如:如配置、依賴關係注入和日誌等。app

Generic Host更傾向於通用性,換句話就是說,咱們便可以在Web項目中使用,也能夠在非Web項目中使用!async

雖然有時候後臺任務混雜在Web項目中並非一個太好的選擇,但也並不失是一個解決方案。尤爲是在資源並不充足的時候。ide

比較好的作法仍是讓其獨立出來,讓它的職責更加單一。

下面就先來看看如何建立後臺任務吧。

後臺任務示例

咱們先來寫兩個後臺任務(一個一直跑,一個定時跑),體驗一下這些後臺任務要怎麼上手,一樣也是咱們後面要使用到的。

這兩個任務統一繼承BackgroundService這個抽象類,而不是IHostedService這個接口。後面會說到二者的區別。

  1. 一直跑的後臺任務

先上代碼

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);
        }
    }
}

來看看裏面的細節。

咱們的這個服務繼承了BackgroundService,就必定要實現裏面的ExecuteAsync,至於StartAsync和StopAsync等方法能夠選擇性的override。

咱們ExecuteAsync在裏面就是輸出了一下日誌,而後休眠在配置文件中指定的秒數。

這個任務能夠說是最簡單的例子了,其中還用到了依賴注入,若是想在任務中注入數據倉儲之類的,應該就不須要再多說了。

一樣的方式再寫一個定時的。

  1. 定時跑的後臺任務

這裏藉助了Timer來完成定時跑的功能,一樣的還能夠結合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();
    }
}

和第一個後臺任務相比,沒有太大的差別。

下面咱們先來看看如何用控制檯的形式來啓動這兩個任務。

控制檯形式

這裏會同時引入NLog來記錄任務跑的日誌,方便咱們觀察。

Main函數的代碼以下:

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();
        //}
    }
}

對於控制檯的方式,須要咱們對HostBuilder有必定的瞭解,雖然說它和WebHostBuild有類似的地方。可能大部分時候,咱們是直接使用了WebHost.CreateDefaultBuilder(args)來構造的,若是對CreateDefaultBuilder裏面的內容沒有了解,那麼對上面的代碼可能就不會太清晰。

上述代碼的大體流程以下:

  1. new一個HostBuilder對象
  2. 配置日誌,主要是接入了NLog
  3. Host的配置,這裏主要是引入了CommandLine,由於須要傳遞參數給程序
  4. 應用的配置,指定了配置文件,和引入CommandLine
  5. Service的配置,這個就和咱們在Startup裏面寫的差很少了,最主要的是咱們的後臺服務要在這裏注入
  6. 啓動

其中,

2-5的順序能夠按我的習慣來寫,裏面的內容也和咱們寫Startup大同小異。

第6步,啓動的時候,有多種方式,這裏列出了兩種行爲等價的方式。

a. 經過RunConsoleAsync的方式來啓動

b. 先StartAsync而後再WaitForShutdownAsync

RunConsoleAsync的奧祕,我以爲仍是直接看下面的代碼比較容易懂。

/// <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);
}

這裏涉及到了一個比較重要的IHostLifetime,Host的生命週期,ConsoleLifeTime是默認的一個,能夠理解成當接收到ctrl+c這樣的指令時,它就會觸發中止。

接下來,寫一下nlog的配置文件

<?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>

這個時候已經能夠經過命令啓動咱們的應用了。

dotnet run -- --environment Staging

這裏指定了運行環境爲Staging,而不是默認的Production。

在構造HostBuilder的時候,能夠經過UseEnvironment或ConfigureHostConfiguration直接指定運行環境,可是我的更加傾向於在啓動命令中去指定,避免一些不可控因素。

這個時候大體效果以下:

雖然效果已經出來了,不過你們可能會以爲這個有點小打小鬧,下面來個略微複雜一點的後臺任務,用來監聽並消費RabbitMQ的消息。

消費MQ消息的後臺任務

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();
    }
}

代碼細節就不須要多說了,下面就啓動MQ發送程序來模擬消息的發送

同時看咱們任務的日誌輸出

由啓動到中止,效果都是符合咱們預期的。

下面再來看看Web形式的後臺任務是怎麼處理的。

Web形式

這種模式下的後臺任務,其實就是十分簡單的了。

咱們只要在Startup的ConfigureServices方法裏面註冊咱們的幾個後臺任務就能夠了。

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

啓動Web站點後,咱們發了20條MQ消息,再訪問了一下Web站點的首頁,最後是中止站點。

下面是日誌結果,都是符合咱們的預期。

可能你們會比較好奇,這三個後臺任務是怎麼混合在Web項目裏面啓動的。

答案就在下面的兩個連接裏。

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

上面說了那麼多,都是在本地直接運行的,可能你們會比較關注這個要怎樣部署,下面咱們就不看看怎麼部署。

部署

部署的話,針對不一樣的情形(web和非web)都有不一樣的選擇。

正常來講,若是自己就是web程序,那麼平時咱們怎麼部署的,就和平時那樣部署便可。

花點時間講講部署非web的情形。

其實這裏的部署等價於讓程序在後臺運行。

在Linux下面讓程序在後臺運行方式有好多好多,Supervisor、Screen、pm二、systemctl等。

這裏主要介紹一下systemctl,同時用上面的例子來進行部署,因爲我的服務器沒有MQ環境,因此沒有啓用消費MQ的後臺任務。

先建立一個 service 文件

vim /etc/systemd/system/ghostdemo.service

內容以下:

[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

其中,各項配置的含義能夠自行查找,這裏不做說明。

而後能夠經過下面的命令來啓動和中止這個服務

service ghostdemo start
service ghostdemo stop

測試無誤以後,就能夠設爲自啓動了。

systemctl enable ghostdemo.service

下面來看看運行的效果

咱們先啓動服務,而後去查看實時日誌,能夠看到應用的日誌不停的輸出。

當咱們停了服務,再看實時日誌,就會發現咱們的兩個後臺任務已經中止了,也沒有日誌再進來了。

再去看看服務系統日誌

sudo journalctl -fu ghostdemo.service

發現它確實也是停了。

在這裏,咱們還能夠看到服務的當前環境和根路徑。

IHostedService和BackgroundService的區別

前面的全部示例中,咱們用的都是BackgroundService,而不是IHostedService。

這二者有什麼區別呢?

能夠這樣簡單的理解,IHostedService是原料,BackgroundService是一個用原料加工過一部分的半成品

這兩個都是不能直接當成成品來用的,都須要進行加工才能作成一個可用的成品。

同時也意味着,若是使用IHostedService可能會須要作比較多的控制。

基於前面的打印後臺任務,在這裏使用IHostedService來實現。

若是咱們只是純綷的把實現代碼放到StartAsync方法中,那麼可能就會有驚喜了。

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;
    }
}

運行以後,想用ctrl+c來中止,發現仍是一直在跑。

ps一看,這個進程還在,kill掉以後纔不會繼續輸出。。

問題出在那裏呢?緣由其實仍是比較明顯的,由於這個任務尚未啓動成功,一直處於啓動中的狀態!

換句話說,StartAsync方法尚未執行完。這個問題必定要當心再當心。

要怎麼處理這個問題呢?解決方法也比較簡單,能夠經過引用一個變量來記錄要運行的任務,將其從StartAsync方法中解放出來。

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.");
    }
}

這樣就能讓這個任務真正的啓動成功了!效果就不放圖了。

相對來講,BackgroundService用起來會比較簡單,實現核心的ExecuteAsync這個抽象方法就差很少了,出錯的機率也會比較低。

IHostBuilder的擴展寫法

在註冊服務的時候,咱們還能夠經過編寫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>());
    }
}

使用的時候就能夠像下面同樣。

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>()
        ;

總結

Generic Host讓咱們能夠用熟悉的方式來處理後臺任務,不得不說這是一個很👍的特性。

不管是將後臺任務獨立一個項目,仍是將其混搭在Web項目中,都已經符合很多應用的情景了。

最後放上本文用到的示例代碼

GenericHostDemo

相關文章
相關標籤/搜索