.NET 8 中新的IHostedLifecycleService 接口简介

作者:微信公众号:【架构师老卢】
1-30 9:36
183

概述:正如普通读者所知道的那样,我密切关注的.NET领域是Microsoft.Extensions.Hosting。我已经在博客中介绍了 .NET 8 中的更改,其中引入了新的并发选项,以支持跨多个 IHostedServices 并行运行 StartAsync 和 StopAsync。在这篇文章中,我们将介绍为 .NET 8 中的托管服务引入的一些新的生命周期事件。请注意,这篇文章与 .NET 8 相关,在撰写本文时,.NET 8 目前处于预览状态。在 11 月最终发布 .NET 8 之前,类型和实现可能会更改。若要继续操作,需要 .NET 8 的预览版 7。IHOSTEDLIFECYCLESER

正如普通读者所知道的那样,我密切关注的.NET领域是Microsoft.Extensions.Hosting。我已经在博客中介绍了 .NET 8 中的更改,其中引入了新的并发选项,以支持跨多个 IHostedServices 并行运行 StartAsync 和 StopAsync。

在这篇文章中,我们将介绍为 .NET 8 中的托管服务引入的一些新的生命周期事件。请注意,这篇文章与 .NET 8 相关,在撰写本文时,.NET 8 目前处于预览状态。在 11 月最终发布 .NET 8 之前,类型和实现可能会更改。若要继续操作,需要 .NET 8 的预览版 7。

IHOSTEDLIFECYCLESERVICE 简介

主要更改是在名为 IHostedLifecycleService 的 Microsoft.Extensions.Hosting 命名空间中包含一个新接口。此接口继承自现有的 IHostedService 接口,对其进行扩展,以便为在现有 StartAsync 和 StopAsync 方法之前或之后发生的新生命周期事件添加方法。它们提供了一种挂接到某些高级方案的更具体的应用程序生存期事件的方法。

接口定义如下:

public partial interface IHostedLifecycleService : Microsoft.Extensions.Hosting.IHostedService
{
    Task StartingAsync(CancellationToken cancellationToken);
    Task StartedAsync(CancellationToken cancellationToken);
    Task StoppingAsync(CancellationToken cancellationToken);
    Task StoppedAsync(CancellationToken cancellationToken);    
}

实现此接口的所有已注册托管服务的 StartingAsync 方法将在应用程序生命周期的早期运行,然后再在任何已注册的托管服务上调用 StartAsync(来自 IHostedService)。这可用于在启动应用程序之前执行一些非常早期的验证检查,例如检查关键需求或依赖项是否可用。这允许应用程序在任何托管服务开始执行其主要工作负载之前启动失败。其他用途包括“预热”和初始化单例以及应用程序使用的其他状态。

在完成已注册托管服务的所有 StartAsync(来自 IHostedService)方法后,将在实现上调用 StartedAsync。这可用于在将应用程序标记为成功启动之前验证应用程序状态或条件。

StoppingAsync 和 StoppedAsync 在应用程序关闭期间的工作方式类似,并为关闭前和关闭后验证提供高级挂钩。

在进入更精细的细节之前,值得讨论为什么 Microsoft 创建了一个新的派生接口,而不是利用默认接口实现来更新现有的 IHostedService 接口。对于默认接口实现来说,这确实是一个很好的案例,可以使用默认的无操作实现将这些内容添加到 IHostedService。当我们查看此库的运行时目标时,原因很明显。托管包多针对各种目标框架。这包括 netstandard2.0,它在引入默认接口实现功能之前就被锁定了。因此,为了继续支持这一目标,改用了派生的界面设计。

作为引入此接口的 PR 的一部分,HostOptions 中还添加了一个新选项 StartupTimeout。这允许提供一个 TimeSpan,该 TimeSpan 将控制所有托管服务启动允许的最长时间。当配置了非无限值(默认值)时,传递给启动生命周期事件的取消令牌将链接到使用提供的值配置的 CancellationTokenSource。

使用新界面

使用 .NET 8 预览版 7,我们可以查看如何利用此新接口的一般示例。我在应用程序中看到的一个相当常见的启动工作是初始化数据库。

在生产中,我们可以期待这样的数据库是在线的、可用的和播种的;在其他环境(如 CI)中,我们可能需要创建一个虚拟数据库,并使用示例数据进行播种。有各种解决方案可以处理这个问题,但一个潜在的选择是使用托管服务有条件地执行工作。当其他托管服务依赖于可用的数据库时,这可能会更加复杂,因为在数据库准备就绪后,这些服务必须以正确的顺序启动。在 .NET 7 中,这是可以实现的,因为托管服务按其注册顺序启动。

因此,在 .NET 7 中,我们可以实现以下目标:


