在这篇文章中,我们将了解 .NET 8 中为托管服务引入的一些新生命周期事件。请注意,这篇文章与 .NET 8 相关,在撰写本文时,.NET 8 目前处于预览状态。在 11 月 .NET 8 最终版本发布之前,类型和实现可能会发生变化。要继续操作,您将需要.NET 8:Announcing .NET 8 - .NET Blog。
IHostedLifecycleService 简介
主要更改是在 Microsoft.Extensions.Hosting 命名空间中包含一个名为 IHostedLifecycleService 的新接口。此接口继承自现有的 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);
}
实现此接口的所有已注册托管服务的 StartAsync 方法将在应用程序生命周期的早期运行,然后在任何已注册托管服务上调用 StartAsync(来自 IHostedService)。这可用于在应用程序启动之前执行一些非常早期的验证检查,例如检查关键需求或可用的依赖项。这使得应用程序可能在任何托管服务开始执行其主要工作负载之前启动失败。其他用途包括“预热”和初始化单例以及应用程序使用的其他状态。
注册托管服务的所有 StartAsync(来自 IHostedService)方法完成后,将在实现上调用 StartedAsync。这可用于在将应用程序标记为成功启动之前验证应用程序状态或条件。
StoppingAsync 和 StoppedAsync 在应用程序关闭期间的工作方式类似,并为关闭前和关闭后验证提供高级挂钩。
在讨论更详细的细节之前,值得讨论一下为什么 Microsoft 创建了一个新的派生接口,而不是利用默认接口实现来更新现有的 IHostedService 接口。对于默认接口实现来说,这确实是一个很好的例子,可以将它们添加到具有默认无操作实现的 IHostedService 中。当我们查看该库的运行时目标时,原因很明显。托管包多目标各种目标框架。这包括netstandard2.0,它在引入默认接口实现功能之前就被锁定了。因此,为了继续支持这个目标,改用衍生的接口设计。
作为引入此接口的 PR 的一部分,HostOptions 中还添加了一个新选项 StartupTimeout。这允许提供一个 TimeSpan 来控制所有托管服务启动所允许的最长时间。当配置为非无限值(默认值)时,传递给启动生命周期事件的取消令牌将链接到使用提供的值配置的 CancellationTokenSource。
使用新界面
使用 .NET 8 ,我们可以查看如何使用此新界面的一般示例。我在应用程序中看到的一项相当常见的启动工作是初始化数据库。
在生产过程中,我们可以预期此类数据库是在线的、可用的和种子的;在其他环境中,例如 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
builder.Services.AddHostedService
由于 .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。我们希望在应用程序生命周期的早期、任何主要工作负载之前执行数据库初始化。因此,我们可以将数据库设置代码包含在 GettingAsync 方法中。
派生自BackgroundService 的ServiceB 现在可以在其ExecuteAsync 方法内安全地使用数据库,因为ExecuteAsync 是由IHostedService 接口中定义的StartAsync 的底层实现调用的。因此,在注册服务的所有 StartingAsync 方法完成之前,不会调用它。
我们将以与 .NET 中相同的方式向 DI 容器注册这些服务,但添加它们的顺序不再重要。
builder.Services.AddHostedService
builder.Services.AddHostedService
即使按照这个顺序,ServiceA的StartingAsync也会在ServiceB.StartAsync之前执行。我们甚至可以配置并发启动和停止行为,而不会破坏我们的逻辑。
builder.Services.Configure
{
options.ServicesStartConcurrently =
true
;
options.ServicesStopConcurrently =
true
;
});
深入细节
引入新接口后,最核心的更改是在内部 Host 类内部实现的,该类实现了 IHost 接口。此类定义了主 Host,在从 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
new
();
_hostedServices = Services.GetRequiredService
_hostedLifecycleServices = GetHostLifecycles(_hostedServices);
bool
concurrent = _options.ServicesStartConcurrently;
bool
abortOnFirstException = !concurrent;
设置列表以包含启动期间的任何异常后,将从容器中检索所有已注册的 IHostedService。GetHostLifecycles 方法用于循环 IHostedService 实现并确定哪个(如果有)也实现 IHostedLifecycleService。
下一段代码为每个 IHostedLifecycleService 执行 GettingAsync 方法。
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(
IEnumerable services,
CancellationToken token,
bool concurrent,
bool abortOnFirstException,
List exceptions,
Func 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? 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;
}
}
}
}
}
代码基于布尔并发参数进行分支。让我们重点关注用于同时调用每个服务的功能的代码。
正如注释所述,实现首先在每个服务上调用操作委托,在本例中为“StartingAsync”。许多 IHostedLifecycleService 完全有可能通过返回缓存的 Task.CompletedTask 在其大多数方法上实现无操作。在这些情况下,代码同步运行,因为没有什么可等待的。上面的代码对此进行了特殊处理,并检查是否有任何任务立即返回完成或抛出同步异常。对于这些已完成的任务,其中引发的任何异常都会添加到异常列表中。
对于此时尚未完成的任何任务,它们都是异步运行的。这些任务将添加到任务列表中。所有任务启动后,就会使用 WhenAll 等待它们,这意味着它们会同时运行,直到所有注册的服务都完成其工作。任何异常也会在这里捕获。
在非并发路径中,代码更简单,因为它可以简单地按顺序等待每个操作。在此配置中,每个服务必须在调用下一个服务之前完成其工作。
返回 Host.StartAsync 方法,对 IHostedService.StartAsync 和 IHostedLifecycleService.StartedAsync 重复该过程。该方法最后会记录并重新抛出任何捕获的异常,然后触发托管应用程序现已启动的通知。
Stopping 和 Stopped 的新生命周期事件的实现几乎相同,因此我们无需在这里深入讨论。
概括
Microsoft 在每个版本中不断完善和增强 .NET 中的托管概念。在 .NET 8 中,重点放在引入对托管服务的启动行为的更多控制。在这项最新的工作中,他们引入了对启动和关闭之前、期间和之后运行的代码的高级、细粒度控制。新的 IHostedLifecycleService 接口出现在.NET 8 中,现已推出。