NET Core中基於Generic Host來實現后台任務


NET Core中基於Generic Host來實現后台任務

https://www.cnblogs.com/catcher1994/p/9961228.html

目錄

前言
什么是Generic Host
后台任務示例
控制台形式
消費MQ消息的后台任務
Web形式
部署
IHostedService和BackgroundService的區別
IHostBuilder的擴展寫法
總結
前言
很多時候,后台任務對我們來說是一個利器,幫我們在后面處理了成千上萬的事情。

在.NET Framework時代,我們可能比較多的就是一個項目,會有一到多個對應的Windows服務,這些Windows服務就可以當作是我們所說的后台任務了。

我喜歡將后台任務分為兩大類,一類是不停的跑,好比MQ的消費者,RPC的服務端。另一類是定時的跑,好比定時任務。

那么在.NET Core時代是不是有一些不同的解決方案呢?答案是肯定的。

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

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

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

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

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

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

下面就先來看看如何創建后台任務吧。

后台任務示例
我們先來寫兩個后台任務(一個一直跑,一個定時跑),體驗一下這些后台任務要怎么上手,同樣也是我們后面要使用到的。

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

一直跑的后台任務
先上代碼

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在里面就是輸出了一下日志,然后休眠在配置文件中指定的秒數。

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

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

定時跑的后台任務
這里借助了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里面的內容沒有了解,那么對上面的代碼可能就不會太清晰。

上述代碼的大致流程如下:

new一個HostBuilder對象
配置日志,主要是接入了NLog
Host的配置,這里主要是引入了CommandLine,因為需要傳遞參數給程序
應用的配置,指定了配置文件,和引入CommandLine
Service的配置,這個就和我們在Startup里面寫的差不多了,最主要的是我們的后台服務要在這里注入
啟動
其中,

2-5的順序可以按個人習慣來寫,里面的內容也和我們寫Startup大同小異。

第6步,啟動的時候,有多種方式,這里列出了兩種行為等價的方式。

a. 通過RunConsoleAsync的方式來啟動

b. 先StartAsync然后再WaitForShutdownAsync

RunConsoleAsync的奧秘,我覺得還是直接看下面的代碼比較容易懂。

///


/// Listens for Ctrl+C or SIGTERM and calls to start the shutdown process.
/// This will unblock extensions like RunAsync and WaitForShutdownAsync.
///

/// The to configure.
/// The same instance of the for chaining.
public static IHostBuilder UseConsoleLifetime(this IHostBuilder hostBuilder)
{
return hostBuilder.ConfigureServices((context, collection) => collection.AddSingleton<IHostLifetime, ConsoleLifetime>());
}

///


/// Enables console support, builds and starts the host, and waits for Ctrl+C or SIGTERM to shut down.
///

/// The to configure.
///
///
public static Task RunConsoleAsync(this IHostBuilder hostBuilder, CancellationToken cancellationToken = default)
{
return hostBuilder.UseConsoleLifetime().Build().RunAsync(cancellationToken);
}
這里涉及到了一個比較重要的IHostLifetime,Host的生命周期,ConsoleLifeTime是默認的一個,可以理解成當接收到ctrl+c這樣的指令時,它就會觸發停止。

接下來,寫一下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 ();
services.AddHostedService ();
services.AddHostedService ();
}
啟動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、pm2、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 (this IHostBuilder hostBuilder)
where T : class, IHostedService, IDisposable
{
return hostBuilder.ConfigureServices(services =>
services.AddHostedService ());
}

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

如果您認為這篇文章還不錯或者有所收獲,可以點擊右下角的【推薦】按鈕,因為你的支持是我繼續寫作,分享的最大動力!
作者:Catcher ( 黃文清 )
來源:http://catcher1994.cnblogs.com/


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM