1. 程式人生 > 其它 >.NET Worker Service 作為 Windows 服務執行及優雅退出改進

.NET Worker Service 作為 Windows 服務執行及優雅退出改進

上一篇文章我們瞭解了如何為 Worker Service 新增 Serilog 日誌記錄,今天我接著介紹一下如何將 Worker Service 作為 Windows 服務執行。

我曾經在前面一篇文章的總結中提到過可以使用 sc.exe 實用工具將 Worker Service 安裝為 Windows 服務執行,本文中我就來具體闡述一下如何實現它。

SC 是什麼?

sc.exe 是包含於 Windows SDK 的,可用於控制服務的命令列實用程式,它的命令對應於服務控制管理器(SCM)[1] 提供的函式。

服務控制管理器(SCM) 是 Windows NT 系列作業系統中的一個特殊程序,它在作業系統啟動時由 wininit 程序啟動,用於啟動和停止 Windows 程序(包括裝置驅動程式和啟動程式)。SCM 的主要功能是在系統啟動時啟動所有必需的服務,它類似於類 Unix 系統上的 init 程序(或者現代 Linux 發行版上使用的較新的 systemd init 系統),用於啟動各種系統守護程序[2]

。SCM 是一個遠端過程呼叫(RPC)服務,服務配置和服務控制程式可以借它來控制遠端計算機上的服務。

開啟 Windows 命令提示符視窗,輸入並執行 sc 命令,您便可以看到 sc.exe 實用工具的幫助資訊:

> sc

描述:
        SC 是用來與服務控制管理器和服務進行通訊
        的命令列程式。
用法:
        sc <server> [command] [service name] <option1> <option2>...


        <server> 選項的格式為 "\\ServerName"
        可通過鍵入以下命令獲取有關命令的更多幫助: "sc [command]"
        命令:
          query-----------查詢服務的狀態,
                          或列舉服務型別的狀態。
          queryex---------查詢服務的擴充套件狀態,
                          或列舉服務型別的狀態。
          start-----------啟動服務。
          pause-----------向服務傳送 PAUSE 控制請求。
          interrogate-----向服務傳送 INTERROGATE 控制請求。
          continue--------向服務傳送 CONTINUE 控制請求。
          stop------------向服務傳送 STOP 請求。
          config----------更改服務的配置(永久)。
          description-----更改服務的描述。
          failure---------更改失敗時服務執行的操作。
          failureflag-----更改服務的失敗操作標誌。
          sidtype---------更改服務的服務 SID 型別。
          privs-----------更改服務的所需特權。
          managedaccount--更改服務以將服務帳戶密碼
                          標記為由 LSA 管理。
          qc--------------查詢服務的配置資訊。
          qdescription----查詢服務的描述。
          qfailure--------查詢失敗時服務執行的操作。
          qfailureflag----查詢服務的失敗操作標誌。
          qsidtype--------查詢服務的服務 SID 型別。
          qprivs----------查詢服務的所需特權。
          qtriggerinfo----查詢服務的觸發器引數。
          qpreferrednode--查詢服務的首選 NUMA 節點。
          qmanagedaccount-查詢服務是否將帳戶
                          與 LSA 管理的密碼結合使用。
          qprotection-----查詢服務的程序保護級別。
          quserservice----查詢使用者服務模板的本地例項。
          delete ----------(從登錄檔中)刪除服務。
          create----------建立服務(並將其新增到登錄檔中)。
          control---------向服務傳送控制。
          sdshow----------顯示服務的安全描述符。
          sdset-----------設定服務的安全描述符。
          showsid---------顯示與任意名稱對應的服務 SID 字串。
          triggerinfo-----配置服務的觸發器引數。
          preferrednode---設定服務的首選 NUMA 節點。
          GetDisplayName--獲取服務的 DisplayName。
          GetKeyName------獲取服務的 ServiceKeyName。
          EnumDepend------列舉服務依賴關係。
...

您可以從幫助資訊中看到 sc 實用工具支援的所有命令集及其介紹。我們在本文中要用到的命令有:

  • create----------建立服務(並將其新增到登錄檔中)
  • description-----更改服務的描述。
  • start-----------啟動服務。
  • stop------------向服務傳送 STOP 請求。
  • delete ----------(從登錄檔中)刪除服務。

建立專案併發布

下載 Worker Service 原始碼

我將基於上一篇文章中的 Worker Service 原始碼[3]來修改,如果您安裝有 git,可以用下面的命令獲取它:

git clone [email protected]:ITTranslate/WorkerServiceWithSerilog.git

然後,使用 Visual Studio Code 開啟此專案,執行一下,以確保一切正常:

dotnet build
dotnet run

新增 Windows Services 依賴

為了作為 Windows 服務執行,我們需要我們的 Worker 監聽來自 ServiceBase 的啟動和停止訊號,ServiceBase 是將 Windows 服務系統公開給 .NET 應用程式的 .NET 型別。為此,我們需要新增 Microsoft.Extensions.Hosting.WindowsServices NuGet 包:

dotnet add package Microsoft.Extensions.Hosting.WindowsServices

然後修改 Program.cs 中的 CreateHostBuilder 方法,新增 UseWindowsService 方法呼叫:

public static IHostBuilder CreateHostBuilder(string[] args) =>
    Host.CreateDefaultBuilder(args)
        .UseWindowsService() // Sets the host lifetime to WindowsServiceLifetime...
        .ConfigureServices((hostContext, services) =>
        {
            services.AddHostedService<Worker>();
        })
        .UseSerilog(); //將 Serilog 設定為日誌提供程式

然後,執行一下構建命令,確保一切正常:

dotnet build

不出意外,您會看到 已成功生成 的提示。

釋出程式

執行 dotnet publish 命令將應用程式及其依賴項釋出到資料夾(我的作業系統是 win10 x64 系統)[4]

dotnet publish -c Release -r win-x64 -o c:\test\workerpub

命令執行完成後,您會在 C:\test\workerpub 資料夾中看到可執行程式及其所有依賴項。

建立並執行服務

首先,需要特別注意的是:當我們使用 sc.exe 實用工具管理服務時,必須以管理員身份執行 Windows 命令提示符,否則會執行失敗。

安裝服務

安裝服務我們需要用到建立服務命令 —— sc create

以管理員身份開啟 Windows 命令提示符視窗,輸入並執行 sc create 命令,可以看到此命令的的幫助資訊:

> sc create

描述:
        在登錄檔和服務資料庫中建立服務項。
用法:
        sc <server> create [service name] [binPath= ] <option1> <option2>...

選項:
注意: 選項名稱包括等號。
      等號和值之間需要一個空格。
 type= <own|share|interact|kernel|filesys|rec|userown|usershare>
       (預設 = own)
 start= <boot|system|auto|demand|disabled|delayed-auto>
       (預設 = demand)
 error= <normal|severe|critical|ignore>
       (預設 = normal)
 binPath= <.exe 檔案的 BinaryPathName>
 group= <LoadOrderGroup>
 tag= <yes|no>
 depend= <依存關係(以 / (斜槓)分隔)>
 obj= <AccountName|ObjectName>
       (預設= LocalSystem)
 DisplayName= <顯示名稱>
 password= <密碼>

命令 sc create 的引數說明[5]

  • server:指定服務所在的遠端伺服器的名稱。名稱必須使用通用命名約定(UNC)格式 (例如,\myserver) 。若要在本地執行 SC.exe,請不要使用此引數。
  • service name:指定 getkeyname 操作返回的服務名稱。
  • binPath=:指定服務二進位制檔案的路徑。 binPath= 沒有預設值,必須提供此字串。
  • displayname= "顯示名稱":指定一個友好名稱,用於標識使用者介面程式中的服務。
  • start= {boot|system|auto|demand|disabled|delayed-auto}:指定服務的啟動型別。選項包括:
    • boot - 指定由啟動載入程式載入的裝置驅動程式。
    • system - 指定在核心初始化過程中啟動的裝置驅動程式。
    • auto - 指定一項服務,該服務在計算機每次重新啟動時自動啟動並執行(即使沒有人登入到計算機)。
    • demand - 指定必須手動啟動的服務。如果未指定 start= ,則此值為預設值。
    • disabled - 指定無法啟動的服務。若要啟動已禁用的服務,請將啟動型別更改為其他某個值。
    • delayed-auto - 指定一項服務,該服務將在啟動其他自動服務之後的短時間自動啟動。

注意事項:
1、每個命令列選項 (引數) 必須包含等號作為選項名稱的一部分。
2、選項與其值之間必須有一個空格(例如,type= own),如果遺漏了空格,操作將失敗。

瞭解了 sc create 命令的用法,不難得出此處我們所需要的命令如下:

sc create MyService binPath= "C:\test\workerpub\MyService.exe" start= auto displayname= "技術譯站的測試服務"

執行以上命令,輸出以下結果:

[SC] CreateService 成功

執行 services.msc 命令開啟本地服務列表,可以看到我們的服務已經安裝好了,服務名稱顯示為 技術譯站的測試服務。它沒有描述,處於已停止狀態。

設定服務的描述

輸入並執行 sc description 命令,可以看到此命令的的幫助資訊:

> sc description
描述:
        設定服務的描述字串。
用法:
        sc <server> description [service name] [description]

執行以下命令給該服務新增描述資訊:

sc description MyService "這是一個由 Worker Service 實現的測試服務。"

輸出結果:

[SC] ChangeServiceConfig2 成功

執行成功以後,按 F5 重新整理服務列表,您將看到服務描述已經更新了。

啟動服務

輸入並執行 sc start 命令,可以看到此命令的的幫助資訊:

> sc start

描述:
        啟動服務執行。
用法:
        sc <server> start [service name] <arg1> <arg2> ...

輸入以下命令啟動服務:

sc start MyService

輸出結果:

[SC] StartService 失敗 1053:

服務沒有及時響應啟動或控制請求。

啟動失敗了,為什麼呢?檢視一下 Windows 事件檢視器 --> 應用程式,顯示的錯誤原因大致如下:

The process was terminated due to an unhandled exception.
Exception Info: System.IO.FileNotFoundException: The configuration file 'appsettings.json' was not found and is not optional. 
The physical path is 'C:\WINDOWS\system32\appsettings.json'.

回頭看一下 Program.cs 檔案,在 Main 方法中我們為配置設定的基路徑是 Directory.GetCurrentDirectory()。但是作為 Windows Service 執行時,預設的當前工作目錄是 C:\WINDOWS\system32,所以導致了這樣的錯誤。為了解決這一問題,我們需要在設定配置的基路徑前新增一行 Directory.SetCurrentDirectory(AppContext.BaseDirectory),程式碼如下:

// 作為 Windows Service 執行時,預設的當前工作目錄是 C:\WINDOWS\system32,會導致找不到配置檔案,
// 所以需要新增下面一行,指定當前工作目錄為應用程式所在的實際目錄。
Directory.SetCurrentDirectory(AppContext.BaseDirectory);

var configuration = new ConfigurationBuilder()
    .SetBasePath(Directory.GetCurrentDirectory())
    .AddJsonFile("appsettings.json")
    .AddJsonFile($"appsettings.{Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? "Production"}.json", true)
    .Build();

作為 Windows Service 執行時,預設情況下,Directory.GetCurrentDirectory() 為 C:\WINDOWS\system32
AppDomain.CurrentDomain.BaseDirectory 和 AppContext.BaseDirectory 為應用程式所在的實際目錄。
因為在有的依賴程式包中有用到 Directory.GetCurrentDirectory() 獲取來程式所在目錄,所以這裡必須使用 Directory.SetCurrentDirectory 設定當前工作目錄。

再次啟動服務:

> sc start MyService

SERVICE_NAME: MyService
        TYPE               : 10  WIN32_OWN_PROCESS
        STATE              : 2  START_PENDING
                                (NOT_STOPPABLE, NOT_PAUSABLE, IGNORES_SHUTDOWN)
        WIN32_EXIT_CODE    : 0  (0x0)
        SERVICE_EXIT_CODE  : 0  (0x0)
        CHECKPOINT         : 0x0
        WAIT_HINT          : 0x7d0
        PID                : 21736
        FLAGS              :

這次服務啟動成功了。

停止服務

執行以下命令,停止 MyService 服務。

sc stop MyService

輸出結果:

SERVICE_NAME: MyService
        TYPE               : 10  WIN32_OWN_PROCESS
        STATE              : 3  STOP_PENDING
                                (STOPPABLE, NOT_PAUSABLE, ACCEPTS_SHUTDOWN)
        WIN32_EXIT_CODE    : 0  (0x0)
        SERVICE_EXIT_CODE  : 0  (0x0)
        CHECKPOINT         : 0x0
        WAIT_HINT          : 0x0

刪除服務

執行以下命令,(從登錄檔中)刪除 MyService 服務。

sc delete MyService

輸出結果:

[SC] DeleteService 成功

至此,我們使用 sc 實用工具演示了服務的建立、更改描述、啟動、停止和刪除。當服務建立完成以後,您也可以使用 Windows 服務管理器來維護服務的啟動、停止等。

Windows Service 優雅退出

問題

我查看了一下 C:\test\workerpub\Logs 目錄下的日誌資訊,發現當停止服務的時候,它並沒有像我將 Worker Service 作為控制檯應用執行時那樣優雅退出(等待關閉前必須完成的任務正常結束後再退出)。也就是說,我在.NET Worker Service 如何優雅退出[6]中使用的方法,在將 Worker Service 作為 Windows 服務執行時失效了。

這是什麼原因呢,該如何解決呢?

查詢原因

我們來看一下 UseWindowsService 方法的原始碼

其中有這樣一行:

// https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.Hosting.WindowsServices/src/WindowsServiceLifetimeHostBuilderExtensions.cs

services.AddSingleton<IHostLifetime, WindowsServiceLifetime>();

也就是說,當 Worker Service 作為 Windows Service 執行時,使用的宿主(Host)生命週期控制類不再是作為控制檯應用執行時的 ConsoleLifetime,而是 WindowsServiceLifetime,它派生自 ServiceBase

讓我們來看一下 WindowsServiceLifetime原始碼

您會發現 WindowsServiceLifetime 類的 OnStopOnShutdown 方法中呼叫了 ApplicationLifetime.StopApplication();而它的基類 ServiceBase 中,當服務停止時呼叫了 OnStopOnShutdown 方法。也就是說,在 Windows 服務停止的時候已經呼叫了 ApplicationLifetime.StopApplication()。這就是我們在 Worker 中手動呼叫 StopApplication 失效的原因。

問題的原因找到了,該怎麼解決它呢?

解決方法

功夫不負有心人,在認真查閱了 dotnet runtime[7]BackgroundServiceWindowsServiceLifetimeApplicationLifetime 類的原始碼後,終於找到了解決方法。既然 WindowsServiceLifetime 中呼叫了 StopApplication,那我就換別的方法唄。

注意到 ApplicationLifetime 的屬性 ApplicationStopping(型別為 CancellationToken),它的註釋是:

Triggered when the application host is performing a graceful shutdown. Request may still be in flight. Shutdown will block until this event completes.

所以,我們可以向它註冊一個取消時呼叫的的委託操作。修改一下 Worker 類中的 StartAsync 方法,新增以下程式碼:

// 註冊應用停止前需要完成的操作
_hostApplicationLifetime.ApplicationStopping.Register(() =>
{
    GetOffWork();
});

向 ApplicationStopping 註冊的委託,在 StopAsync 之前執行。

修改後 Worker 類的完整程式碼如下:

public class Worker : BackgroundService
{
    /// <summary>
    /// 狀態:0-預設狀態,1-正在完成關閉前的必要工作,2-正在執行 StopAsync
    /// </summary>
    private volatile int _status = 0; //狀態
    private readonly IHostApplicationLifetime _hostApplicationLifetime;
    private readonly ILogger<Worker> _logger;

    public Worker(IHostApplicationLifetime hostApplicationLifetime, ILogger<Worker> logger)
    {
        _hostApplicationLifetime = hostApplicationLifetime;
        _logger = logger;
    }

    public override Task StartAsync(CancellationToken cancellationToken)
    {
        // 註冊應用停止前需要完成的操作
        _hostApplicationLifetime.ApplicationStopping.Register(() =>
        {
            GetOffWork();
        });

        _logger.LogInformation("上班了,又是精神抖擻的一天,output from StartAsync");
        return base.StartAsync(cancellationToken);
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        try
        {
            // 這裡實現實際的業務邏輯
            while (!stoppingToken.IsCancellationRequested)
            {
                try
                {
                    _logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);

                    await SomeMethodThatDoesTheWork(stoppingToken);
                }
                catch (Exception ex)
                {
                    _logger.LogError(ex, "Global exception occurred. Will resume in a moment.");
                }

                await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
            }
        }
        finally
        {
            _logger.LogWarning("My worker service shut down.");
        }
    }

    private async Task SomeMethodThatDoesTheWork(CancellationToken cancellationToken)
    {
        string msg = _status switch
        {
            1 => "正在完成關閉前的必要工作……",
            2 => "假裝還在埋頭苦幹ing…… 其實我去洗杯子了",
            _ => "我愛工作,埋頭苦幹ing……"
        };

        _logger.LogInformation(msg);
        await Task.CompletedTask;
    }

    /// <summary>
    /// 關閉前需要完成的工作
    /// </summary>
    private void GetOffWork()
    {
        _status = 1;

        _logger.LogInformation("太好了,下班時間到,output from ApplicationStopping.Register Action at: {time}", DateTimeOffset.Now);           

        _logger.LogDebug("開始處理關閉前必須完成的工作 at: {time}", DateTimeOffset.Now);

        _logger.LogInformation("糟糕,有一個緊急 bug 需要下班前完成!!!");

        _logger.LogInformation("啊啊啊,我愛加班,我要再幹 20 秒,Wait 1 ");

        Task.Delay(TimeSpan.FromSeconds(20)).Wait();

        _logger.LogInformation("啊啊啊啊啊啊,我愛加班,我要再幹 1 分鐘,Wait 2 ");

        Task.Delay(TimeSpan.FromMinutes(1)).Wait();

        _logger.LogInformation("啊哈哈哈哈哈,終於好了,可以下班了!");

        _logger.LogDebug("關閉前必須完成的工作處理完成 at: {time}", DateTimeOffset.Now);
    }

    public override Task StopAsync(CancellationToken cancellationToken)
    {
        _status = 2;

        _logger.LogInformation("準備下班了,output from StopAsync at: {time}", DateTimeOffset.Now);

        _logger.LogInformation("去洗洗茶杯先……", DateTimeOffset.Now);
        Task.Delay(30_000).Wait();
        _logger.LogInformation("茶杯洗好了。", DateTimeOffset.Now);

        _logger.LogInformation("下班嘍 ^_^", DateTimeOffset.Now);

        return base.StopAsync(cancellationToken);
    }
}

程式碼修改完成以後,停止服務,重新發布程式。

dotnet publish -c Release -r win-x64 -o c:\test\workerpub

再次啟動服務然後關閉服務,您會發現,我們編寫的 Windows Service 已經可以優雅退出了。

這種方法,不僅作為 Windows 服務執行時可以優雅退出,而且作為控制檯應用執行時也一樣適用,它比我在.NET Worker Service 如何優雅退出中介紹的方法更加完美。

總結

在本文中,我通過一個例項詳細介紹瞭如何將 .NET Worker Service 作為 Windows 服務執行,並說明了如何使用 sc.exe 實用工具安裝和管理服務。還改進了 Worker Service 優雅退出的方法,使它不僅適用於控制檯應用而且適用於 Windows 服務。

當我們向 HostBuilder 添加了 .UseWindowsService() 方法呼叫後,編譯出的程式,既可以作為控制檯應用執行,也可以作為 Windows 服務執行。

您可以從 GitHub 下載本文中的原始碼[8]


相關閱讀:

作者 : 技術譯民
出品 : 技術譯站


  1. https://docs.microsoft.com/zh-cn/windows/win32/services/service-control-manager ↩︎

  2. https://www.techopedia.com/definition/25522/service-control-manager-scm ↩︎

  3. https://github.com/ITTranslate/WorkerServiceWithSerilog ↩︎

  4. https://docs.microsoft.com/zh-cn/dotnet/core/tools/dotnet-publish ↩︎

  5. https://docs.microsoft.com/zh-cn/windows-server/administration/windows-commands/sc-create ↩︎

  6. https://www.cnblogs.com/ittranslator/p/worker-service-gracefully-shutdown.html ↩︎

  7. https://github.com/dotnet/runtime dotnet runtime ↩︎

  8. https://github.com/ITTranslate/WorkerServiceAsWindowsService 原始碼下載 ↩︎