目录git
不少时候,后台任务对咱们来讲是一个利器,帮咱们在后面处理了成千上万的事情。github
在.NET Framework时代,咱们可能比较多的就是一个项目,会有一到多个对应的Windows服务,这些Windows服务就能够看成是咱们所说的后台任务了。web
我喜欢将后台任务分为两大类,一类是不停的跑,比如MQ的消费者,RPC的服务端。另外一类是定时的跑,比如定时任务。shell
那么在.NET Core时代是否是有一些不一样的解决方案呢?答案是确定的。json
Generic Host就是其中一种方案,也是本文的主角。vim
Generic Host是ASP.NET Core 2.1中的新增功能,它的目的是将HTTP管道从Web Host的API中分离出来,从而启用更多的Host方案。服务器
这样可让基于Generic Host的一些特性延用一些基础的功能。如:如配置、依赖关系注入和日志等。app
Generic Host更倾向于通用性,换句话就是说,咱们便可以在Web项目中使用,也能够在非Web项目中使用!async
虽然有时候后台任务混杂在Web项目中并非一个太好的选择,但也并不失是一个解决方案。尤为是在资源并不充足的时候。ide
比较好的作法仍是让其独立出来,让它的职责更加单一。
下面就先来看看如何建立后台任务吧。
咱们先来写两个后台任务(一个一直跑,一个定时跑),体验一下这些后台任务要怎么上手,一样也是咱们后面要使用到的。
这两个任务统一继承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里面的内容没有了解,那么对上面的代码可能就不会太清晰。
上述代码的大体流程以下:
其中,
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的消息。
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形式的后台任务是怎么处理的。
这种模式下的后台任务,其实就是十分简单的了。
咱们只要在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、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
发现它确实也是停了。
在这里,咱们还能够看到服务的当前环境和根路径。
前面的全部示例中,咱们用的都是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的扩展方法来完成。
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项目中,都已经符合很多应用的情景了。
最后放上本文用到的示例代码