前言
很多时候,后台任务对我们来说是一个利器,帮我们在后面处理了成千上万的事情。
在.NET Framework时代,我们可能比较多的就是一个项目,会有一到多个对应的Windows服务,这些Windows服务就可以当作是我们所说的后台任务了。
我喜欢将后台任务分为两大类,一类是不停的跑,好比MQ的消费者,RPC的服务端。另一类是定时的跑,好比定时任务。
那么在.NET Core时代是不是有一些不同的解决方案呢?答案是肯定的。
什么是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、一直跑的后台任务
先上代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
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来完成。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
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函数的代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
|
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的奥秘,我觉得还是直接看下面的代码比较容易懂。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
/// <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的配置文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
<? 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 >
|
这个时候已经可以通过命令启动我们的应用了。
1
|
dotnet run -- --environment Staging
|
这里指定了运行环境为Staging,而不是默认的Production。
在构造HostBuilder的时候,可以通过UseEnvironment或ConfigureHostConfiguration直接指定运行环境,但是个人更加倾向于在启动命令中去指定,避免一些不可控因素。
这个时候大致效果如下:
虽然效果已经出来了,不过大家可能会觉得这个有点小打小闹,下面来个略微复杂一点的后台任务,用来监听并消费RabbitMQ的消息。
消费MQ消息的后台任务
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
|
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方法里面注册我们的几个后台任务就可以了。
1
2
3
4
5
6
7
|
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 文件
1
|
vim /etc/systemd/system/ghostdemo .service
|
内容如下:
1
2
3
4
5
6
7
8
9
10
11
|
[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
|
其中,各项配置的含义可以自行查找,这里不作说明。
然后可以通过下面的命令来启动和停止这个服务
1
2
|
service ghostdemo start
service ghostdemo stop
|
测试无误之后,就可以设为自启动了。
1
|
systemctl enable ghostdemo.service
|
下面来看看运行的效果
我们先启动服务,然后去查看实时日志,可以看到应用的日志不停的输出。
当我们停了服务,再看实时日志,就会发现我们的两个后台任务已经停止了,也没有日志再进来了。
再去看看服务系统日志
1
|
sudo journalctl -fu ghostdemo.service
|
发现它确实也是停了。
在这里,我们还可以看到服务的当前环境和根路径。
IHostedService和BackgroundService的区别
前面的所有示例中,我们用的都是BackgroundService,而不是IHostedService。
这两者有什么区别呢?
可以这样简单的理解,IHostedService是原料,BackgroundService是一个用原料加工过一部分的半成品。
这两个都是不能直接当成成品来用的,都需要进行加工才能做成一个可用的成品。
同时也意味着,如果使用IHostedService可能会需要做比较多的控制。
基于前面的打印后台任务,在这里使用IHostedService来实现。
如果我们只是纯綷的把实现代码放到StartAsync方法中,那么可能就会有惊喜了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
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方法中解放出来。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
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的扩展方法来完成。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
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>());
}
}
|
使用的时候就可以像下面一样。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
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项目中,都已经符合不少应用的情景了。
最后放上本文用到的示例代码
好了,以上就是这篇文章的全部内容了,希望本文的内容对大家的学习或者工作具有一定的参考学习价值,如果有疑问大家可以留言交流,谢谢大家对服务器之家的支持。
原文链接:https://www.cnblogs.com/catcher1994/p/9961228.html