使用 .NET Generic Host 建立 Console 主控台應用程式 (.NET Core 3.1+)

我們會將 .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 既有提供的架構進行撰寫,不但程式碼更容易上手,整體程式碼結構也更漂亮。以下就是實作的完整步驟:

  1. 建立基礎 Console 專案

    dotnet new console -n GenericHostConsole
    cd GenericHostConsole
    
  2. 建立 Git 版控

    dotnet tool update -g dotnet-ignore
    dotnet ignore get -n VisualStudio
    git init && git add . && git commit -m "Initial commit"
    
  3. 安裝 Microsoft.Extensions.Hosting 套件

    dotnet add package Microsoft.Extensions.Hosting
    
  4. 修改 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>();
                    });
        }
    }
    
  5. 新增 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();
            }
        }
    }
    
  6. 加入建構式(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();
            }
        }
    }
    
  7. 完成基礎 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;
            }
        }
    }
    

    上述程式碼有幾個重點:

    1. 這裡的 StartAsync() 主要是程式的起點,而 StopAsync() 則是在程式結束時呼叫
    2. .NET Generic Host 會自動判斷 SIGTERM 訊號來結束 Host 執行
    3. 無論啟動或關閉都會傳入 CancellationToken cancellationToken 可以讓你優雅的結束 Task 執行
    4. 當程式執行完畢,可直接呼叫 appLifetime.StopApplication(); 即可結束 Host 執行
  8. 調整紀錄等級 (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
    
  9. 改用 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);
    }
    
  10. 加入應用程式結束碼 (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# 非同步程式開發觀念與技巧! 👍

相關連結

留言評論