使用 BackgroundService 创建 Windows 服务

.NET Framework 开发人员可能熟悉 Windows 服务应用。 在 .NET Core 和 .NET 5+ 之前,依赖 .NET Framework 的开发人员可能会创建 Windows 服务来执行后台任务或执行长时间运行的进程。 此功能仍然可用,你可以创建作为 Windows 服务运行的辅助角色服务。

本教程介绍以下操作:

  • 将 .NET 辅助角色应用作为单个文件可执行文件发布。
  • 创建 Windows 服务。
  • BackgroundService 应用创建为 Windows 服务。
  • 启动和停止 Windows 服务。
  • 查看事件日志。
  • 删除 Windows 服务。

提示

所有“.NET 中的辅助角色”示例源代码都可以在示例浏览器中下载。 有关详细信息,请参阅浏览代码示例:.NET 中的辅助角色

重要

安装 .NET SDK 还会安装 Microsoft.NET.Sdk.Worker 和辅助角色模板。 换句话说,安装 .NET SDK 后,可以使用 dotnet new worker 命令创建新的辅助角色。 如果使用的是 Visual Studio,则在安装可选 ASP.NET 和 Web 开发工作负载之前,模板将隐藏。

先决条件

创建新项目

若要使用 Visual Studio 创建新的辅助角色服务项目,请选择“文件”“新建”“项目...”。从“创建新项目”对话框搜索“辅助角色服务”,并选择辅助角色服务模板。 如果你想要使用 .NET CLI,请在工作目录中打开你最喜欢的终端。 运行 dotnet new 命令,将 <Project.Name> 替换为所需的项目名称。

dotnet new worker --name <Project.Name>

有关 .NET CLI 新建辅助角色服务项目命令的详细信息,请参阅 dotnet new 辅助角色

提示

如果使用 Visual Studio Code,则可以从集成终端运行 .NET CLI 命令。 有关详细信息,请参阅 Visual Studio Code:集成终端

安装 NuGet 包

为了与 .NET IHostedService 实现中的本机 Windows 服务互操作,需要安装 Microsoft.Extensions.Hosting.WindowsServices NuGet 包

若要从 Visual Studio 安装此包,请使用“管理 NuGet 包...”对话框。 搜索“Microsoft.Extensions.Hosting.WindowsServices”,然后安装它。 如果要使用 .NET CLI,请运行 dotnet add package 命令:

dotnet add package Microsoft.Extensions.Hosting.WindowsServices

有关 .NET CLI add package 命令的详细信息,请参阅 dotnet add package

成功添加包后,你的项目文件现在应包含以下包引用:

<ItemGroup>
  <PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
  <PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.0" />
</ItemGroup>

更新项目文件

此辅助角色项目使用 C# 的可为 null 的引用类型。 若要为整个项目启用这些类型,请对项目文件进行相应的更新:

<Project Sdk="Microsoft.NET.Sdk.Worker">

  <PropertyGroup>
    <TargetFramework>net8.0-windows</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>true</ImplicitUsings>
    <RootNamespace>App.WindowsService</RootNamespace>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
    <PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.0" />
  </ItemGroup>
</Project>

上述项目文件更改添加了 <Nullable>enable<Nullable> 节点。 有关详细信息,请参阅设置可为 null 的上下文

创建服务

将新类添加到名为 JokeService.cs 的项目,并将其内容替换为以下 C# 代码:

namespace App.WindowsService;

public sealed class JokeService
{
    public string GetJoke()
    {
        Joke joke = _jokes.ElementAt(
            Random.Shared.Next(_jokes.Count));

        return $"{joke.Setup}{Environment.NewLine}{joke.Punchline}";
    }

    // Programming jokes borrowed from:
    // https://github.com/eklavyadev/karljoke/blob/main/source/jokes.json
    private readonly HashSet<Joke> _jokes = new()
    {
        new Joke("What's the best thing about a Boolean?", "Even if you're wrong, you're only off by a bit."),
        new Joke("What's the object-oriented way to become wealthy?", "Inheritance"),
        new Joke("Why did the programmer quit their job?", "Because they didn't get arrays."),
        new Joke("Why do programmers always mix up Halloween and Christmas?", "Because Oct 31 == Dec 25"),
        new Joke("How many programmers does it take to change a lightbulb?", "None that's a hardware problem"),
        new Joke("If you put a million monkeys at a million keyboards, one of them will eventually write a Java program", "the rest of them will write Perl"),
        new Joke("['hip', 'hip']", "(hip hip array)"),
        new Joke("To understand what recursion is...", "You must first understand what recursion is"),
        new Joke("There are 10 types of people in this world...", "Those who understand binary and those who don't"),
        new Joke("Which song would an exception sing?", "Can't catch me - Avicii"),
        new Joke("Why do Java programmers wear glasses?", "Because they don't C#"),
        new Joke("How do you check if a webpage is HTML5?", "Try it out on Internet Explorer"),
        new Joke("A user interface is like a joke.", "If you have to explain it then it is not that good."),
        new Joke("I was gonna tell you a joke about UDP...", "...but you might not get it."),
        new Joke("The punchline often arrives before the set-up.", "Do you know the problem with UDP jokes?"),
        new Joke("Why do C# and Java developers keep breaking their keyboards?", "Because they use a strongly typed language."),
        new Joke("Knock-knock.", "A race condition. Who is there?"),
        new Joke("What's the best part about TCP jokes?", "I get to keep telling them until you get them."),
        new Joke("A programmer puts two glasses on their bedside table before going to sleep.", "A full one, in case they gets thirsty, and an empty one, in case they don’t."),
        new Joke("There are 10 kinds of people in this world.", "Those who understand binary, those who don't, and those who weren't expecting a base 3 joke."),
        new Joke("What did the router say to the doctor?", "It hurts when IP."),
        new Joke("An IPv6 packet is walking out of the house.", "He goes nowhere."),
        new Joke("3 SQL statements walk into a NoSQL bar. Soon, they walk out", "They couldn't find a table.")
    };
}

readonly record struct Joke(string Setup, string Punchline);

前面的玩笑服务源代码公开了单个功能,即 GetJoke 方法。 这是一个表示随机编程玩笑的 string 返回方法。 类范围 _jokes 字段用于存储玩笑列表。 从列表中选择随机玩笑并返回。

重写 Worker

将模板中的现有 Worker 替换为以下 C# 代码,并将该文件重命名为 WindowsBackgroundService.cs:

namespace App.WindowsService;

public sealed class WindowsBackgroundService(
    JokeService jokeService,
    ILogger<WindowsBackgroundService> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        try
        {
            while (!stoppingToken.IsCancellationRequested)
            {
                string joke = jokeService.GetJoke();
                logger.LogWarning("{Joke}", joke);

                await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
            }
        }
        catch (OperationCanceledException)
        {
            // When the stopping token is canceled, for example, a call made from services.msc,
            // we shouldn't exit with a non-zero exit code. In other words, this is expected...
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "{Message}", ex.Message);

            // Terminates this process and returns an exit code to the operating system.
            // This is required to avoid the 'BackgroundServiceExceptionBehavior', which
            // performs one of two scenarios:
            // 1. When set to "Ignore": will do nothing at all, errors cause zombie services.
            // 2. When set to "StopHost": will cleanly stop the host, and log errors.
            //
            // In order for the Windows Service Management system to leverage configured
            // recovery options, we need to terminate the process with a non-zero exit code.
            Environment.Exit(1);
        }
    }
}

在前面的代码中,将 JokeServiceILogger 一起注入。 这两者都作为 private readonly 字段提供给类。 在 ExecuteAsync 方法中,玩笑服务请求一个玩笑,并将其写入记录器。 在这种情况下,记录器由 Windows 事件日志实现 - Microsoft.Extensions.Logging.EventLog.EventLogLogger。 日志已写入,并可在事件查看器中查看。

