我們會將 .NET Generic Host 用在 ASP.NET Core 或 Worker Service (背景服務) 這類需要運行在長時間執行的應用程式中。不過,若只是僅執行一次的這種單純的 Console 主控台應用程式適合用 .NET Generic Host 來建立應用程式架構嗎?是的,當然適合,而且還有很多附加的好處。
建議閱讀新版文章: 從 .NET 7 開始就不鼓勵使用 .NET Generic Host 建立應用程式
何謂 Host
物件?
其實 Host
是一個來自 Microsoft.Extensions.Hosting 套件中的一個靜態類別,主要用來提供應用程式一個標準的 Hosting (裝載) 與 Startup (啟動) 基礎建設(infrastructures),原始碼位於 GitHub 的 dotnet/runtime Repo 下。
這裡我們最常看到的 Host
使用方式,是呼叫該類別下的 Host.CreateDefaultBuilder(args) 靜態方法,例如:
-
.NET 5.0 的 ASP.NET Core Web API 專案範本的 Program.cs
主程式內容
public class Program
{
public static void Main(string[] args)
{
CreateHostBuilder(args).Build().Run();
}
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
});
}
-
.NET 5.0 的 Worker 專案範本的 Program.cs
主程式內容
public class Program
{
public static void Main(string[] args)
{
CreateHostBuilder(args).Build().Run();
}
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureServices((hostContext, services) =>
{
services.AddHostedService<Worker>();
});
}
上述兩段程式碼片段,都有執行到 Host.CreateDefaultBuilder(args)
方法,我們可以從 原始碼 清楚的看出,這個方法幫我們初始化主控台應用程式所需的各種設定,其中包括:
- 基本組態設定 (Configuration)
- Host Configuration
- App Configuration
- 應用程式紀錄 (Logging)
- 自動讀入組態設定的
Logging
區段
- 加入 Console 與 Debug 紀錄提供者 (Logger Provider)
- 如果是 Windows 平台則還包含 EventLog 紀錄提供者
- 活動追蹤選項(ActivityTrackingOptions)包含
SpanId
, TraceId
與 ParentId
等資訊
- 基本 Dependency injection (DI) 設定
基本上,執行 Host.CreateDefaultBuilder(args)
方法會取得一個 IHostBuilder 物件,如果再呼叫 Build() 就會得到一個 IHost 的實體,最後再執行 Run() 或 RunAsync() 就可以開始執行 Host 應用程式,正式進入 Generic Host 所控管的執行生命週期。
注意:.NET Generic Host 從 .NET Core 3.1 開始有了一次小改版,跟之前 .NET Core 2.1 的版本稍微有點不同。而 .NET 5 的 .NET Generic Host 則跟 .NET Core 3.1 完全相同。
Worker 背景服務
我以 .NET 5.0 的 Worker 專案範本為例,如下是 Worker
類別的範例程式:
public class Worker : BackgroundService
{
private readonly ILogger<Worker> _logger;
public Worker(ILogger<Worker> logger)
{
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
_logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
await Task.Delay(1000, stoppingToken);
}
}
}
這裡的 ConfigureServices
主要用來設定 DI 服務註冊,而裡面的 services.AddHostedService<Worker>();
就是把 Worker
類別註冊為 Hosted Service,該類別繼承了 BackgroundService 抽象類別,而該抽象類別又實作了 IHostedService 介面。
雖然我們只需要在 Worker
實作 ExecuteAsync(CancellationToken stoppingToken)
方法,但事實上只是透過 BackgroundService
的 StartAsync(CancellationToken cancellationToken) 幫你呼叫 ExecuteAsync
而已。
直接閱讀 BackgroundService 的原始碼,可以讓你對 IHostedService 的用法更加清晰!
取得 .NET Generic Host 預設提供的 DI 服務
所有的 .NET Generic Host 應用程式,預設都可以透過 DI 注入以下服務:
關於 .NET Generic Host 優雅的關閉應用程式
使用 .NET Generic Host 有個非常棒的地方,就是他可以幫你處理優雅的關閉(graceful shutdown)應用程式,這個特性特別適用於容器化應用程式執行,當你的應用程式部署在 Docker 或 Kubernetes 環境下,當容器需要被關閉或重啟時,會向應用程式送出一個 SIGINT 或 SIGTERM 訊號,這就如同你對應用程式按下 Ctrl+C
中斷程式執行一樣。
這個部分你只要看 Microsoft.Extensions.Hosting.Internal.ConsoleLifetime 原始碼,就可以知道確切的實作方式!
建立含有 DI 能力的 Console 主控台應用程式
終於要進入重點了!當我們需要撰寫一份不用長時間執行的 Console 應用程式,但又希望能夠使用 .NET Core DI、Logging、Configuration 等常用功能,這時我們就可以利用 .NET Generic Host 既有提供的架構進行撰寫,不但程式碼更容易上手,整體程式碼結構也更漂亮。以下就是實作的完整步驟:
-
建立基礎 Console 專案
dotnet new console -n GenericHostConsole
cd GenericHostConsole
-
建立 Git 版控
dotnet tool update -g dotnet-ignore
dotnet ignore get -n VisualStudio
git init && git add . && git commit -m "Initial commit"
-
安裝 Microsoft.Extensions.Hosting 套件
dotnet add package Microsoft.Extensions.Hosting
-
修改 Program
類別
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace GenericHostConsole
{
public class Program
{
public static void Main(string[] args)
{
CreateHostBuilder(args).Build().Run();
}
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureServices((hostContext, services) =>
{
services.AddHostedService<App>();
});
}
}
-
新增 App
類別並實作 IHostedService
介面
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
namespace GenericHostConsole
{
public class App : IHostedService
{
public Task StartAsync(CancellationToken cancellationToken)
{
throw new System.NotImplementedException();
}
public Task StopAsync(CancellationToken cancellationToken)
{
throw new System.NotImplementedException();
}
}
}
-
加入建構式(Constructor)並注入 ILogger 與 IHostApplicationLifetime 服務
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace GenericHostConsole
{
public class App : IHostedService
{
private readonly ILogger<App> logger;
private readonly IHostApplicationLifetime appLifetime;
public App(ILogger<App> logger, IHostApplicationLifetime appLifetime)
{
this.logger = logger;
this.appLifetime = appLifetime;
}
public Task StartAsync(CancellationToken cancellationToken)
{
throw new System.NotImplementedException();
}
public Task StopAsync(CancellationToken cancellationToken)
{
throw new System.NotImplementedException();
}
}
}
-
完成基礎 Console 應用程式開發
這裡我不寫太多複雜的邏輯,本文主要用來示範如何利用 .NET Generic Host 的架構,做到不用長時間執行的 Console 應用程式。
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace GenericHostConsole
{
public class App : IHostedService
{
private readonly ILogger<App> logger;
private readonly IHostApplicationLifetime appLifetime;
public App(ILogger<App> logger, IHostApplicationLifetime appLifetime)
{
this.logger = logger;
this.appLifetime = appLifetime;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
logger.LogWarning("App running at: {time}", DateTimeOffset.Now);
await Task.Yield();
appLifetime.StopApplication();
}
public Task StopAsync(CancellationToken cancellationToken)
{
logger.LogWarning("App stopped at: {time}", DateTimeOffset.Now);
return Task.CompletedTask;
}
}
}
上述程式碼有幾個重點:
- 這裡的
StartAsync()
主要是程式的起點,而 StopAsync()
則是在程式結束時呼叫
- .NET Generic Host 會自動判斷 SIGTERM 訊號來結束 Host 執行
- 無論啟動或關閉都會傳入
CancellationToken cancellationToken
可以讓你優雅的結束 Task 執行
- 當程式執行完畢,可直接呼叫
appLifetime.StopApplication();
即可結束 Host 執行
-
調整紀錄等級 (LogLevel
)
如果我們透過 dotnet run
執行程式,你也會從 Console 看到以下 Log 訊息,這是因為 Host.CreateDefaultBuilder(args) 預設已經幫我們透過 AddConsole() 設定好了 ConsoleLoggerProvider,而預設的 LogLevel 被設定在 LogLevel.Information
等級以上,所以會顯示許多紀錄:
warn: GenericHostConsole.App[0]
Worker running at: 12/09/2020 00:17:28 +08:00
info: Microsoft.Hosting.Lifetime[0]
Application is shutting down...
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
Hosting environment: Production
info: Microsoft.Hosting.Lifetime[0]
Content root path: G:\Projects\GenericHostConsole
warn: GenericHostConsole.App[0]
Worker stopped at: 12/09/2020 00:17:28 +08:00
若要調整最小紀錄等級為 Warning
以上,可以調整 Program.cs
裡面的 CreateHostBuilder()
方法如下:
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureLogging(logging => {
logging.SetMinimumLevel(LogLevel.Warning);
})
.ConfigureServices((hostContext, services) =>
{
services.AddHostedService<App>();
});
也可以直接新增一個 appsettings.json
檔案,並設定以下內容:
{
"Logging": {
"LogLevel": {
"Default": "Warning"
}
}
}
warn: GenericHostConsole.App[0]
Worker running at: 12/09/2020 00:35:09 +08:00
warn: GenericHostConsole.App[0]
Worker stopped at: 12/09/2020 00:35:09 +08:00
-
改用 RunConsoleAsync() 啟動 Host 應用程式
事實上,你的 Program.Main()
除了以下這種方法啟動 Host 應用程式外:
public static void Main(string[] args)
{
CreateHostBuilder(args).Build().Run();
}
其實還有另一種啟動方式:
public static void Main(string[] args)
{
CreateHostBuilder(args).RunConsoleAsync();
}
這個 RunConsoleAsync()
如果你從原始碼去看,其實還蠻好理解的,其實它只是明確的使用了「預設」的 UseConsoleLifetime()
而已,但是程式的語意會更清楚一點,你也可以考慮用這個 API 來啟動 Console 應用程式:
public static Task RunConsoleAsync(this IHostBuilder hostBuilder, CancellationToken cancellationToken = default)
{
return hostBuilder.UseConsoleLifetime().Build().RunAsync(cancellationToken);
}
-
加入應用程式結束碼 (Exit Code)
撰寫 Console 應用程式如果要搭配批次檔或 Shell Script 執行,指定其「結束碼」是非常重要的!
以下是完整的實作範例:
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace GenericHostConsole
{
public class App : IHostedService
{
private int? _exitCode;
private readonly ILogger<App> logger;
private readonly IHostApplicationLifetime appLifetime;
public App(ILogger<App> logger, IHostApplicationLifetime appLifetime)
{
this.logger = logger;
this.appLifetime = appLifetime;
}
public Task StartAsync(CancellationToken cancellationToken)
{
try
{
logger.LogWarning("Worker running at: {time}", DateTimeOffset.Now);
_exitCode = 1;
}
catch (Exception ex)
{
logger.LogError(ex, "Unhandled exception!");
_exitCode = 1;
}
finally
{
appLifetime.StopApplication();
}
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
logger.LogWarning("Worker stopped at: {time}", DateTimeOffset.Now);
Environment.ExitCode = _exitCode.GetValueOrDefault(-1);
return Task.CompletedTask;
}
}
}
結語
你從這篇文章的範例程式中,應該可以發現從 .NET Core 開始,到現在最新的 .NET 6 都一樣,大量的非同步程式碼已經變成常態,如果你目前還對 C# 非同步沒有一個完整的概念,歡迎你來報名我在 2022-06-12(日) 與 2022-06-19(日) 的「C# 開發實戰:非同步程式開發技巧」課程,我將會在兩天的時間內,幫助你徹頭徹尾的全面理解 C# 非同步程式開發觀念與技巧! 👍
相關連結