很多時候我們都會有設計一個後臺服務的需求,比如,傳統的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(); }); 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 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(); await serviceRunner.RunAsync(args); } } |
程式碼執行效果如下:
合理終止無限迴圈的服務端任務
另一個使用場景,就是當ServiceHost啟動的時候,會啟動一個後臺任務,不停地執行一些處理邏輯,直到使用者按下CTRL+C,才會停止這個重覆執行的任務並正常終止程式。使用上面的服務宿主框架也很容易實現:
class MyService : ServiceHost { private readonly ILogger logger; private readonly CancellationTokenSource cancellationTokenSource = new CancellationTokenSource(); private readonly List tasks = new List(); public MyService(ILogger 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(); await serviceRunner.RunAsync(args); } } |
主要思路就是在MyService中定義一個CancellationTokenSource,在OnHostStopping的回呼函式中,呼叫Cancel方法觸發取消事件,然後在任務的執行體中判斷是否已經發起了“取消”請求。執行結果如下:
Serilog的整合與使用
我們還可以非常方便地在我們的服務宿主程式中使用Serilog,以實現強大的日誌功能,程式碼如下:
class MyService : ServiceHost { private readonly Microsoft.Extensions.Logging.ILogger logger; public MyService(ILogger 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 { 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以及本文介紹的框架,還有很多高階功能和特殊用法,有需要的讀者可以在本文留言,共同探討。