注意

默认情况下,事件日志严重性为 。 可对此进行配置,但出于演示目的,WindowsBackgroundService 使用 LogWarning 扩展方法进行记录。 若要专门针对 EventLog 级别,请在 appsettings.{Environment}.json 中添加一个条目或提供一个 EventLogSettings.Filter 值。

{
  "Logging": {
    "LogLevel": {
      "Default": "Warning"
    },
    "EventLog": {
      "SourceName": "The Joke Service",
      "LogName": "Application",
      "LogLevel": {
        "Microsoft": "Information",
        "Microsoft.Hosting.Lifetime": "Information"
      }
    }
  }
}

有关配置日志级别的详细信息,请参阅 .NET 中的日志记录提供程序:配置 Windows 事件日志

重写 Program

将模板 Program.cs 文件内容替换为以下 C# 代码:

using App.WindowsService;
using Microsoft.Extensions.Logging.Configuration;
using Microsoft.Extensions.Logging.EventLog;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.Services.AddWindowsService(options =>
{
    options.ServiceName = ".NET Joke Service";
});

LoggerProviderOptions.RegisterProviderOptions<
    EventLogSettings, EventLogLoggerProvider>(builder.Services);

builder.Services.AddSingleton<JokeService>();
builder.Services.AddHostedService<WindowsBackgroundService>();

IHost host = builder.Build();
host.Run();

AddWindowsService 扩展方法将应用配置为作为 Windows 服务工作。 服务名称设置为 ".NET Joke Service"。 托管服务已针对依赖项注入注册。

有关注册服务的详细信息,请参阅 .NET 中的依赖关系注入

发布应用

若要将 .NET 辅助角色服务应用创建为 Windows 服务,建议将应用作为单个可执行文件发布。 拥有一个独立式可执行文件不太容易出错,因为文件系统周围没有任何依赖文件。 但可以选择其他发布形式,这是完全可以接受的,前提是你创建的 *.exe 文件可以作为 Windows 服务控制管理器的目标。

重要

另一种发布方法是生成 *.dll(而不是 *.exe),并在使用 Windows 服务控制管理器安装已发布的应用时,委托给 .NET CLI 并传递 DLL。 有关详细信息,请参阅 .NET CLI:dotnet 命令

sc.exe create ".NET Joke Service" binpath="C:\Path\To\dotnet.exe C:\Path\To\App.WindowsService.dll"
<Project Sdk="Microsoft.NET.Sdk.Worker">

  <PropertyGroup>
    <TargetFramework>net8.0-windows</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>true</ImplicitUsings>
    <RootNamespace>App.WindowsService</RootNamespace>
    <OutputType>exe</OutputType>
    <PublishSingleFile Condition="'$(Configuration)' == 'Release'">true</PublishSingleFile>
    <RuntimeIdentifier>win-x64</RuntimeIdentifier>
    <PlatformTarget>x64</PlatformTarget>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
    <PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.0" />
  </ItemGroup>
</Project>

项目文件前面突出显示的行定义了以下行为:

  • <OutputType>exe</OutputType>:创建控制台应用程序。
  • <PublishSingleFile Condition="'$(Configuration)' == 'Release'">true</PublishSingleFile>:启用单文件发布。
  • <RuntimeIdentifier>win-x64</RuntimeIdentifier>:指定 win-x64<RuntimeIdentifier>win-x64</RuntimeIdentifier>
  • <PlatformTarget>x64</PlatformTarget>:指定 64 位的目标平台 CPU。

若要从 Visual Studio 发布应用,可以创建一个持久保留的发布配置文件。 发布配置文件基于 XML,其文件扩展名为 .pubxml。 Visual Studio 使用此配置文件来隐式发布应用,而如果你使用 .NET CLI,则必须显式指定发布配置文件才能使用该配置文件。

