使用 Microsoft.Extensions.Hosting.WindowsServices 和 Topshelf.Extensions.Hosting 建立 Windows Service 應用程式

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

裡面就兩個檔案

  1. TopshelfLifetime
  2. IHostBuilder.RunAsTopshelfService

設定服務的功能在 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)

 

服務安裝後的結果如下:

範例位置

sample.dotblog/Host/Lab.WorkerService/ConsoleAppNetFx48 at master · yaochangyu/sample.dotblog (github.com)

若有謬誤,煩請告知,新手發帖請多包涵


Microsoft MVP Award 2010~2017 C# 第四季
Microsoft MVP Award 2018~2022 .NET

Image result for microsoft+mvp+logo