启动ASP.NET Core中的异步代码:避开GetAwaiter()的4种方法。

自从C#5.0中引入async / await机制以来,所有文章和文档中都不断教导我们在同步中使用异步代码非常糟糕。他们呼吁像GetAwaiter()。GetResult()那样的恐惧。但是,在某些情况下,Microsoft程序员本身并不轻视此设计。



工作任务的背景


我们现在正在从传统身份验证过渡到OAuth 2.0,OAuth 2.0已经成为我们行业的标准。我现在正在使用的服务已经成为与新系统集成以及向JWT身份验证过渡的试验。

在集成过程中,我们进行了实验,考虑了各种选项,如何减轻令牌提供者(在我们的情况下为IdentityServer)的负担,并确保整个系统的更高可靠性。将基于JWT的验证连接到ASP.NET Core非常简单,并且与令牌提供程序的特定实现无关:

services
      .AddAuthentication()
      .AddJwtBearer(); 

但是这两行背后隐藏着什么?在他们的幕后,创建了一个JWTBearerHandler,它已经处理了来自API客户端的JWT。


请求

客户端,API和令牌提供者之间的交互JWTBearerHandler从客户端收到令牌时,它不会将令牌发送给提供者进行验证,相反,它请求Signing Key provider(签名密钥提供者)-签名令牌的密钥的公共部分。根据此密钥,验证令牌是否由正确的提供者签名。

在JWTBearerHandler中放置HttpClient,它通过网络与提供程序进行交互。但是,如果我们假设提供者的签名密钥不打算经常更改,那么在启动应用程序,缓存自己并摆脱持续的网络请求时,您可以选择一次。

我获得了用于签名密钥的代码:

public static AuthenticationBuilder AddJwtAuthentication(this AuthenticationBuilder builder, AuthJwtOptions options)
{
    var signingKeys = new List<SecurityKey>();

    var jwtBearerOptions = new JwtBearerOptions {Authority = options?.Authority};
    
    new JwtBearerPostConfigureOptions().PostConfigure(string.Empty, jwtBearerOptions);
    try
    {
        var config = jwtBearerOptions.ConfigurationManager
            .GetConfigurationAsync(new CancellationTokenSource(options?.AuthorityTimeoutInMs ?? 5000).Token)
            .GetAwaiter().GetResult();
        var providerSigningKeys = config.SigningKeys;
        signingKeys.AddRange(providerSigningKeys);
    }
    catch (Exception)
    {
        // ignored
    }

    builder
        .AddJwtBearer(options =>
        {
            options.TokenValidationParameters = new TokenValidationParameters
            {
                // ...
                IssuerSigningKeys = signingKeys,
                // ...
            };
        });
    return builder;
}

在第12行中,我们遇到了.GetAwaiter()。GetResult()。这是因为AuthenticationBuilder是在Startup类的公共void ConfigureServices(IServiceCollection服务){...}内配置的,并且此方法没有异步版本。麻烦。

从C#7.1开始,我们有一个异步的Main()。但是Asp.NET Core中的异步启动配置方法尚未交付。从美学上讲,我很讨厌写GetAwaiter()。GetResult()(被教我不要这样做!),所以我上网寻找其他人如何处理这个问题。

我被GetAwaiter()。GetResult()困扰,但Microsoft却没有


我首先找到了一个选项,Microsoft程序员在类似的任务中使用了该选项来从Azure KeyVault中获取秘密。如果您经过几层抽象,那么我们将看到:

public override void Load() => LoadAsync().ConfigureAwait(false).GetAwaiter().GetResult();

再次问好,GetAwaiter()。GetResult()!还有其他解决方案吗?

经过短暂的谷歌搜索后,我发现了安德鲁·洛克 Andrew Lock)撰写一系列精彩文章,一年前安德鲁·洛克 Andrew Lock)曾想到与我同一个问题。即使出于相同的原因-从美学上讲,他也不喜欢同步调用异步代码。