右键单击“解决方案资源管理器”中的项目,选择“发布...”。然后,选择“添加发布配置文件”创建配置文件。 从“发布”对话框中,选择“文件夹”作为“目标”

The Visual Studio Publish dialog

保留默认“位置”,然后选择“完成”。 创建配置文件后,选择“显示所有设置”,然后验证“配置文件设置”

The Visual Studio Profile settings

确保指定了以下设置:

  • 部署模式:自包含
  • 生成单个文件:已选中
  • 启用 ReadyToRun 编译:已选中
  • 剪裁未使用的程序集(预览版):未选中

最后,选择“发布”。 将编译应用,并将生成的 .exe 文件发布到 /publish 输出目录。

或者,可以使用 .NET CLI 发布应用:

dotnet publish --output "C:\custom\publish\directory"

有关详细信息,请参阅 dotnet publish

重要

对于 .NET 6,如果尝试使用 <PublishSingleFile>true</PublishSingleFile> 设置调试应用,将无法调试应用。 有关详细信息,请参阅调试 'PublishSingleFile' .NET 6 应用时无法附加到 CoreCLR

创建 Windows 服务

如果不熟悉如何使用 PowerShell,并且想要为服务创建安装程序,请参阅创建 Windows 服务安装程序。 否则,如果要创建 Windows 服务,请使用本机 Windows 服务控制管理器的(sc.exe)create 命令。 以管理员身份运行 PowerShell。

sc.exe create ".NET Joke Service" binpath="C:\Path\To\App.WindowsService.exe"

提示

如果需要更改主机配置的内容根源,可以在指定 binpath 时将其作为命令行参数传递:

sc.exe create "Svc Name" binpath="C:\Path\To\App.exe --contentRoot C:\Other\Path"

你将看到以下输出消息:

[SC] CreateService SUCCESS

有关详细信息,请参阅 sc.exe create

配置 Windows 服务

创建服务后,可以选择对其进行配置。 如果觉得服务默认设置可用,则跳到验证服务功能部分。

Windows 服务提供恢复配置选项。 你可以使用 sc.exe qfailure "<Service Name>"(其中 <Service Name> 是服务器的名称)命令查询当前配置,以读取当前的恢复配置值:

sc qfailure ".NET Joke Service"
[SC] QueryServiceConfig2 SUCCESS

SERVICE_NAME: .NET Joke Service
        RESET_PERIOD (in seconds)    : 0
        REBOOT_MESSAGE               :
        COMMAND_LINE                 :

命令将输出恢复配置,由于未对其进行配置,输出的是默认值。

The Windows Service recovery configuration properties dialog.

若要配置恢复,请使用 sc.exe failure "<Service Name>",其中 <Service Name> 是服务的名称:

sc.exe failure ".NET Joke Service" reset=0 actions=restart/60000/restart/60000/run/1000
[SC] ChangeServiceConfig2 SUCCESS

提示

若要配置恢复选项,终端会话需要以“管理员”角色运行。

成功配置后,可以使用 sc.exe qfailure "<Service Name>" 命令再次查询这些值:

sc qfailure ".NET Joke Service"
[SC] QueryServiceConfig2 SUCCESS

SERVICE_NAME: .NET Joke Service
        RESET_PERIOD (in seconds)    : 0
        REBOOT_MESSAGE               :
        COMMAND_LINE                 :
        FAILURE_ACTIONS              : RESTART -- Delay = 60000 milliseconds.
                                       RESTART -- Delay = 60000 milliseconds.
                                       RUN PROCESS -- Delay = 1000 milliseconds.

你将看到已配置的重启值。

The Windows Service recovery configuration properties dialog with restart enabled.

服务恢复选项和 .NET BackgroundService 实例

使用 .NET 6 时,新的托管异常处理行为已添加到 .NET。 BackgroundServiceExceptionBehavior 枚举已添加到 Microsoft.Extensions.Hosting 命名空间,用于指定引发异常时服务的行为。 下表列出了可用选项:

选项 说明
Ignore 忽略 BackgroundService 中引发的异常。
StopHost 引发未经处理的异常时,IHost 将停止。

.NET 6 之前的默认行为是 Ignore,这会导致僵尸进程(一个不执行任何操作的运行进程)。 使用 .NET 6 时,默认行为是 StopHost,这将导致引发异常时主机停止。 但它会完全停止,这意味着 Windows 服务管理系统不会重启该服务。 若要正确允许重新启动服务,可以使用非零退出代码调用 Environment.Exit。 请考虑以下突出显示的 catch 块:

namespace App.WindowsService;

public sealed class WindowsBackgroundService(
    JokeService jokeService,
    ILogger<WindowsBackgroundService> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        try
        {
            while (!stoppingToken.IsCancellationRequested)
            {
                string joke = jokeService.GetJoke();
                logger.LogWarning("{Joke}", joke);

                await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
            }
        }
        catch (OperationCanceledException)
        {
            // When the stopping token is canceled, for example, a call made from services.msc,
            // we shouldn't exit with a non-zero exit code. In other words, this is expected...
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "{Message}", ex.Message);

            // Terminates this process and returns an exit code to the operating system.
            // This is required to avoid the 'BackgroundServiceExceptionBehavior', which
            // performs one of two scenarios:
            // 1. When set to "Ignore": will do nothing at all, errors cause zombie services.
            // 2. When set to "StopHost": will cleanly stop the host, and log errors.
            //
            // In order for the Windows Service Management system to leverage configured
            // recovery options, we need to terminate the process with a non-zero exit code.
            Environment.Exit(1);
        }
    }
}

验证服务功能

若要查看创建为 Windows 服务的应用,请打开“服务”。 选择 Windows 键(或 Ctrl + Esc),然后从“服务”进行搜索。 从“服务”应用,你应该能够按名称找到你的服务。

重要

默认情况下,常规(非管理员)用户无法管理 Windows 服务。 若要验证此应用是否按预期运行,需要使用管理员帐户。

The Services user interface.

若要验证服务是否按预期运行,需要执行以下操作:

  • 启动服务
  • 查看日志
  • 停止服务

重要

若要对应用程序进行调试,请确保未尝试调试正在 Windows 服务进程中运行的可执行文件。

Unable to start program.

启动 Windows 服务

若要启动 Windows 服务,请使用 sc.exe start 命令:

sc.exe start ".NET Joke Service"

将显示类似于下面的输出:

SERVICE_NAME: .NET Joke Service
    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                : 37636
    FLAGS

服务“状态”将从 转换到“正在运行”。

查看日志

若要查看日志,请打开“事件查看器”。 选择 Windows 键(或 Ctrl + Esc),然后搜索 "Event Viewer"。 选择“事件查看器(本地)”>“Windows 日志”>“应用程序”节点。 你应该会看到“源”与应用命名空间匹配的“警告”级别条目。 双击该条目,或右键单击并选择“事件属性”以查看详细信息。

The Event Properties dialog, with details logged from the service

查看“事件日志”中的日志后,应停止该服务。 它设计为每分钟记录一次随机玩笑。 这是有意的行为,但不适用于生产服务。

停止 Windows 服务

若要停止 Windows 服务,请使用 sc.exe stop 命令:

sc.exe stop ".NET Joke Service"

将显示类似于下面的输出:

SERVICE_NAME: .NET Joke Service
    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

服务“状态”将从 STOP_PENDING 转换到“已停止”。

删除 Windows 服务

若要删除 Windows 服务,请使用本机 Windows 服务控制管理器 (sc.exe) delete 命令。 以管理员身份运行 PowerShell。

重要

如果服务未处于“已停止”状态,将不会立即删除它。 在发出删除命令之前,请确保服务已停止。

sc.exe delete ".NET Joke Service"

你将看到以下输出消息:

[SC] DeleteService SUCCESS

有关详细信息,请参阅 sc.exe delete

另请参阅

下一页