6

使用 .NET 6 開發 Windows Service

 2 years ago
source link: https://blog.darkthread.net/blog/net6-windows-service/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client
使用 .NET 6 開發 Windows Service-黑暗執行緒

.NET Framework 時代寫 Windows Service 的標準做法是用 Visaul Studio 新增 Installer、再用 InstallUtil.exe 安裝。(參考:Windows Service 新增 Installer 功能並自動開啟防火牆設定 by 保哥)

而 .NET Framework 時代開發 Windows Service 專案最麻煩的一件事是其執行模式較特殊,沒法像一般 Console 程式用 Visual Studio F5 偵錯,開發起來蠻困擾的。我有個的解法是將核心邏輯拆成獨立 DLL,另寫兩個專案:Windows Service 跟 Console 引用它,平時用 Console 開發測試,OK 後再用 Windows Service 專案發行部署。這個做法讓開發偵測輕鬆不少,缺點是原本一個專案變成三個,徒增複雜度。

.NET Core 起新增了 Worker Service 專案類別,適合定期觸發排程、循序執行的作業佇列(Queue)... 等在背景長期執行的作業,而 Windows Service 也被歸類其中,而在 .NET Core 3.0 起 .NET Platform Extension 內建 UseWindowsService(https://docs.microsoft.com/en-us/dotnet/api/microsoft.extensions.hosting.windowsservicelifetimehostbuilderextensions.usewindowsservice) 方法,簡化開發 Windows Service 的複雜度,體驗上非常接近一般的 Console 應用程式。這篇會用一個無聊小範例 - 記錄 CPU% 來展示如何用 .NET 6 開發 Windows Service。

關於 Windows Service 開發教學,官方文件寫得相當完整(參考:Create a Windows Service using BackgroundService),是很不錯的入門教材。簡單歸納成以下步驟:\

  1. 用 new worker 開新專案
  2. dotnet add package Microsoft.Extensions.Hosting.WindowsServices
  3. csproj 改 <OutputType>exe</OutputType>
  4. 修改 Program.cs 加入 UseWindowsService 參考

Program.cs 如下,只多加了UseWindowsService() 加了指定 ServiceName:

using CpuLoadLogger;

IHost host = Host.CreateDefaultBuilder(args)
    .UseWindowsService(options =>
    {
        options.ServiceName = "CPU Load Logger";
    })
    .ConfigureServices(services =>
    {
        services.AddHostedService<Worker>();
    })
    .Build();

await host.RunAsync();

原本的 Worker.cs 長這樣:

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);
        }
    }
}

說明我改寫的重點:

  1. Worker 繼承 BackgroundService,預設只有一個 ExecuteAsync() 方法,它在服務啟動時會被 BackgroundService 呼叫,一般會在其中跑迴圈提供服務,直到 CancellationToken 傳來停止訊號。
  2. 在建構式新增 IConfiguration 參數,由 appsettings.json 讀取 LogPath 參數,若沒有 appsettings.json 或沒設定 LogPath,就存在 .exe 所在目錄。
  3. 我覆寫 public override async Task StartAsync(CancellationToken stoppingToken) 及 public override async Task StopAsync(CancellationToken stoppingToken) 在啟動或停止服務時加入自訂邏輯,記錄服務啟動及停止時間。記得一定要要呼叫 base.StartAsync() 及 base.StopAsync()。
  4. 當服務停止時,要怎麼停止執行中的程式? .NET 5 之後,Thread.Abort() 這種粗暴做法已被標為過式,建議的做法是傳入 CancellationToken,在執行過程持續檢查 CancellationToken.IsCancellationRequested 主動停止,或用 CancellationToken.ThrowIfCancellationRequested() 在收到停止訊號時抛出例外結束作業。

完整版 Worker.cs 如下:(註:程式碼未做優化,歡迎提供寫法建議)

using System.Diagnostics;

namespace CpuLoadLogger;

public class Worker : BackgroundService
{
    private readonly ILogger<Worker> _logger;
    private readonly string logPath;
    private StreamWriter cpuLogger = null!;
    public Worker(ILogger<Worker> logger, IConfiguration config)
    {
        _logger = logger;
        logPath = Path.Combine(
            config.GetValue<string>("LogPath") ??
            System.AppContext.BaseDirectory!,
            "cpu.log");
    }