通常,我建议所有对此主题感兴趣的人阅读安德鲁撰写的五篇文章的整个系列。他在那里详细分析了哪些工作任务导致了此问题,然后考虑了几种错误的方法,然后仅描述了解决方案。在我的文章中,我将尝试简要介绍他的研究,将重点更多地放在解决方案上。

异步任务在启动Web服务中的作用


退后一步查看整个图片。无论采用哪种框架,我都想解决什么概念性问题?
问题:有必要启动Web服务,以便它处理其客户端的请求,但是存在一组(相对)冗长的操作,没有这些操作,服务将无法响应客户端,否则其答案将是不正确的。
此类操作的示例:

  • 验证强类型的配置。
  • 填充缓存。
  • 与数据库或其他服务的初步连接。
  • JIT和程序集加载(服务预热)。
  • 数据库迁移。这是安德鲁·洛克(Andrew Lock)的例子之一,但他本人也承认,毕竟,在启动服务时此操作是不可取的

我想找到一个解决方案,该解决方案允许您在应用程序启动时以自然的方式执行任意异步任务,而无需GetAwaiter()。GetResult()。

这些任务必须在应用程序开始接受请求之前完成,但是对于它们的工作,他们可能需要配置和注册应用程序的服务。因此,必须在DI配置之后执行这些任务。

这个想法可以用图表的形式表示:


解决方案1:可能会使继承人感到困惑的可行解决方案


Lock提供 的第一个工作解决方案

public class Program
{
   public static async Task Main(string[] args)
   {
       IWebHost webHost = CreateWebHostBuilder(args).Build();

       using (var scope = webHost.Services.CreateScope())
       {
           //   
           var myService = scope.ServiceProvider.GetRequiredService<MyService>();

           await myService.DoAsyncJob();
       }

       await webHost.RunAsync();
   }

   public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
       WebHost.CreateDefaultBuilder(args)
           .UseStartup<Startup>();
}

C#7.1中异步Main()的出现使这种方法成为可能。唯一的负面影响是我们将配置部分从Startup.cs转移到Program.cs。这种针对ASP.NET框架的非标准解决方案可能会使继承我们代码的人感到困惑。

解决方案2:在DI中嵌入异步操作


因此,安德鲁提出了该解决方案的改进版本。声明了异步任务的接口:

public interface IStartupTask
{
    Task ExecuteAsync(CancellationToken cancellationToken = default);
}

以及在DI中注册这些任务的扩展方法:

public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddStartupTask<T>(this IServiceCollection services)
        where T : class, IStartupTask
        => services.AddTransient<IStartupTask, T>();
}

接下来,声明了另一个扩展方法,已经用于IWebHost:

public static class StartupTaskWebHostExtensions
{
    public static async Task RunWithTasksAsync(this IWebHost webHost, CancellationToken cancellationToken = default)
    {
        //      DI
        var startupTasks = webHost.Services.GetServices<IStartupTask>();

        //   
        foreach (var startupTask in startupTasks)
        {
            await startupTask.ExecuteAsync(cancellationToken);
        }

        //    
        await webHost.RunAsync(cancellationToken);
    }
}

在Program.cs中,我们仅更改一行。代替:

await CreateWebHostBuilder(args).Build().Run();

我们称之为:

await CreateWebHostBuilder(args).Build().RunWithTasksAsync();

我认为,一种很棒的方法可以使启动应用程序时长时间操作的工作尽可能透明。

解决方案#3:对于那些切换到ASP.NET Core 3.x的用户


但是,如果您使用的是ASP.NET Core 3.x,则还有另一种选择。我将再次参考Andrew Lock的文章

这是ASP.NET Core 2.x的WebHost启动代码:

public class WebHost
{
    public virtual async Task StartAsync(CancellationToken cancellationToken = default)
    {
        // ... initial setup
        await Server.StartAsync(hostingApp, cancellationToken).ConfigureAwait(false);

        // Fire IApplicationLifetime.Started
        _applicationLifetime?.NotifyStarted();

        // Fire IHostedService.Start
        await _hostedServiceExecutor.StartAsync(cancellationToken).ConfigureAwait(false);

        // ...remaining setup
    }
}

