Microsoft.Extensions.Hosting.WindowsServices 實作 IHostLifetime,可以讓我們輕鬆地將 Console 應用程式掛載在 Windows Service,在實作的過程當中,發現控制服務不是那麼的友善。
於是想起了 Topshelf,便找到了 Topshelf.Extensions.Hosting,它除了可以使用原本的 Host 生命週期,DI Container 注入方式,還可以享有 Topshelf 自我管理 Windows Service 的功能。
開發環境
- Rider 2021.3.4
- Windows 10
- .Net Fx 4.8 via 新版專案範本 .NET Project SDKs
- Microsoft.Extensions.Hosting 5.0.0
Microsoft.Extensions.Hosting 適用於 .NET Fx 4.6.1 以上
新增 Worker Service 專案
通過 IDE 的Worker Service 範本建立專案,名為 ConsoleAppNetFx48
修改平台目標 net48
設定二進位檔輸出位置,為了讓自動化建置輕鬆一點,我習慣讓所有的 build 使用相同的輸出位置,打開專案檔在 Rider IDE 對著專案按 F4,如果你使用 VS IDE,可以到專案目錄直接用記事本或 Notepad++ 開啟專案檔 *.csproj
在專案檔 .NET Project SDKs 設定 OutDir、DocumentationFile 屬性
<PropertyGroup>
<TargetFramework>net48</TargetFramework>
<OutDir>bin</OutDir>
<DocumentationFile>bin\ConsoleAppNetFx48.xml</DocumentationFile>
</PropertyGroup>
完整內容如下,範本已經幫我們安裝好 Microsoft.Extensions.Hosting
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<TargetFramework>net48</TargetFramework>
<OutDir>bin</OutDir>
<DocumentationFile>bin\ConsoleAppNetFx48.xml</DocumentationFile>
<UserSecretsId>dotnet-ConsoleAppNetFx48-525DDA0C-18EF-4AE3-A405-A9653AA2D910</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="5.0.0" />
</ItemGroup>
</Project>
接下來看看範本所產生 Worker.cs / Program.cs 這兩個檔案
Worker 實作 BackgroundService,ExecuteAsync 是定期執行的工作,每秒輸出到 log,預設已經使用 Console 輸出,不清楚原因的話請參考 如何使用組態 Microsoft.Extensions.Configuration | 余小章 @ 大內殿堂 - 點部落 (dotblogs.com.tw)
public class Worker : BackgroundService
{
private readonly ILogger<Worker> _logger;
public Worker(ILogger<Worker> logger)
{
this._logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
this._logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
await Task.Delay(1000, stoppingToken);
}
}
}
實例化 HostBuilder 並註冊 Worker 到 DI Container
public class Program
{
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureServices((hostContext, services) => { services.AddHostedService<Worker>(); });
public static void Main(string[] args) => CreateHostBuilder(args).Build().Run();
}
按 F5,執行,會看到定期執行的 Work,每秒輸出到 Console
掛載 Windows Service
Microsoft.Extensions.Hosting.WindowsServices
安裝套件
Install-Package Microsoft.Extensions.Hosting.WindowsServices -Version 5.0.1
WindowsServiceLifetime 實作 IHostLifetime,骨子裡面依賴 System.ServiceProcess,主要用來掛載在 Windows Service,最後再調用 IHostBuilder.UseWindowsService 擴充方法
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.UseWindowsService()
.ConfigureServices((hostContext, services) => { services.AddHostedService<Worker>(); });
通過 SC.exe 管理服務
最後,再透過 SC.exe 進行服務控制,這需要管理員權限,
- 安裝:sc create ConsoleAppNetFx48 BinPath=E:\src\sample.dotblog\Host\Lab.WorkerService\ConsoleAppNetFx48\bin\ConsoleAppNetFx48.exe
- 啟動:sc start ConsoleAppNetFx48
- 停止:sc stop ConsoleAppNetFx48
- 刪除:sc delete ConsoleAppNetFx48
使用管理員權限開啟 cmder
我把這些功能寫成以下,主要是為了自動化,將各個動作拆開
- SafeCreateService.bat
- SafeStartService.bat
- SafeStopService.bat
- SafeDeleteService.bat
然後再用以下腳本呼叫,這腳本可以是 CI Pipeline 的某一個步驟
@echo off
set batchFolder=%~dp0
set serviceName=ConsoleAppNetFx48
set serviceDisplayName=ConsoleAppNetFx48
set serviceDescription="測試"
set serviceLaunchPath=%batchFolder%bin\ConsoleAppNetFx48.exe
set serviceLogonId=.\setup
set serviceLogonPassword=password
::set serverName=\\Computer Name
set serverName=
Call SafeStopService %serviceName% %serverName%
Call SafeDeleteService %serviceName% %serverName%
Call SafeCreateService %serviceName% %serviceDisplayName% %serviceDescription% %serviceLaunchPath% %serviceLogonId% %serviceLogonPassword% %serverName%
Call SafeStartService %serviceName% %serverName%
SafeCreateService.bat
@echo off
IF [%1]==[] GOTO usage
IF [%2]==[] GOTO usage
IF [%3]==[] GOTO usage
IF [%4]==[] GOTO usage
IF [%5]==[] GOTO usage
IF NOT "%1"=="" SET serviceName=%1
IF NOT "%2"=="" SET serviceDisplayName=%2
IF NOT "%3"=="" SET serviceDescription=%3
IF NOT "%4"=="" SET serviceLaunchPath=%4
IF NOT "%5"=="" SET serviceLogonId=%5
IF NOT "%6"=="" SET serviceLogonPassword=%6
IF NOT "%7"=="" SET serverName=%7
SC %serverName% query %serviceName%
IF errorlevel 1060 GOTO ServiceNotFound
IF errorlevel 1722 GOTO SystemOffline
IF errorlevel 1001 GOTO DeletingServiceDelay
:ResolveInitialState
SC %serverName% query %serviceName% | FIND "STATE" | FIND "RUNNING"
IF errorlevel 0 IF NOT errorlevel 1 GOTO StopService
SC %serverName% query %serviceName% | FIND "STATE" | FIND "STOPPED"
IF errorlevel 0 IF NOT errorlevel 1 GOTO StoppedService
SC %serverName% query %serviceName% | FIND "STATE" | FIND "PAUSED"
IF errorlevel 0 IF NOT errorlevel 1 GOTO SystemOffline
echo Service State is changing, waiting for service to resolve its state before making changes
sc %serverName% query %serviceName% | Find "STATE"
ping -n 2 127.0.0.1 > NUL
GOTO ResolveInitialState
:StopService
echo Stopping %serviceName% on %serverName%
sc %serverName% stop %serviceName%
GOTO StoppingService
:StoppingServiceDelay
echo Waiting for %serviceName% to stop
ping -n 2 127.0.0.1 > NUL
:StoppingService
SC %serverName% query %serviceName% | FIND "STATE" | FIND "STOPPED"
IF errorlevel 1 GOTO StoppingServiceDelay
:StoppedService
echo %serviceName% on %serverName% is stopped
GOTO DeleteService
:DeleteService
echo Deleting %serviceName% on %serverName%
SC %serverName% delete %serviceName%
:DeletingServiceDelay
echo Waiting for %serviceName% to get deleted
ping -n 2 127.0.0.1 > NUL
:DeletingService
SC %serverName% query %serviceName%
IF NOT errorlevel 1060 GOTO DeletingServiceDelay
:DeletedService
echo %serviceName% on %serverName% is deleted
GOTO CreateService
:SystemOffline
echo Server %serverName% is not accessible or is offline
GOTO End
:ServiceNotFound
echo Service %serviceName% is not installed on Server %serverName%
GOTO CreateService
:CreateService
echo Creating %serviceName% on %serverName%
::SC %serverName% create %serviceName% binpath= "%serviceLaunchPath%" displayname= "THS MSMQ %serviceDisplayName% Agent"
SC %serverName% create %serviceName% binpath= "%serviceLaunchPath%"
SC %serverName% config %serviceName% displayname= "%serviceDisplayName%"
SC %serverName% config %serviceName% obj= %serviceLogonId% password= "%serviceLogonPassword%"
SC %serverName% config %serviceName% start= auto
SC %serverName% description %serviceName% "%serviceDescription%"
::SC "%serverName%" config "%serviceName%" type= share start= auto
:CreatingServiceDelay
echo Waiting for %serviceName% to get created
ping -n 2 127.0.0.1 > NUL
:CreatingService
::SC %serverName% query %serviceName% >NUL
SC %serverName% query %serviceName% | FIND "STATE" | FIND "STOPPED"
IF errorlevel 1 GOTO CreatingServiceDelay
:CreatedService
echo %serviceName% on %serverName% is created
GOTO End
:usage
echo Will cause a local/remote service to START (if not already started).
echo This script will waiting for the service to enter the started state if necessary.
echo.
echo %0 [service name] [system name]
echo Example: %0 MyService server1
echo Example: %0 MyService (for local PC)
echo.
::GOTO:eof
:End
SafeStartService.bat
@echo off
IF [%1]==[] GOTO usage
IF NOT "%1"=="" SET serviceName=%1
IF NOT "%2"=="" SET serverName=%2
SC %serverName% query %serviceName%
IF errorlevel 1060 GOTO ServiceNotFound
IF errorlevel 1722 GOTO SystemOffline
:ResolveInitialState
SC %serverName% query %serviceName% | FIND "STATE" | FIND "STOPPED"
IF errorlevel 0 IF NOT errorlevel 1 GOTO StartService
SC %serverName% query %serviceName% | FIND "STATE" | FIND "RUNNING"
IF errorlevel 0 IF NOT errorlevel 1 GOTO StartedService
SC %serverName% query %serviceName% | FIND "STATE" | FIND "PAUSED"
IF errorlevel 0 IF NOT errorlevel 1 GOTO SystemOffline
echo Service State is changing, waiting for service to resolve its state before making changes
SC %serverName% query %serviceName% | Find "STATE" >NUL
ping -n 2 127.0.0.1 > NUL
GOTO ResolveInitialState
:StartService
echo Starting %serviceName% on %serverName%
SC %serverName% start %serviceName%
GOTO StartingService
:StartingServiceDelay
echo Waiting for %serviceName% to start
ping -n 2 127.0.0.1 > NUL
:StartingService
SC %serverName% query %serviceName% | FIND "STATE" | FIND "RUNNING"
IF errorlevel 1 GOTO StartingServiceDelay
:StartedService
echo %serviceName% on %serverName% is started
GOTO End
:SystemOffline
echo Server %serverName% is not accessible or is offline
GOTO End
:ServiceNotFound
echo Service %serviceName% is not installed on Server %serverName%
::exit /b 0
GOTO End
:usage
echo Will cause a local/remote service to START (if not already started).
echo This script will waiting for the service to enter the started state if necessary.
echo.
echo %0 [service name] [system name]
echo Example: %0 MyService server1
echo Example: %0 MyService (for local PC)
echo.
::GOTO:eof
:End
SafeStopService.bat
@echo off
IF [%1]==[] GOTO usage
IF NOT "%1"=="" SET serviceName=%1
IF NOT "%2"=="" SET serverName=%2
SC %serverName% query %serviceName%
IF errorlevel 1060 GOTO ServiceNotFound
IF errorlevel 1722 GOTO SystemOffline
:ResolveInitialState
SC %serverName% query %serviceName% | FIND "STATE" | FIND "RUNNING"
IF errorlevel 0 IF NOT errorlevel 1 GOTO StopService
SC %serverName% query %serviceName% | FIND "STATE" | FIND "STOPPED"
IF errorlevel 0 IF NOT errorlevel 1 GOTO StoppedService
SC %serverName% query %serviceName% | FIND "STATE" | FIND "PAUSED"
IF errorlevel 0 IF NOT errorlevel 1 GOTO SystemOffline
echo Service State is changing, waiting for service to resolve its state before making changes
SC %serverName% query %serviceName% | Find "STATE"
ping -n 2 127.0.0.1 > NUL
GOTO ResolveInitialState
:StopService
echo Stopping %serviceName% on %serverName%
SC %serverName% stop %serviceName%
GOTO StoppingService
:StoppingServiceDelay
echo Waiting for %serviceName% to stop
ping -n 2 127.0.0.1 > NUL
:StoppingService
SC %serverName% query %serviceName% | FIND "STATE" | FIND "STOPPED"
IF errorlevel 1 GOTO StoppingServiceDelay
:StoppedService
echo %serviceName% on %serverName% is stopped
GOTO End
:SystemOffline
echo Server %serverName% is not accessible or is offline
GOTO End
:ServiceNotFound
echo Service %serviceName% is not installed on Server %serverName%
::exit /b 0
GOTO End
:usage
echo Will cause a local/remote service to STOP (if not already stopped).
echo This script will waiting for the service to enter the stopped state if necessary.
echo.
echo %0 [service name] [system name] {reason}
echo Example: %0 MyService server1 {reason}
echo Example: %0 MyService (for local PC, DO NOT specify reason)
echo.
echo For reason codes, run "sc stop"
::GOTO:eof
:End
SafeDeleteService.bat
@echo off
IF [%1]==[] GOTO usage
IF NOT "%1"=="" SET serviceName=%1
IF NOT "%2"=="" SET serverName=%2
SC %serverName% query %serviceName%
IF errorlevel 1060 GOTO ServiceNotFound
IF errorlevel 1722 GOTO SystemOffline
IF errorlevel 1001 GOTO DeletingServiceDelay
:ResolveInitialState
SC %serverName% query %serviceName% | FIND "STATE" | FIND "RUNNING"
IF errorlevel 0 IF NOT errorlevel 1 GOTO StopService
SC %serverName% query %serviceName% | FIND "STATE" | FIND "STOPPED"
IF errorlevel 0 IF NOT errorlevel 1 GOTO StoppedService
SC %serverName% query %serviceName% | FIND "STATE" | FIND "PAUSED"
IF errorlevel 0 IF NOT errorlevel 1 GOTO SystemOffline
echo Service State is changing, waiting for service to resolve its state before making changes
SC %serverName% query %serviceName% | Find "STATE"
ping -n 2 127.0.0.1 > NUL
GOTO ResolveInitialState
:StopService
echo Stopping %serviceName% on %serverName%
SC %serverName% stop %serviceName%
GOTO StoppingService
:StoppingServiceDelay
echo Waiting for %serviceName% to stop
ping -n 2 127.0.0.1 > NUL
:StoppingService
SC %serverName% query %serviceName% | FIND "STATE" | FIND "STOPPED"
IF errorlevel 1 GOTO StoppingServiceDelay
:StoppedService
echo %serviceName% on %serverName% is stopped
GOTO DeleteService
:DeleteService
SC %serverName% delete %serviceName%
:DeletingServiceDelay
echo Waiting for %serviceName% to get deleted
ping -n 2 127.0.0.1 > NUL
:DeletingService
SC %serverName% query %serviceName%
IF NOT errorlevel 1060 GOTO DeletingServiceDelay
:DeletedService
echo %serviceName% on %serverName% is deleted
GOTO End
:SystemOffline
echo Server %serverName% is not accessible or is offline
GOTO End
:ServiceNotFound
echo Service %serviceName% is not installed on Server %serverName%
::exit /b 0
GOTO End
:usage
echo Will cause a local/remote service to START (if not already started).
echo This script will waiting for the service to enter the started state if necessary.
echo.
echo %0 [service name] [system name]
echo Example: %0 MyService server1
echo Example: %0 MyService (for local PC)
echo.
:End
安裝還是需要自己處理,跟以往的 Windows Service 專案一樣沒有很方便,為解決此問題,我發現了 Topshelf.Extensions.Hosting
參考:[TFS 2017] 實作 Build vNext 自動部署 Windows Service | 余小章 @ 大內殿堂 - 點部落 (dotblogs.com.tw)
Topshelf.Extensions.Hosting
想到 Windows Service 就不能忘記 Topshelf ,不熟悉 Topshelf 的話參考:[Topshelf ] 使用 Topshelf 取代 Windows Service 專案 | 余小章 @ 大內殿堂 - 點部落 (dotblogs.com.tw)
只要簡單的設定就能讓你的 Console App 自我掛載到 Windows Service
internal class Program
{
private static void Main(string[] args)
{
HostFactory.Run(x =>
{
x.Service<DoThing>(s =>
{
s.ConstructUsing(name => new DoThing());
s.WhenStarted(tc => tc.Start());
s.WhenStopped(tc => tc.Stop());
});
x.RunAsLocalSystem();
var assemblyName = Assembly.GetEntryAssembly().GetName().Name;
x.SetDescription("Sample Topshelf Host");
x.SetDisplayName(assemblyName);
x.SetServiceName(assemblyName);
});
}
}
有人寫了一個針對 Topshelf 寫了一個 Host 的擴充套件
Install-Package Topshelf.Extensions.Hosting -Version 0.4.0
裡面就兩個檔案
設定服務的功能在 IHostBuilder.RunAsTopshelfService,骨子裡面調用 HostFactory.Run,這會立即啟動程式並阻斷主執行緒,另外,管理服務的行為就交由它處理了
用不到 WindowsServiceLifetime 了 ,註解 UseWindowsService。
代碼如下:
public class Program
{
private static void Main(string[] args)
{
var hostBuilder = CreateHostBuilder(args);
var exitCode =
hostBuilder.RunAsTopshelfService(config =>
{
var assemblyName = Assembly.GetEntryAssembly().GetName().Name;
config.SetServiceName(assemblyName);
config.SetDisplayName(assemblyName);
config.SetDescription("Runs a generic host as a Topshelf service.");
config.RunAsPrompt();
});
Console.WriteLine($"服務控制狀態:{exitCode}");
// hostBuilder.Build().Run();
}
public static IHostBuilder CreateHostBuilder(string[] args)
{
return Host.CreateDefaultBuilder(args)
// .UseWindowsService()
.ConfigureServices((hostContext, services) => { services.AddHostedService<Worker>(); });
}
}
整個應用程式的生命週期還是跟 Host 一樣,有 IConfiguration、DI Container,若不熟 Host 可以參考:如何使用 .NET Generic Host for Microsoft.Extensions.Hosting | 余小章 @ 大內殿堂 - 點部落 (dotblogs.com.tw)
按 F5,執行結果如下圖
自我管理服務
應用程式通過 Topshelf 之後就自帶服務的管理
安裝:ConsoleAppNetFx48 install
啟動:ConsoleAppNetFx48 start
停止:ConsoleAppNetFx48 stop
刪除:ConsoleAppNetFx48 uninstall
更多設定控制請參考:Topshelf Configuration — Topshelf 3.0 documentation
老實說,這比起 SC.exe 好用許多。
另外,在實作的 Topshelf.Extensions.Hosting 過程當中,發現還有 Topshelf.Extensions 開頭的套件順便也研究
Topshelf Host 注入 Microsoft.Extensions.Logging
原本的Topshelf Host Log 並沒有使用 Microsoft.Extensions.Logging
現在可以改用 Topshelf.Extensions.Logging
Install-Package Topshelf.Extensions.Logging -Version 4.3.0
通過 HostConfigurator.UseLoggingExtensions,就可以換掉(注入) Topshelf 原本的 Log,這個目前沒有文件,根據 API 的描述得知
private static void Main(string[] args)
{
var hostBuilder = CreateHostBuilder(args);
var exitCode =
hostBuilder.RunAsTopshelfService(config =>
{
...
config.UseLoggingExtensions(LoggerFactory.Create(builder =>
{
builder.AddConsole();
}));
});
Console.WriteLine($"服務控制狀態:{exitCode}");
}
對於 Microsoft.Extensions.Logging 不熟的可以參考:
通過標準化的 Microsoft.Extensions.Logging 實現日誌紀錄 | 余小章 @ 大內殿堂 - 點部落 (dotblogs.com.tw)
執行結果如下圖:
Topshelf Host 注入 Microsoft.Extensions.Configuration
除了用 Hard Code 設定 Topshelf Host 之外,還能透過組態檔設定 Host
Install-Package Topshelf.Extensions.Configuration -Version 4.3.0
在新增 appsettings.json 新增 Topshelf 節點,節點內容並沒有文件,翻了一下他的測試案例轉換出來的,我只轉換我需要的設定
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"Topshelf": {
"ServiceName": "ConsoleAppNetFx48",
"DisplayName": "ConsoleAppNetFx48",
"Description":"Runs a generic host as a Topshelf service.",
"Instance":"1",
"Account":{
"Username":".\\setup",
"Password":"password"
},
"StopTimeout":"60"
}
}
實例化
private static void Main(string[] args)
{
var hostBuilder = CreateHostBuilder(args);
var exitCode =
hostBuilder.RunAsTopshelfService(config =>
{
....
var configRoot = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json")
.Build();
var topshelfSection = configRoot.GetSection("Topshelf");
config.ApplyConfiguration(topshelfSection);
});
Console.WriteLine($"服務控制狀態:{exitCode}");
}
對於 Microsoft.Extensions.Configuration 不熟的可以參考:如何使用組態 Microsoft.Extensions.Configuration | 余小章 @ 大內殿堂 - 點部落 (dotblogs.com.tw)
服務安裝後的結果如下:
範例位置
若有謬誤,煩請告知,新手發帖請多包涵
Microsoft MVP Award 2010~2017 C# 第四季
Microsoft MVP Award 2018~2022 .NET