在.NET Core中设计自己的服务宿主(Service Hosting)框架
很多时候我们都会有设计一个后台服务的需求,比如,传统的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服务宿主程序只需要实现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)
- 可以通过配置文件、命令行或者环境变量来提供程序执行的配置信息
- 可以提供基本的日志定义和输出机制,比如可以通过配置文件来配置日志系统,并将日志输出到控制台
- 还可以提供一些额外的编程接口,以保证循环任务的合理退出、资源的合理释放等等
- 设计一个ServiceHost的类型,它的主要任务就是托管一种后台服务,它包含服务的启动与停止的逻辑。因此,ServiceHost是IHostedService的一种实现
- 设计一个ServiceRunner的类型,它的主要任务是配置运行环境,并对ServiceHost进行注册。因此,ServiceRunner基本上就类似于ASP.NET Core中Startup类的职责,在里面可以进行各种配置和服务注册,为ServiceHost的执行提供环境
使用
这里介绍几种不同的应用场景下使用我们的服务宿主框架的方法,供大家参考。基本用法
下面的代码就是最简单的使用方式,可以看到,与上面的代码相比,我们已经可以使用日志来输出信息了,并且更重要的是,应用程序的配置信息都可以放在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的官方网站了解,这里就不详细介绍了。