很多时候我们都会有设计一个后台服务的需求,比如,传统的Windows Service,或者Linux下的守护进程。这类应用的一个共同特点就是后台运行,并且不占用控制台界面。通常情况下,后台服务在提供服务时,会通过日志输出来记录服务处理的详细信息,用户也可以根据具体需要来设置不同的日志级别(Log Level),从而决定日志的输出详细程度。无论是传统的Windows Service还是Linux守护进程,都是开发人员非常熟悉的应用程序形式,开发技术和开发模式都是大家所熟知的,那么,在.NET Core中,又如何专业地实现这类后台服务应用呢?
其实,.NET Core的开发人员应该早就接触过并且使用过某种基于.NET Core的后台服务的开发技术了,它就是ASP.NET Core。ASP.NET Core应用程序在启动后,通过监听端口接受来自客户端的HTTP请求,然后根据路由策略定位到某个控制器(Controller)的某个方法(Action)上,接着将处理结果又以HTTP Response的形式返回给客户端(此处描述省略了Filter等步骤)。ASP.NET Core作为后台服务的一个最大特点是,它是专为HTTP协议定制的,也就是说,ASP.NET Core有着非常强大的处理HTTP协议与通信管道的能力。很显然,在某些场景中,服务端与客户端的通信并非基于HTTP协议,甚至于后台服务仅仅是在本地处理一些批量的事务,并不会涉及与其它服务或者客户端的交互。在这种情况下,使用ASP.NET Core就会显得比较重了。
在上面,我特别强调了“专业地”三个字,如何理解什么叫“专业”?我想,简单地说,就是我们所设计的后台服务程序,在基础设施部分,能够做到与ASP.NET Core相当的编程模型,并且能够达到与ASP.NET Core相当的扩展能力,具体地说,主要有以下几个方面:
具有非常好的隔离性:开发者只需要关注怎么实现自己的后台服务逻辑即可,不需要关注服务运行的保障体系,比如:如何正常终止服务、如何写入日志、如何管理对象生命周期等等
具有非常好的编程体验:使用过ASP.NET Core的开发者能够快速上手,直击主题,快速实现业务处理逻辑
可扩展、可配置的应用程序配置体系
可扩展、可配置的日志体系
可扩展、可配置的依赖注入体系
对服务宿主环境的区分。比如:在ASP.NET Core中,通常分为Development、Test、Staging、Production等环境,不同的环境可以有不同的配置信息等
在.NET Core 2.1以前,要在后台服务中自己实现上述各项是很不容易的,但从.NET Core 2.1开始,我们就可以直接使用.NET Generic Host体系,来实现自己的后台服务程序(也称为服务宿主程序)。根据微软官方文档,服务宿主程序分为两种:Web Hosting和Generic Hosting,前者主要处理HTTP请求,ASP.NET Core就是基于Web Hosting,但在今后,Generic Hosting会一统江湖,以做到能够同时处理HTTP和非HTTP两种不同的使用场景。基于.NET Generic Host,我们可以打造自己的服务宿主(Service Hosting)框架,以便在实际项目中能够基于这个框架来快速实现不同的后台服务应用场景。
设计
从本质上讲,一个.NET Core服务宿主程序只需要实现IHostedService接口,然后在控制台应用程序中通过HostBuilder来建立一个Host实例,并将IHostedService的实例注册到Host中,然后直接运行即可。下面的代码展示了这种最基础的实现方式:
class MyService : IHostedService { public Task StartAsync(CancellationToken cancellationToken) { Console.WriteLine( "Host Started" ); return Task.CompletedTask; } public Task StopAsync(CancellationToken cancellationToken) { Console.WriteLine( "Host Stopped" ); return Task.CompletedTask; } } class Program { static async Task Main( string [] args) { var hostBuilder = new HostBuilder() .ConfigureServices(serviceCollection => { serviceCollection.AddSingleton<IHostedService, MyService>(); }); await hostBuilder.RunConsoleAsync(); } } |
我们已经成功地实现了一个服务宿主程序,请使用C# 7.2或更高的版本来编译上面的代码,因为使用了异步Main函数。执行程序,会在控制台打印Host Started的字样,并提示目前的执行环境为Production,按下CTRL+C可以结束程序的执行。在按下CTRL+C时,控制台又会输出Host Stopped字样,然后程序退出。
上面的代码最关键的一点就是要将IHostedService的实现类注册到依赖注入框架中,于是,Host Builder在运行主机(Host)的时候,就会通过IHostedService接口类型找到已注册的实例,然后运行服务。通过Host Builder,我们还可以对宿主程序的执行环境、配置信息、日志等各方面进行配置,从而提供更为强大的服务端功能。比如在上面的代码中,仅仅是通过Console.WriteLine的调用来输出信息,这种做法并不好,因为如果服务运行于后台,是不能访问控制台的,我们需要日志发布机制。
由此可见,还有很多工作我们需要完成,总结起来,我们希望有一个简单的框架,在这个框架中,配置、日志、宿主环境等等设置都已遵循常规的标准做法,我们只需要关注于实现上面的StartAsync和StopAsync方法即可,这样的框架基本上也就能够满足大多数的服务宿主应用程序的开发需求。所谓的“遵循常规的标准做法”,意思就是:
可以通过配置文件、命令行或者环境变量来指定目前的宿主环境(是Development、Test、Staging还是Production)
可以通过配置文件、命令行或者环境变量来提供程序执行的配置信息
可以提供基本的日志定义和输出机制,比如可以通过配置文件来配置日志系统,并将日志输出到控制台
还可以提供一些额外的编程接口,以保证循环任务的合理退出、资源的合理释放等等
根据上述需求分析,以及.NET Core中服务宿主程序的基本实现技术,我做出了如下的设计:
设计一个ServiceHost的类型,它的主要任务就是托管一种后台服务,它包含服务的启动与停止的逻辑。因此,ServiceHost是IHostedService的一种实现
设计一个ServiceRunner的类型,它的主要任务是配置运行环境,并对ServiceHost进行注册。因此,ServiceRunner基本上就类似于ASP.NET Core中Startup类的职责,在里面可以进行各种配置和服务注册,为ServiceHost的执行提供环境
基于这样的设计,当我需要实现一个宿主服务时,我只需要继承ServiceHost类,实现其中的StartAsync和StopAsync方法,然后运行ServiceRunner,即可达到上述“标准做法”的要求。当然还可以继承ServiceRunner,以实现一些运行环境的高级配置。下面的类图展示了这样一种设计:
上面的设计可以看到,ServiceHost类提供了两个抽象方法:StartAsync、StopAsync,这两个方法都可以支持基于任务的异步执行模式(Task-based Asynchronous Pattern,TAP),在实际应用中,只需要实现这两个方法即可。ServiceHost所提供的OnHostStarted、OnHostStopped以及OnHostStopping回调方法,会在ServiceHost的生命周期的特定阶段被调用到,因此,如果有需要在服务启动完成、服务准备停止以及服务完成停止这几个阶段进行额外的处理的话,就可以根据自己的需要来重载这几个方法。
而服务宿主环境的配置,就实现在ServiceRunner中。ServiceRunner提供了类似ASP.NET Core中Startup类的一系列方法,在这些方法中,ServiceRunner完成了对应用程序配置信息、宿主环境配置信息、日志以及类型依赖项的配置工作。同样,开发者也可以根据自己的需要,重载这些方法,来完成额外的配置任务。
综上所述,整体设计既满足了简化开发任务的需求,又满足了提供必要扩展的需要。具体代码这里就不贴了,请直接下载本文的附件,其中包含完整的代码。接下来,我们来了解一下基于该服务宿主框架的几个常用开发模式。
使用
这里介绍几种不同的应用场景下使用我们的服务宿主框架的方法,供大家参考。
基本用法
下面的代码就是最简单的使用方式,可以看到,与上面的代码相比,我们已经可以使用日志来输出信息了,并且更重要的是,应用程序的配置信息都可以放在appsettings.json文件中,不仅如此,宿主程序的运行环境配置在hostsettings.json文件中,还可以根据当前的宿主环境来选择不同的配置文件。这些行为已经跟ASP.NET Core的执行行为非常相似了。更有趣的是,ServiceRunner的ConfigureAppConfiguration方法中默认加入了通过环境变量以及命令行的方式来实现程序的配置,因此,开发出来的服务宿主程序可以很方便地集成在容器环境中。
class MyService : ServiceHost { private readonly ILogger logger; public MyService(ILogger<MyService> logger, IApplicationLifetime applicationLifetime) : base (applicationLifetime) => this .logger = logger; public override Task StartAsync(CancellationToken cancellationToken) { this .logger.LogInformation( "MyService started." ); return Task.CompletedTask; } public override Task StopAsync(CancellationToken cancellationToken) { this .logger.LogInformation( "MyService stopped." ); return Task.CompletedTask; } } class Program { static async Task Main( string [] args) { var serviceRunner = new ServiceRunner<MyService>(); await serviceRunner.RunAsync(args); } } |
代码执行效果如下:
合理终止无限循环的服务端任务
另一个使用场景,就是当ServiceHost启动的时候,会启动一个后台任务,不停地执行一些处理逻辑,直到用户按下CTRL+C,才会停止这个重复执行的任务并正常终止程序。使用上面的服务宿主框架也很容易实现:
class MyService : ServiceHost { private readonly ILogger logger; private readonly CancellationTokenSource cancellationTokenSource = new CancellationTokenSource(); private readonly List<Task> tasks = new List<Task>(); public MyService(ILogger<MyService> logger, IApplicationLifetime applicationLifetime) : base (applicationLifetime) { this .logger = logger; } public override Task StartAsync(CancellationToken cancellationToken) { var task = Task.Run( async () => { while (!cancellationTokenSource.IsCancellationRequested) { logger.LogInformation($ "Task executing at {DateTime.Now}" ); await Task.Delay(1000); } }); tasks.Add(task); return Task.CompletedTask; } public override Task StopAsync(CancellationToken cancellationToken) { Task.WaitAll(tasks.ToArray(), 5000); logger.LogInformation( "Host stopped." ); return Task.CompletedTask; } protected override void Dispose( bool disposing) { logger.LogInformation( "Host disposed." ); base .Dispose(disposing); } protected override void OnHostStopping() { logger.LogInformation( "Host stopping requested." ); this .cancellationTokenSource.Cancel(); } } class Program { static async Task Main( string [] args) { var serviceRunner = new ServiceRunner<MyService>(); await serviceRunner.RunAsync(args); } } |
主要思路就是在MyService中定义一个CancellationTokenSource,在OnHostStopping的回调函数中,调用Cancel方法触发取消事件,然后在任务的运行体中判断是否已经发起了“取消”请求。执行结果如下:
Serilog的集成与使用
我们还可以非常方便地在我们的服务宿主程序中使用Serilog,以实现强大的日志功能,代码如下:
class MyService : ServiceHost { private readonly Microsoft.Extensions.Logging.ILogger logger; public MyService(ILogger<MyService> logger, IApplicationLifetime applicationLifetime) : base (applicationLifetime) => this .logger = logger; public override Task StartAsync(CancellationToken cancellationToken) { this .logger.LogInformation( "MyService started." ); return Task.CompletedTask; } public override Task StopAsync(CancellationToken cancellationToken) { this .logger.LogInformation( "MyService stopped." ); return Task.CompletedTask; } } class SerilogSampleRunner : ServiceRunner<MyService> { protected override void ConfigureLogging(HostBuilderContext context, ILoggingBuilder logging) { // Leave this method blank to remove any logging configuration from base implementation. } protected override IHostBuilder ConfigureAdditionalFeatures(IHostBuilder hostBuilder) { return hostBuilder.UseSerilog((hostBuilderConfig, loggerConfig) => { loggerConfig.ReadFrom.Configuration(hostBuilderConfig.Configuration); }); } } class Program { static async Task Main( string [] args) { var serviceRunner = new SerilogSampleRunner(); await serviceRunner.RunAsync(args); } } |
执行上面的代码,可以看到,输出日志的格式发生了变化:
Serilog有很多插件,可以很方便地将日志输出到各种不同的载体,比如文件、数据库、Azure托管的消息总线等等,有兴趣的读者可以上Serilog的官方网站了解,这里就不详细介绍了。
总结
本文介绍了基于.NET Core通用主机(Generic Host)的服务宿主框架的设计与实现,并给出了三个应用场景的案例代码,详细代码可以点击文后的下载链接进行下载。有关.NET Core Generic Host以及本文介绍的框架,还有很多高级功能和特殊用法,有需要的读者可以在本文留言,共同探讨。
原文地址:http://sunnycoding.cn/2019/01/29/implementing-generic-purpose-service-hosting-framework-2/
.NET社区新闻,深度好文,欢迎访问公众号文章汇总 http://www.csharpkit.com