The Will Will Web

記載著 Will 在網路世界的學習心得與技術分享

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

我們會將 .NET Generic Host 用在 ASP.NET Core 或 Worker Service (背景服務) 這類需要運行在長時間執行的應用程式中。不過,若只是僅執行一次的這種單純的 Console 主控台應用程式適合用 .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, TraceIdParentId 等資訊
  • 基本 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) 方法,但事實上只是透過 BackgroundServiceStartAsync(CancellationToken cancellationToken) 幫你呼叫 ExecuteAsync 而已。

直接閱讀 BackgroundService 的原始碼,可以讓你對 IHostedService 的用法更加清晰!

取得 .NET Generic Host 預設提供的 DI 服務

所有的 .NET Generic Host 應用程式,預設都可以透過 DI 注入以下服務:

關於 .NET Generic Host 優雅的關閉應用程式

使用 .NET Generic Host 有個非常棒的地方,就是他可以幫你處理優雅的關閉(graceful shutdown)應用程式,這個特性特別適用於容器化應用程式執行,當你的應用程式部署在 Docker 或 Kubernetes 環境下,當容器需要被關閉或重啟時,會向應用程式送出一個 SIGINTSIGTERM 訊號,這就如同你對應用程式按下 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# 非同步程式開發觀念與技巧! 👍

相關連結

留言評論