.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方案。

现在2.1版本的Asp.Net Core中,有了两种可用的Host。

Web Host–适用于托管Web程序的Host,就是我们所熟悉的在Asp.Net Core应用程序的Mai函数中用CreateWebHostBuilder创建出来的常用的WebHost。

Generic Host (ASP.NET Core 2.1版本才有) – 适用于托管非 Web 应用(例如,运行后台任务的应用)。 在未来的版本中,通用主机将适用于托管任何类型的应用,包括 Web 应用。 通用主机最终将取代 Web 主机,这大概也是这种类型的主机叫做通用主机的原因。

这样可以让基于Generic Host的一些特性延用一些基础的功能。如:如配置、依赖关系注入和日志等。

Generic Host更倾向于通用性,换句话就是说,我们即可以在Web项目中使用,也可以在非Web项目中使用!

虽然有时候后台任务混杂在Web项目中并不是一个太好的选择,但也并不失是一个解决方案。尤其是在资源并不充足的时候。

比较好的做法还是让其独立出来,让它的职责更加单一。

下面就先来看看如何创建后台任务吧。

后台任务示例

我们先来写两个后台任务(一个一直跑,一个定时跑),体验一下这些后台任务要怎么上手,同样也是我们后面要使用到的。

这两个任务统一继承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在里面就是输出了一下日志,然后休眠在配置文件中指定的秒数。

这个任务可以说是最简单的例子了,其中还用到了依赖注入,如果想在任务中注入数据仓储之类的,应该就不需要再多说了。

同样的方式再写一个定时的。

定时跑的后台任务

这里借助了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项目里面启动的。

答案就在下面的两个链接里。

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

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

好了,以上就是这篇文章的全部内容了,希望本文的内容对大家的学习或者工作具有一定的参考学习价值,如果有疑问大家可以留言交流,谢谢大家对我们的支持。

(0)

相关推荐

  • ASP.NET 计划任务实现方法(不使用外接程序,.net内部机制实现)

    以下讲解步骤: 1. 在Global.asax 文件中作如下修改 复制代码 代码如下: void Application_Start(object sender, EventArgs e) { // 在应用程序启动时运行的代码 //定义定时器 //1000表示1秒的意思 System.Timers.Timer myTimer = new System.Timers.Timer(1000); //TaskAction.SetContent 表示要调用的方法 myTimer.Elapsed += n

  • asp.net 每天定点执行任务

    复制代码 代码如下: public static void ClearCountByTime() { System.Timers.Timer aTimer = new System.Timers.Timer(); aTimer.Elapsed += new ElapsedEventHandler(TimeEvent); // 设置引发时间的时间间隔 此处设置为1秒 aTimer.Interval = 1000; aTimer.Enabled = true; } private static vo

  • asp.net 定时间点执行任务的简易解决办法

    比如每天凌晨七点的时候email发送一次报表. 这里首先想到的就是利用 Global.asax 文件来实现, 以下Global文件的内容. 复制代码 代码如下: <%@ Application Language="C#" %> <%@ Import Namespace="System.Threading" %> <script runat="server"> //这里使用静态保持对这处Timer实例的引用,以免

  • asp.net 在客户端显示服务器端任务处理进度条的探讨

    下面就是采用静态变量的方法实现的: 复制代码 代码如下: <%@ Page Language="C#" %> <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1 -transitional.dtd"> <script runat="server"

  • asp.net 计划任务管理程序实现,多线程任务加载

    asp.net下实现可以将计划任务的方法放在global里,使用一个统一的任务管理类来管理各种任务的执行,做到并行不悖! 下面是我写的一个方法,希望起个抛砖引玉的作用!大家一起学习下: 第一步定义一个接口,用来规范任务必须要实现的动作,该接口只有一个方法(简单起见): 复制代码 代码如下: /// <summary> /// 工作单元接口,定义一个计划任务必须完成的工作 /// </summary> public interface IScheduledTask { /// <

  • 详解免费开源的DotNet任务调度组件Quartz.NET(.NET组件介绍之五)

    很多的软件项目中都会使用到定时任务.定时轮询数据库同步,定时邮件通知等功能..NET Framework具有"内置"定时器功能,通过System.Timers.Timer类.在使用Timer类需要面对的问题:计时器没有持久化机制:计时器具有不灵活的计划(仅能设置开始时间和重复间隔,没有基于日期,时间等):计时器不使用线程池(每个定时器一个线程):计时器没有真正的管理方案 - 你必须编写自己的机制,以便能够记住,组织和检索任务的名称等. 如果需要在.NET实现定时器的功能,可以尝试使用以

  • 利用Timer在ASP.NET中实现计划任务的方法

    .NET Framework中为我们提供了3种类型的Timer,分别是: Server Timer(System.Timers.Timer),Thread Timer(System.Threading.Timer )和Windows Timer(System.Windows.Forms.Timer). 其中Windows Timer和WinAPI中的Timer一样,是基于消息的,而且是单线程的.另外两个Timer则不同于Windows Timer,它们是基于ThreadPool的,这样最大的好处

  • Asp.Net(C#)自动执行计划任务的程序实例分析分享

    在业务复杂的应用程序中,有时候会要求一个或者多个任务在一定的时间或者一定的时间间隔内计划进行,比如定时备份或同步数据库,定时发送电子邮件等,我们称之为计划任务.实现计划任务的方法也有很多,可以采用SQLAgent执行存储过程来实现,也可以采用Windows任务调度程序来实现,也可以使用Windows服务来完成我们的计划任务,这些方法都是很好的解决方案.但是,对于Web应用程序来说,这些方法实现起来并不是很简单的,主机服务提供商或者不能直接提供这样的服务,或者需要你支付许多额外的费用. 本文就介绍

  • C#(asp.net)多线程用法示例(可用于同时处理多个任务)

    本文实例讲述了C#(asp.net)多线程用法.分享给大家供大家参考,具体如下: using System; using System.Collections.Generic; using System.Linq; using System.Web; using System.Web.UI; using System.Threading; using System.Web.UI.WebControls; public partial class muti_thread : System.Web.

  • Asp.net ajax实现任务提示页面的简单代码

    复制代码 代码如下: <%@ Page Language="C#" %><!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN""http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><script runat="server">    void WaitFiveS

随机推荐