使用 .NET 6 開發 Windows Service
20 | 17,077 |
.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),是很不錯的入門教材。簡單歸納成以下步驟:\
- 用 new worker 開新專案
- dotnet add package Microsoft.Extensions.Hosting.WindowsServices
- csproj 改
<OutputType>exe</OutputType>
- 修改 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);
}
}
}
說明我改寫的重點:
- Worker 繼承 BackgroundService,預設只有一個 ExecuteAsync() 方法,它在服務啟動時會被 BackgroundService 呼叫,一般會在其中跑迴圈提供服務,直到 CancellationToken 傳來停止訊號。
- 在建構式新增 IConfiguration 參數,由 appsettings.json 讀取 LogPath 參數,若沒有 appsettings.json 或沒設定 LogPath,就存在 .exe 所在目錄。
- 我覆寫 public override async Task StartAsync(CancellationToken stoppingToken) 及 public override async Task StopAsync(CancellationToken stoppingToken) 在啟動或停止服務時加入自訂邏輯,記錄服務啟動及停止時間。記得一定要要呼叫 base.StartAsync() 及 base.StopAsync()。
- 當服務停止時,要怎麼停止執行中的程式? .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
# by 小黑
請教黑哥,像這類有固定頻率需要去執行的任務,該如何選擇要使用windows service 或是console application 搭配作業系統排程任務設定?