.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 優雅結束程式當個文明人,呵。

Writing a simple Windows service with .NET 6.


Comments

# by kenlai

傳統的.net framework可以透過 topshelf 做到開發Windows Service時,用 Console 開發測試,並自帶安裝指令,非常好用且易上手。可以參考看看。 http://topshelf-project.com/

# by Jeffrey

to kenlai, 謝謝補充,長知識了。

# by Jy

請問黑大, Worker Service部署成Windows Service時, 如果Windows 10重新開機,會執行StartAsync。 但如果先關機,再開機,StartAsyn就不會執行, 想請教有沒有方法可以在關機後開機的狀況,執行StartAsync?

# by Jeffrey

to Jy, 先關機再開機,StartAsyn就不會執行 <= 依我理解應非預期的行為,會不會有什麼環境因素造成?建議寫個什麼都不做的空服務或換一台機器測試,看是否也能重現問題。

# by Jy

原來是Windows的快速啟動功能,關機時會將服務狀態寫入硬碟, 所以開機時服務就會回到關機前的狀態。

# by Jeffrey

to Jy, 感謝經驗分享。(已筆記)

# by Quintos

如果是在CI,CD场景下, 如果要在同一台 机器上 自动化 部署 同一个的app 多个实例的 windows service,有没有好的方案?。最常碰到的使用场景是需要自动化更新机器上 消息队列的 Consumer的实例

# by Jeffrey

to Quintos, 比較優美的解法是用一個 Windows Service 去服務多個 Queue,用一個 Thread 或多個 Thread 去消化一個 Message Queue。如果不想改程式,同一個 App 程式 Copy 成多個 Folder,給不同的設定檔,sc create 時給不同的名字,就可以同時安裝多個 Instance,應該符合你的需求。

# by Childlok

請問黑大 改成用 .NET 6 的這種 Windows Service 開發的服務,似乎就沒有辨法像以前用 ServiceBase 時 Overwrite OnPowerEvent / OnSessionChange 這類的事件來取得系統的待機,或使用者登入登出的事件,還是我遺漏了什麼嗎?

# by Jeffrey

to Childlok, 要改繼承 ServiceBase (在 .NET Platform Extensions 裡),參考: https://devblogs.microsoft.com/ifdef-windows/creating-a-windows-service-with-c-net5/#comment-54

# by Childlok

To Jeffrey 不太明白,是指BackgroundService換成ServiceBase嗎?但若是換回繼承 ServiceBase 那不就和以前一樣了?service.AddHostedService 也無法以更改基底(ServiceBase)後的類別加入不是?

# by Jeffrey

to Childlok,依我的理解,BackgroundService 雖然底層是靠 ServiceBase 實現 Windows 服務,但它沒有提供你要的事件,要嘛改寫一版客製 BackgroundService,要嘛放棄 BackgroundService 直接用 ServiceBase 實做,但這樣不能比照 BackgroundService 使用。 類似的比喻是 WebClient 底層是走 HttpWebRequest,如果 WebClient 沒提供你要的功能,可選擇改用 HttpWebRequest,但寫起來就不會像 WebClient 那麼簡便。

# by Bryan

@Childlok, 個人認為應該是要在你繼承 BackgroundService 或 IHostedService 的類別中去註冊有興趣的事件,像是 += OnSessionChange () 的用法之類的。 因為 BackgroundService 只實作了最基本的功能,不一定個每個 BackgroundService 都需要知道 OnPowerEvent / OnSessionChange 這類事件。 這應該也是 .Net Core 的設計理念所造成。 以上個人淺見。

# by Ho.Chun

想請問黑大, Console 專案和 Worker Service 專案有什麼差別嗎 ? 如果我將 Console 專案輸出的 .exe 掛在 Windows 排程上 感覺這樣是不是就是 Worker Service 了 ?

# by Jeffrey

to Ho.Chun, Woker Service 是 .NET Core 新增的專案類型,適合定期觸發排程、循序執行的作業佇列(Queue)... 等在背景長期執行的作業,而 Windows Service 只是其中一種應用形式,像 Woker Service 也可放在 ASP.NET Core 網站跑。Windows 排程通常是在排定時間執行指定程式,做完就結束,跟 Worker Service 長時間執行不結束不太一樣。

# by TOM

用了 UseWindowsService,如何加入跨域 builder.Services.AddCors(options => { options.AddPolicy(name: "myCors", builde => { builde.WithOrigins("*", "*", "*") .AllowAnyOrigin() .AllowAnyHeader() .AllowAnyMethod(); }); }); builder.Host.UseWindowsService(); var app = builder.Build(); app.UseCors("myCors");

# by Jeffrey

to TOM,我有點混淆,CORS 應該跟網站存取有關,怎麼會套用到 Windows Service 上?

# by Tom

@Jeffrey 就是终端设备,写了一个程序,调用本机扫描器,打印机, 给浏览器调用

# by Jeffrey

to Tom, 想在 Windows Service 裡跑 ASP.NET Core 可參考這篇:在 Windows 服務上裝載 ASP.NET Core https://learn.microsoft.com/zh-tw/aspnet/core/host-and-deploy/windows-service?view=aspnetcore-7.0&tabs=visual-studio

Post a comment