使用 BackgroundService 创建 Windows 服务

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

在本教程中,你将学习如何:

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

小窍门

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

重要

安装 .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 worker

小窍门

如果使用 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,请运行以下命令。 (如果使用的是 .NET 9 或更高版本的 SDK 版本,请改用 dotnet add package 窗体。

dotnet package add Microsoft.Extensions.Hosting.WindowsServices

有关详细信息,请参阅 dotnet 包添加

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

<ItemGroup>
  <PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.10" />
  <PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="9.0.10" />
</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="9.0.10" />
    <PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="9.0.10" />
  </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 被一起注入。 这两者都作为字段提供给类。 在 ExecuteAsync 方法中,笑话服务请求一个笑话,并将其写入记录器。 在这种情况下,记录器由 Windows 事件日志实现 - Microsoft.Extensions.Logging.EventLog.EventLogLoggerProvider。 日志将写入事件查看器,并可用于在 事件查看器中查看。

注释

默认情况下, 事件日志 严重性为 Warning。 这可以配置,但出于演示目的, 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 EventLog

重写 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="9.0.10" />
    <PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="9.0.10" />
  </ItemGroup>
</Project>

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

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

若要从 Visual Studio 发布应用,可以创建持久保存的发布配置文件。 发布配置文件基于 XML,具有 .pubxml 文件扩展名。 Visual Studio 使用此配置文件隐式发布应用,而如果使用 .NET CLI,则必须显式指定要使用的发布配置文件。

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

Visual Studio 发布对话框

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

Visual Studio 配置文件设置

确保指定了以下设置:

  • 部署模式:独立
  • 生成单个文件:已选中
  • 启用 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)创建命令。 以管理员身份运行 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 创建

配置 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                 :

该命令将输出恢复配置,即默认值,因为它们尚未配置。

Windows 服务恢复配置属性对话框。

若要配置恢复,请使用 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.

你将看到配置的重启值。

启用重启的 Windows 服务恢复配置属性对话框。

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

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

选项 Description
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 服务。 若要验证此应用是否按预期运行,需要使用管理员帐户。

服务用户界面。

若要验证服务是否按预期运行,需要:

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

重要

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

无法启动程序。

启动 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

服务Status将从START_PENDING转换到正在运行

查看日志

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

“事件属性”对话框,其中包含从服务记录的详细信息

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

停止 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)删除命令。 以管理员身份运行 PowerShell。

重要

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

sc.exe delete ".NET Joke Service"

你将看到一条输出消息:

[SC] DeleteService SUCCESS

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

另请参阅

Next