public class ServiceA : IHostedService
{
    public Task StartAsync(CancellationToken cancellationToken)
    {
        // INIT DB
  
        return Task.CompletedTask;
    }
  
    public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
  
public class ServiceB : BackgroundService
{
    protected override Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // USE DB
  
        return Task.CompletedTask;
    }
}

要向 DI 容器注册这些服务以便主机执行它们,我们必须确保以正确的顺序专门添加它们。

builder.Services.AddHostedService<ServiceA>();
builder.Services.AddHostedService<ServiceB>();

由于 .NET 7 按顺序而不是并发执行每个服务的 StartAsync 方法,因此我们知道,在调用 ServiceB.StartAsync 时,数据库应已在 ServiceA 中完成初始化。

虽然默认情况下,此行为在 .NET 8 中也适用,但现在也可以将主机配置为同时启动它们。如果我们想更改此选项,我们的应用程序可能会中断,因为 ServiceB 将与 ServiceA 同时触发。这可能不是一个重大问题,但如果应用程序中有其他托管服务,通过切换到并发执行,我们可以减少应用程序的整体启动时间。

随着 .NET 8 中新 IHostedLifecycleService 的引入,我们可以在生命周期的早期移动数据库初始化工作,同时还可以利用并发托管服务启动。

public class ServiceA : IHostedService, IHostedLifecycleService
{
    public Task StartingAsync(CancellationToken cancellationToken)
    {
        // INIT DB
  
        return Task.CompletedTask;
    }
  
    public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
  
    public Task StartedAsync(CancellationToken cancellationToken) => Task.CompletedTask;
  
    public Task StoppingAsync(CancellationToken cancellationToken) => Task.CompletedTask;
  
    public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
  
    public Task StoppedAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
  
public class ServiceB : BackgroundService
{
    protected override Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // USE DB
  
        return Task.CompletedTask;
    }
}

在上面的示例代码中,我们定义了两个托管服务。除了 IHostedService 之外,ServiceA 还实现了新的 IHostedLifecycleService。我们希望在应用生命周期的早期,在任何主要工作负载之前执行数据库初始化。因此,我们可以在 StartingAsync 方法中包含数据库设置代码。

派生自 BackgroundService 的 ServiceB 现在可以在其 ExecuteAsync 方法中安全地使用数据库,因为 ExecuteAsync 是由 IHostedService 接口中定义的 StartAsync 的基础实现调用的。因此,在已注册服务的所有 StartingAsync 方法完成之前,不会调用它。

我们将以与 .NET 中相同的方式向 DI 容器注册这些服务,但添加它们的顺序不再重要。

builder.Services.AddHostedService<ServiceB>();
builder.Services.AddHostedService<ServiceA>();

即使按此顺序,ServiceA 的 StartingAsync 也会在 ServiceB.StartAsync 之前执行。我们甚至可以在不破坏逻辑的情况下配置并发启动和停止行为。

builder.Services.Configure<HostOptions>(options =>
{
    options.ServicesStartConcurrently = true;
    options.ServicesStopConcurrently = true;
});

深入了解细节

引入新接口后,大多数核心更改都是在内部 Host 类中实现的,该类实现 IHost 接口。此类定义主主机,该主机是在从模板(如 ASP.NET Core 和 Worker Service)创建新应用程序时构建的。IHost 接口定义在应用程序启动或停止时调用的 StartAsync 和 StopAsync 方法。

第一个有意义的更改在 StartAsync 方法的开头引入了额外的逻辑,以实现新的 StartupTimeout 功能。

CancellationTokenSource? cts = null;
CancellationTokenSource linkedCts;
if (_options.StartupTimeout != Timeout.InfiniteTimeSpan)
{
    cts = new CancellationTokenSource(_options.StartupTimeout);
    linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, cancellationToken, _applicationLifetime.ApplicationStopping);
}
else
{
    linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _applicationLifetime.ApplicationStopping);
}

从此更新的代码中可以看出,在所有情况下,传递给 StartAsync 的取消令牌都可能导致取消,在 IHostApplicationLifetime 上公开的 ApplicationStopping 令牌也可能导致取消,如果触发了关闭,则该令牌将被标记为已取消。

当 HostOptions.StartupTimeout 不等于 InfiniteTimeSpan 时,将创建 CancellationTokenSource,并将 TimeSpan 传递到构造函数中。然后,可以将其令牌添加到链接的令牌源中,以确保第三个条件也可以触发中止启动。这个新选项允许应用程序开发人员为预期的“正常”启动提供预期的时间上限,以便在特殊情况下,长时间的延迟可以主动触发启动的流产。应跟踪并记录可能由不可用的外部依赖项导致的此类情况,以便进行调查。