这是ASP.NET Core 3.0中的相同方法

public class WebHost
{
    public virtual async Task StartAsync(CancellationToken cancellationToken = default)
    {
        // ... initial setup

        // Fire IHostedService.Start
        await _hostedServiceExecutor.StartAsync(cancellationToken).ConfigureAwait(false);

        // ... more setup
        await Server.StartAsync(hostingApp, cancellationToken).ConfigureAwait(false);

        // Fire IApplicationLifetime.Started
        _applicationLifetime?.NotifyStarted();

        // ...remaining setup
    }
}

在ASP.NET Core 3.x中,首先启动HostedServices,然后才启动主WebHost,与之相反。这给了我们什么?现在,可以在IHostedService接口的StartAsync(CancellationToken)方法内部调用所有异步操作,并且无需创建单独的接口和扩展方法即可达到相同的效果。

解决方案4:健康检查和Kubernetes的故事


一个人可以对此平静下来,但是有另一种方法,突然间它在当前现实中变得很重要。这是使用健康检查。

基本思想是尽早启动Kestrel服务器,以通知负载平衡器该服务已准备好接受请求。但是同时,所有非健康检查请求都将返回503(服务不可用)。 Microsoft网站上有一篇相当广泛的文章内容涉及如何在ASP.NET Core中使用运行状况检查。我想考虑这种方法,但没有适用于我们任务的特殊细节。

安德鲁·洛克(Andrew Lock)对此方法另有单独的文章。它的主要优点是可以避免网络超时。
, , , . Kestrel , , « ».

在此,我将不为健康检查方法提供完整的Andrew Lock解决方案。它非常庞大,但是没有什么复杂的。

简而言之,您将需要启动Web服务,而不必等待异步操作的完成。在这种情况下,运行状况检查端点应了解这些操作的状态,在执行这些操作时发出503,在它们完成时发出200。

老实说,当我研究此选项时,我有一定的怀疑态度。与以前的方法相比,整个解决方案看起来很麻烦。如果我们进行类比,那么这就是如何再次将EAP方法与事件订阅一起使用,而不是已经熟悉的异步/等待。

但是随后,Kubernetes开始发挥作用。他有自己的准备调查概念。我将在我的免费演讲中引用“实践中的Kubernetes”一书:
始终确定准备情况探针。

如果您没有准备情况调查,那么您的Pod几乎会立即成为服务的端点。如果您的应用程序花费太多时间准备接受传入请求,则客户端对服务的请求也将启动尚未准备好接受传入连接的Pod。结果,客户端将收到“连接被拒绝”错误。

我进行了一个简单的实验:我在HostedService中创建了一个带有长时间异步任务的ASP.NET Core 3服务:

public class LongTaskHostedService : IHostedService
{
    public async Task StartAsync(CancellationToken cancellationToken)
    {
            Console.WriteLine("Long task started...");
            await Task.Delay(5000, cancellationToken);
            Console.WriteLine("Long task finished.");
    }

    public Task StopAsync(CancellationToken cancellationToken)
    {...}
}

当我使用minikube启动此服务,然后在延迟的5秒钟内将其数目增加到2以下时,我的第二个请求并没有产生有用的信息,而是“连接被拒绝”。

Kubernetes 1.16 UPD
, , Kubernetes 1.16 startup probe ( ). , readiness probe. . .

发现


从所有这些研究中可以得出什么结论?也许每个人都应该为自己的项目决定哪种解决方案最合适。如果假定异步操作不会花费太多时间,并且客户端具有某种重试策略,则可以使用所有方法,从ASP.NET Core 3.x中的GetAwaiter().GetResult()开始,以IHostedService结束。

另一方面,如果您使用Kubernetes并且您的异步操作可以执行很长时间,那么您就不能没有运行状况检查(也称为就绪/启动探针)。

All Articles