欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

.NET Core基于Generic Host实现后台任务方法教程

程序员文章站 2022-04-10 09:59:06
前言 很多时候,后台任务对我们来说是一个利器,帮我们在后面处理了成千上万的事情。 在.net framework时代,我们可能比较多的就是一个项目,会有一到多个对应的w...

前言

很多时候,后台任务对我们来说是一个利器,帮我们在后面处理了成千上万的事情。

在.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。

.NET Core基于Generic Host实现后台任务方法教程

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直接指定运行环境,但是个人更加倾向于在启动命令中去指定,避免一些不可控因素。

这个时候大致效果如下:

.NET Core基于Generic Host实现后台任务方法教程

虽然效果已经出来了,不过大家可能会觉得这个有点小打小闹,下面来个略微复杂一点的后台任务,用来监听并消费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发送程序来模拟消息的发送

.NET Core基于Generic Host实现后台任务方法教程

同时看我们任务的日志输出

.NET Core基于Generic Host实现后台任务方法教程

由启动到停止,效果都是符合我们预期的。

下面再来看看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站点的首页,最后是停止站点。

下面是日志结果,都是符合我们的预期。

.NET Core基于Generic Host实现后台任务方法教程

可能大家会比较好奇,这三个后台任务是怎么混合在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

下面来看看运行的效果

.NET Core基于Generic Host实现后台任务方法教程

我们先启动服务,然后去查看实时日志,可以看到应用的日志不停的输出。

当我们停了服务,再看实时日志,就会发现我们的两个后台任务已经停止了,也没有日志再进来了。

再去看看服务系统日志

sudo journalctl -fu ghostdemo.service

.NET Core基于Generic Host实现后台任务方法教程

发现它确实也是停了。

在这里,我们还可以看到服务的当前环境和根路径。

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来停止,发现还是一直在跑。

.NET Core基于Generic Host实现后台任务方法教程

ps一看,这个进程还在,kill掉之后才不会继续输出。。

.NET Core基于Generic Host实现后台任务方法教程

问题出在那里呢?原因其实还是比较明显的,因为这个任务还没有启动成功,一直处于启动中的状态!

换句话说,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

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