创建 linkedCts 后,它将用于访问 CancellationToken,然后将其传递到启动过程中调用的任何后续异步方法中。

CancellationToken token = linkedCts.Token;

这些异步方法中的第一个是 IHostLifetime.WaitForStartAsync 的调用,这是托管生存期的早期挂钩,此时正在等待。这是另一个高级钩子,它可能会延迟启动,直到外部事件发出信号。此概念自 .NET Core 3.0 以来一直可用。

// This may not catch exceptions.
await _hostLifetime.WaitForStartAsync(token).ConfigureAwait(false);
                
token.ThrowIfCancellationRequested();

实现中的下一行准备了一些变量和字段。

List<Exception> exceptions = new();
_hostedServices = Services.GetRequiredService<IEnumerable<IHostedService>>();
_hostedLifecycleServices = GetHostLifecycles(_hostedServices);
bool concurrent = _options.ServicesStartConcurrently;
bool abortOnFirstException = !concurrent;

在启动期间设置列表以包含任何异常后,将从容器中检索所有已注册的 IHostedServices。GetHostLifecycles 方法用于循环访问 IHostedService 实现,并确定哪些(如果有)也实现 IHostedLifecycleService。

下一段代码为每个 IHostedLifecycleService 执行 StartingAsync 方法。


if (_hostedLifecycleServices is not null)
{
    // Call StartingAsync().
    await ForeachService(_hostedLifecycleServices, token, concurrent, abortOnFirstException, exceptions,
        (service, token) => service.StartingAsync(token)).ConfigureAwait(false);
}

ForeachService 是一个帮助程序方法,它根据作为参数传入的 HostOptions 设置并发或按顺序执行服务。


private static async Task ForeachService<T>(
    IEnumerable<T> services,
    CancellationToken token,
    bool concurrent,
    bool abortOnFirstException,
    List<Exception> exceptions,
    Func<T, CancellationToken, Task> operation)
{
    if (concurrent)
    {
        // The beginning synchronous portions of the implementations are run serially in registration order for
        // performance since it is common to return Task.Completed as a noop.
        // Any subsequent asynchronous portions are grouped together run concurrently.
        List<Task>? tasks = null;
 
        foreach (T service in services)
        {
            Task task;
            try
            {
                task = operation(service, token);
            }
            catch (Exception ex)
            {
                exceptions.Add(ex); // Log exception from sync method.
                continue;
            }
 
            if (task.IsCompleted)
            {
                if (task.Exception is not null)
                {
                    
exceptions.AddRange(task.Exception.InnerExceptions); // Log exception from async method.
                }
            }
            else
            {
                tasks ??= new();
                tasks.Add(Task.Run(() => task, token));
            }
        }
 
        if (tasks is not null)
        {
            Task groupedTasks = Task.WhenAll(tasks);
 
            try
            {
                await groupedTasks.ConfigureAwait(false);
            }
            catch (Exception ex)
            {
                
exceptions.AddRange(groupedTasks.Exception?.InnerExceptions ?? new[] { ex }.AsEnumerable());
            }
        }
    }
    else
    {
        foreach (T service in services)
        {
            try
            {
                await operation(service, token).ConfigureAwait(false);
            }
            catch (Exception ex)
            {
                exceptions.Add(ex);
                if (abortOnFirstException)
                {
                    return;
                }
            }
        }
    }
}

代码基于布尔 concurrent 参数进行分支。让我们重点介绍用于并发调用每个服务函数的代码。

如注释所述,实现首先在每个服务上调用操作委托,在本例中为 StartingAsync。许多 IHostedLifecycleService 实现完全有可能通过返回缓存的 Task.CompletedTask 对其大多数方法执行 no-op。在这些情况下,代码会同步运行,因为没有什么可等待的。上面的代码对此进行了特殊处理,并检查是否有任何任务立即返回为已完成或引发同步异常。对于这些已完成的任务,其中引发的任何异常都将添加到例外列表中。

对于此时未完成的任何任务,它们将异步运行。这些任务将添加到任务列表中。启动所有任务后,将使用 WhenAll 等待它们,这意味着它们会并发运行,直到所有已注册的服务都完成其工作。此处还捕获了任何异常。

在非并发路径中,代码更简单,因为它可以简单地按顺序等待每个操作。在此配置中,每个服务都必须先完成其工作,然后才能调用下一个服务。

返回到 Host.StartAsync 方法,对 IHostedService.StartAsync 和 IHostedLifecycleService.StartedAsync 重复该过程。该方法最后记录并重新引发任何捕获的异常,然后触发托管应用程序现已启动的通知。

Stopping 和 Stopped 的新生命周期事件的实现几乎相同,因此我们无需在此处深入探讨。

阅读排行