    void Log(string message)
    {
        if (cpuLogger == null) return;
        cpuLogger.WriteLine($"{DateTime.Now:yyyy-MM-dd HH:mm:ss} {message}");
        cpuLogger.Flush();
    }

    // 服務啟動時
    public override async Task StartAsync(CancellationToken stoppingToken)
    {
        cpuLogger = new StreamWriter(logPath, true);
        _logger.LogInformation("Service started");
        Log("Service started");
        // 基底類別 BackgroundService 在 StartAsync() 呼叫 ExecuteAsync、
        // 在 StopAsync() 時呼叫 stoppingToken.Cancel() 優雅結束
        await base.StartAsync(stoppingToken);
    }

    int GetCpuLoad()
    {
        using (var p = new Process())
        {
            p.StartInfo.FileName = "wmic.exe";
            p.StartInfo.Arguments = "cpu get loadpercentage";
            p.StartInfo.UseShellExecute = false;
            p.StartInfo.RedirectStandardOutput = true;
            p.Start();
            int load = -1;
            var m = System.Text.RegularExpressions.Regex.Match(
                p.StandardOutput.ReadToEnd(), @"\d+");
            if (m.Success) load = int.Parse(m.Value);
            p.WaitForExit();
            return load;
        }
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            // 使用 ThreadPool 執行,避免讀取 CPU 百分比的耗用時間干擾 Task.Delay 間隔
            // https://docs.microsoft.com/en-us/dotnet/standard/threading/cancellation-in-managed-threads
            // 這裡用舊式 ThreadPool 寫法,亦可用 Task 取代 
            // 參考:https://docs.microsoft.com/en-us/dotnet/standard/parallel-programming/how-to-cancel-a-task-and-its-children
            ThreadPool.QueueUserWorkItem(
                (obj) =>
                {
                    try
                    {
                        var cancelToken = (CancellationToken)obj!;
                        if (!stoppingToken.IsCancellationRequested)
                        {
                            Log($"CPU: {GetCpuLoad()}%");
                            _logger.LogInformation($"Logging CPU load");
                        }
                    }
                    catch (Exception ex)
                    {
                        _logger.LogError(ex.ToString());
                        throw;
                    }
                }, stoppingToken);
            await Task.Delay(5000, stoppingToken);
        }
    }

    // 服務停止時
    public override async Task StopAsync(CancellationToken stoppingToken)
    {
        _logger.LogInformation("Service stopped");
        Log("Service stopped");
        cpuLogger.Dispose();
        cpuLogger = null!;
        await base.StopAsync(stoppingToken);
    }
}

.NET 6 靠 Microsoft.Extensions.Hosting.WindowsServices 讓 Worker Service 專案具備 Windows Service 介面,而 Worker Service 專案執行與測試與一般 Console 專案沒什麼不同,在 Visual Studio/VSCode 可以按 F5 Line-By-Line 偵錯, 從 _logger 輸出錯誤訊息到螢幕上,或者直接執行 .exe 進行測試,待開發測試完畢,再用 sc create 指令將 .exe 裝成 Windows Service 即可,非常方便。

按 F5 可偵錯,會顯示 _logger 輸出,也可設定中斷點:

或者也直接執行 .exe 啟動服務,按 Ctrl-C 停止服務,模擬掛載成服務的行為:

這種操作模式,就是我過去寫成元件再開 Windows Service 跟 Console 專案要做的事,而 .NET 6 直接內建了。

使用 dotnet publish -c Release -r win-x64 --no-self-contained -p:PublishSingleFile=true 編譯成單一 .exe 檔 參考:使用 dotnet 命令列工具發行 .NET 6 專案

sc create "CPU Load Loagger" binPath="D:\..."
sc start "CPU Load Logger"
sc stop "CPU Load Logger"

編譯成單一 exe,跑 sc 指令就變身為 Windows Service,簡潔有力,以下來個一鏡到底示範:

開發老人觀點:用 .NET 6 開發 Windows Service 的體驗很不錯,尤其能直接測試偵錯這點,省下不少力氣。而編譯成單一 exe 檔後 sc create 掛載就轉成 Windows Service 的部署程序,蠻方便的。唯一要適應的是忘掉簡單粗暴的 Thread.Abort(),學習用 CancellationToken 優雅結束程式當個文明人,呵。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK