[.NET 6] IHostedService 的呼叫等等我的爱——等待Web应用准备就绪

目录

序言

1.困扰

2. 查找侦听的端口

3. .NET 6 中的 IHostedService 启动顺序

4.使用 IHostApplicationLifetime 接收应用状态通知

5. 等待应用在后台服务中准备就绪


序言

在这篇文章中,我将介绍如何等待 ASP.NET Core 应用是否已经启动好,已经开始可以接收来自 .NET 6 中的 / 中的请求了。如果你需要编写IHostedService BackgroundService IHostedService,并向 ASP.NET Core 应用发送请求,如果它需要查找应用正在侦听的 URL,或者如果需要等待应用完全启动,这会很有用。

图片

1.困扰

如果在应用中同时启用了多个IHostedService,已开始多个背景任务,那么一个有趣的现象是,其他的任务总是比 web应用启动的快,这有时候会导致一些异常。

当然,除了有些无力的控制感以外,并没有啥大不了的,除非真的有其他需求要求我们必须这么做。

而现在我就遇到了一个类似的问题,我想在背景任务中获取应用已经绑定的端口,啊哈哈,什么,应用还没有启动,那我们搞个毛线…

2. 查找侦听的端口

查找 ASP.NET Core 应用程序正在侦听的 URL 非常容易。

如果使用依赖关系注入获取实例,则可以检查属性 IServerIServerAddressesFeatureFeaturesAddresses, 这将公开列出地址的属性。

void PrintAddresses(IServiceProvider services)
{
    Console.WriteLine("Checking addresses...");
    var server = services.GetRequiredService<IServer>();
    var addressFeature = server.Features.Get<IServerAddressesFeature>();
    foreach(var address in addressFeature.Addresses)
    {
        Console.WriteLine("Listing on address: " + address);
    }
}

这个方法不算什么。
可是,怎么确定web应用已经启动了呢?

3. .NET 6 中的 IHostedService 启动顺序

图片

在 .NET Core 2.x 中,引入通用抽象之前,应用程序将在 Kestrel 完全配置并开始侦听请求后启动。

有点讽刺的是,当时的原因不适合运行异步启动任务(它们在 Kestrel 之后开始), 在.NET Core 3.0中,当 ASP.NET Core在通用主机之上重新构建平台时,情况发生了变化。现在 Kestrel 将作为一个自身主机运行,它将在所有其他主机服务之后最后启动。 这使得 IHostedService 非常适合异步启动任务,但现在你不能依赖 Kestrel 在运行时可用。

在 .NET 6 中,随着最小托管 API 的引入,情况再次发生了轻微变化。使用这些托管 API,您可以创建令人难以置信的简洁程序(不需要类和“魔术”方法名称等),但在创建和启动方式方面存在一些差异。

具体来说,在调用StartupIHostedServicesWebApplication.Run()时启动 ,通常是在配置中间件和端点之后:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHostedService<TestHostedService>();
var app = builder.Build();

app.MapGet("/", () => "Hello World!");

app.Run(); // 👈 TestHostedService 在这里启动

这与 .NET Core 3.x/.NET 5/ 方案略有不同,在 .NET Core 3.x/.NET 5/ 方案中,托管服务将在调用方法之前启动。现在,所有端点和中间件都已添加,并且只有在调用IHostStartup.Configure() WebApplication.Run()时,才会启动所有托管服务。

对于我们的方案,这种差异不一定会改变任何内容,但如果需要在配置中间件路由之前启动,则需要注意这一点。

最终结果是,我们不能依赖 Kestrel 已经开始,并在您的IHostedServiceBackgroundService运行时可用,因此我们需要一种在我们的服务中等待它的方法。

4.使用 IHostApplicationLifetime 接收应用状态通知

图片

幸运的是,所有 Core 3.x+ 应用程序中都提供一项服务。ASP.NET 可以在应用程序完成启动并处理请求后立即通知您。

这就是 IHostApplicationLifetime,此接口包括 3 个属性,可以通知您有关应用程序生命周期的各个阶段,以及一种触发应用程序关闭的方法。

public interface IHostApplicationLifetime
{
    CancellationToken ApplicationStarted { get; }
    CancellationToken ApplicationStopping { get; }
    CancellationToken ApplicationStopped { get; }
    void StopApplication();
}

正如你看到的, 每个属性都是一个取消token令牌, 这对于接收通知来看,看起来是一个老的选择(当你的程序启动后,没有什么被取消),但是它提供了方法在事件发生时可以安全的运行回调。

public void PrintStartedMessage(IHostApplicationLifetime lifetime)
{
    lifetime.ApplicationStarted.Register(() => Console.WriteLine("App has started!"));
}

如上所示,您可以调用并传入在应用启动时执行的Register(Action) 。同样,您可以接收其他状态的通知,例如“正在停止”或“已停止”。

例如,停止回调特别有用,因为它允许您阻止关闭,直到回调完成,让您有机会清理资源或执行其他长时间运行的清理。

虽然这很有用,但它只是拼图的一部分。我们需要在应用程序启动时运行一些异步代码(例如调用HTTP API),那么我们如何安全地做到这一点呢?

5. 等待应用在后台服务中准备就绪

让我们从具体的东西开始,我们想要“阻止它” BackgroundService,直到应用程序启动:

public class TestHostedService: BackgroundService
{
    private readonly IServiceProvider _services;
    public TestHostedService(IServiceProvider services)
    {
        _services = services;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // TODO: wait here until Kestrel is ready

        PrintAddresses(_services);
        await DoSomethingAsync();
    }
}

在初始方法中,我们可以使用 IHostApplicationLifetime和一个简单的 bool 等待应用程序准备就绪,一直循环直到我们收到该信号:

public class TestHostedService: BackgroundService
{
    private readonly IServiceProvider _services;
    private volatile bool _ready = false; // 👈 标志位
    public TestHostedService(IServiceProvider services, IHostApplicationLifetime lifetime)
    {
        _services = services;
        lifetime.ApplicationStarted.Register(() => _ready = true); // 👈 如果启动,在这里更新
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while(!_ready)
        {
            // 如果app没有启动,则等待
            await Task.Delay(1_000)
        }

        PrintAddresses(_services);
        await DoSomethingAsync();
    }
}

这十分有效,但代码并不漂亮优雅。

该方法每秒都在检查字段,如果未设置,则会再次进入睡眠状态。这种情况可能不会发生太多次(除非你的应用程序启动非常慢),但它仍然感觉有点混乱

我现在明确忽略传递给方法stoppingToken,我们稍后再讨论!

我发现的最干净的方法是使用帮助程序类作为“已启动”取消令牌信号和我们需要运行的异步代码之间的中介。
理想情况下,我们希望在接收到信号时完成。以下代码就是用来执行此操作的:

public class TestHostedService: BackgroundService
{
    private readonly IServiceProvider _services;
    private readonly IHostApplicationLifetime _lifetime;
    private readonly TaskCompletionSource _source = new(); // 👈 增加任务完成源
    public TestHostedService(IServiceProvider services, IHostApplicationLifetime lifetime)
    {
        _services = services;
        _lifetime = lifetime;

        // 👇 在这里设置 TaskCompletionSource
        _lifetime.ApplicationStarted.Register(() => _source.SetResult()); 
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await _source.Task.ConfigureAwait(false); // 等待任务完成

        PrintAddresses(_services);
        await DoSomethingAsync();
    }
}

哈哈~~~~ 这种方法要好得多。

我们没有使用轮询来设置字段,而是有一个await ,它在事件触发时完成。这是建议的方法,任何时候你发现自己想要“等待”都可以这样的搞定。

但是,代码中存在潜在问题。如果应用程序永远无法启动怎么办!?

如果令牌从未触发,则我们的ApplicationStartedTaskCompletionSource永远不会完成,并且该方法将永远不会完成!这不太可能,但例如,如果启动应用程序时出现问题,则可能会发生这种情况。

幸运的是,通过使用stoppingToken传递给ExecuteAsync和另一个TaskCompletionSource来解决此问题!例如:

public class TestHostedService: BackgroundService
{
    private readonly IServiceProvider _services;
    private readonly IHostApplicationLifetime _lifetime;
    private readonly TaskCompletionSource _source = new();
    public TestHostedService(IServiceProvider services, IHostApplicationLifetime lifetime)
    {
        _services = services;
        _lifetime = lifetime;

        _lifetime.ApplicationStarted.Register(() => _source.SetResult());
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // 👇 建立一个 stoppingToken的 TaskCompletionSource
        var tcs = new TaskCompletionSource();
        stoppingToken.Register(() => tcs.SetResult());

        // 等待任意一个源完成
        await Task.WhenAny(tcs.Task, _source.Task).ConfigureAwait(false);

        // 如果是停止,则返回
        if (stoppingToken.IsCancellationRequested)
        {
            return;
        }

        // 否则, App已经准备好了,干吧,兄弟。
        PrintAddresses(_services);
        await DoSomethingAsync();
    }
}

这段代码稍微复杂了一些,但它可以优雅地处理我们需要的一切。
我们甚至可以将其提取到一个方便的辅助方法中。

public class TestHostedService: BackgroundService
{
    private readonly IServiceProvider _services;
    private readonly IHostApplicationLifetime _lifetime;
    public TestHostedService(IServiceProvider services, IHostApplicationLifetime lifetime)
    {
        _services = services;
        _lifetime = lifetime;

    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        if (!await WaitForAppStartup(_lifetime, stoppingToken))
        {
            return;
        }

        PrintAddresses(_services);
        await DoSomethingAsync();
    }

    static async Task<bool> WaitForAppStartup(IHostApplicationLifetime lifetime, CancellationToken stoppingToken)
    {
        var startedSource = new TaskCompletionSource();
        var cancelledSource = new TaskCompletionSource();

        using var reg1 = lifetime.ApplicationStarted.Register(() => startedSource.SetResult());
        using var reg2 = stoppingToken.Register(() => cancelledSource.SetResult());

        Task completedTask = await Task.WhenAny(
            startedSource.Task,
            cancelledSource.Task).ConfigureAwait(false);

        // If the completed tasks was the "app started" task, return true, otherwise false
        return completedTask == startedSource.Task;
    }
}

无论您采用哪种方法,您现在都可以执行后台任务代码了,因为亲爱的,您已经知道 Kestrel 会监听!

引入引入

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值