談談.NET Core中基於Generic Host來實現後臺任務
目錄
前言
很多時候,後臺任務對我們來說是一個利器,幫我們在後面處理了成千上萬的事情。
在.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
- 一直跑的後臺任務
先上程式碼
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的奧祕,我覺得還是直接看下面的程式碼比較容易懂。
/// <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專案裡面啟動的。
答案就在下面的兩個連結裡。
上面說了那麼多,都是在本地直接執行的,可能大家會比較關注這個要怎樣部署,下面我們就不看看怎麼部署。
部署
部署的話,針對不同的情形(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<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讓我們可以用熟悉的方式來處理後臺任務,不得不說這是一個很