Código assíncrono no Startup ASP.NET Core: 4 maneiras de contornar o GetAwaiter (). GetResult ()

Desde que o mecanismo async / waitit foi introduzido no C # 5.0, fomos constantemente ensinados em todos os artigos e documentos que o uso de código assíncrono em síncrono é muito ruim. E eles pedem medo como construções GetAwaiter (). GetResult (). No entanto, há um caso em que os próprios programadores da Microsoft não desdenham esse design.



Antecedentes sobre a tarefa de trabalho


Agora, estamos no processo de transição da autenticação herdada para o OAuth 2.0, que já é um padrão em nosso setor. O serviço em que estou trabalhando agora se tornou um piloto para a integração com o novo sistema e para a transição para a autenticação JWT.

Durante o processo de integração, experimentamos, considerando várias opções, como reduzir a carga no provedor de token (IdentityServer no nosso caso) e garantir maior confiabilidade de todo o sistema. A conexão da validação baseada em JWT ao ASP.NET Core é muito simples e não está vinculada a uma implementação específica do provedor de token:

services
      .AddAuthentication()
      .AddJwtBearer(); 

Mas o que está oculto por trás dessas duas linhas? Sob o capô, é criado um JWTBearerHandler, que já lida com o JWT do cliente da API.


Interação do cliente, API e provedor de token ao solicitar

Quando o JWTBearerHandler recebe o token do cliente, ele não envia o token ao provedor para validação, mas, pelo contrário, solicita o provedor de Chave de Assinatura - a parte pública da chave com a qual o token está assinado. Com base nessa chave, verifica-se que o token é assinado pelo provedor correto.

Dentro do JWTBearerHandler está o HttpClient, que interage com o provedor pela rede. Porém, se assumirmos que a Chave de assinatura do nosso provedor não planeja mudar com frequência, você poderá buscá-la uma vez ao iniciar o aplicativo, armazenar em cache e se livrar de solicitações de rede constantes.

Eu recebi este código para a Chave de assinatura:

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;
}

Na linha 12, encontramos .GetAwaiter (). GetResult (). Isso ocorre porque o AuthenticationBuilder está configurado dentro do Public void ConfigureServices (serviços IServiceCollection) {...} da classe Startup, e esse método não possui uma versão assíncrona. Problema.

Começando com C # 7.1, temos um Main () assíncrono. Mas os métodos de configuração de inicialização assíncrona no Asp.NET Core ainda não foram entregues. Eu estava esteticamente preocupado em escrever GetAwaiter (). GetResult () (fui ensinado a não fazer isso!). Então, fiquei on-line para procurar como os outros lidam com esse problema.

Estou incomodado com GetAwaiter (). GetResult (), mas a Microsoft não é


Uma das primeiras descobri uma opção usada pelos programadores da Microsoft em uma tarefa semelhante para obter segredos do Azure KeyVault . Se você passar por várias camadas de abstração, veremos:

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

Olá novamente, GetAwaiter (). GetResult ()! Existem outras soluções?

Depois de um breve google, encontrei uma série de excelentes artigos de Andrew Lock, que há um ano pensava no mesmo problema que eu. Mesmo pelas mesmas razões - ele esteticamente não gosta de chamar sincronicamente código assíncrono.

Em geral, recomendo a todos os interessados ​​neste tópico que leiam toda a série de cinco artigos de Andrew. Lá, ele analisa em detalhes quais tarefas de trabalho levam a esse problema, depois considera várias abordagens incorretas e só depois descreve soluções. No meu artigo, tentarei apresentar um breve aperto de sua pesquisa, concentrando-me mais em soluções.

O papel das tarefas assíncronas no início de um serviço da web


Dê um passo para trás para ver a foto inteira. Qual é o problema conceitual que tentei resolver, independentemente da estrutura?
Problema: é necessário iniciar o serviço da Web para processar as solicitações de seus clientes, mas há um conjunto de algumas operações (relativamente) longas, sem as quais o serviço não pode responder ao cliente ou suas respostas estarão incorretas.
Exemplos de tais operações:

  • Validação de configurações fortemente tipadas.
  • Preenchendo o cache.
  • Conexão preliminar ao banco de dados ou outros serviços.
  • Carregamento de JIT e montagem (aquecimento de serviço).
  • Migração de banco de dados. Este é um dos exemplos de Andrew Lock, mas ele próprio admite que, afinal, essa operação é indesejável ao iniciar o serviço .

Gostaria de encontrar uma solução que permita executar tarefas assíncronas arbitrárias na inicialização do aplicativo e de maneira natural para elas, sem GetAwaiter (). GetResult ().

Essas tarefas devem ser concluídas antes que o aplicativo comece a aceitar solicitações, mas, para o trabalho delas, podem precisar da configuração e dos serviços registrados do aplicativo. Portanto, essas tarefas devem ser executadas após a configuração do DI.

Esta ideia pode ser representada na forma de um diagrama:


Solução 1: uma solução funcional que pode confundir os herdeiros


A primeira solução de trabalho oferecida pela 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>();
}

Essa abordagem foi possível graças ao advento do Main assíncrono () do C # 7.1. Seu único aspecto negativo é que transferimos a parte da configuração do Startup.cs para o Program.cs. Uma solução não padrão para a estrutura do ASP.NET pode confundir uma pessoa que herdará nosso código.

Solução 2: incorporar operações assíncronas no DI


Portanto, Andrew propôs uma versão aprimorada da solução. Uma interface para tarefas assíncronas é declarada:

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

Bem como um método de extensão que registra essas tarefas no DI:

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

Em seguida, outro método de extensão é declarado, já para 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);
    }
}

E no Program.cs, mudamos apenas uma linha. Em vez de:

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

Nós chamamos:

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

Na minha opinião, uma ótima abordagem que torna o trabalho com operações longas o mais transparente possível ao iniciar o aplicativo.

Solução nº 3: para quem mudou para o ASP.NET Core 3.x


Mas se você estiver usando o ASP.NET Core 3.x, existe outra opção. Voltarei a referir-me a um artigo de Andrew Lock .

Aqui está o código de inicialização do WebHost do ASP.NET Core 2.x:

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
    }
}

E aqui está o mesmo método no 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
    }
}

No ASP.NET Core 3.x, o HostedServices é iniciado pela primeira vez, e somente então o WebHost principal, e anteriormente era exatamente o oposto. O que isso nos dá? Agora todas as operações assíncronas podem ser chamadas dentro do método StartAsync (CancellationToken) da interface IHostedService e obter o mesmo efeito sem criar interfaces e métodos de extensão separados.

Solução # 4: a história do exame de saúde e do Kubernetes


Alguém poderia se acalmar com isso, mas há outra abordagem, que de repente se torna importante nas realidades atuais. Este é o uso da verificação de saúde.

A idéia básica é iniciar o servidor Kestrel o mais cedo possível para informar o balanceador de carga de que o serviço está pronto para aceitar solicitações. Mas, ao mesmo tempo, todas as solicitações de verificação de integridade retornarão 503 (Serviço Indisponível). Há um artigo bastante extenso no site da Microsoft sobre como usar a verificação de integridade no ASP.NET Core. Eu queria considerar essa abordagem sem detalhes especiais aplicados à nossa tarefa.

Andrew Lock tem um artigo separado para essa abordagem. Sua principal vantagem é que evita o tempo limite da rede.
, , , . Kestrel , , « ».

Não darei aqui a solução completa da Andrew Lock para a abordagem de verificação de saúde. É bastante volumoso, mas não há nada complicado.

Em poucas palavras, vou lhe dizer: você precisa iniciar o serviço da Web sem esperar pela conclusão das operações assíncronas. Nesse caso, o terminal de verificação de integridade deve estar ciente do status dessas operações, emitir 503 enquanto elas estão sendo executadas e 200 quando já tiverem sido concluídas.

Honestamente, quando estudei essa opção, tive um certo ceticismo. Toda a solução parecia complicada em comparação com as abordagens anteriores. E se traçarmos uma analogia, é assim que usaremos novamente a abordagem EAP com assinatura de eventos, em vez do já assíncrono / aguardado já familiar.

Mas então Kubernetes entrou em cena. Ele tem seu próprio conceito de sonda de prontidão. Vou citar o livro “Kubernetes em Ação” em minha apresentação gratuita:
Sempre determine a sonda de prontidão.

Se você não tiver uma análise de prontidão, seus pods se tornarão pontos finais de serviços quase que instantaneamente. Se o seu aplicativo demorar muito para se preparar para aceitar solicitações recebidas, as solicitações do cliente pelo serviço também iniciarão os pods que ainda não estão prontos para aceitar as conexões recebidas. Como resultado, os clientes receberão um erro "Conexão recusada".

Realizei um experimento simples: criei um serviço ASP.NET Core 3 com uma longa tarefa assíncrona no HostedService:

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)
    {...}
}

Quando iniciei este serviço usando o minikube e depois aumentei o número para dois, dentro de 5 segundos após o atraso, cada segundo pedido meu não produzia informações úteis, mas “Conexão recusada”.

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

achados


Que conclusão pode ser tirada de todos esses estudos? Talvez todos devam decidir em seu projeto qual solução é a mais adequada. Se for assumido que a operação assíncrona não levará muito tempo e os clientes tiverem algum tipo de política de nova tentativa, você poderá usar todas as abordagens, começando com GetAwaiter (). GetResult () e terminando com IHostedService no ASP.NET Core 3.x.

Por outro lado, se você usar o Kubernetes e suas operações assíncronas puderem ser executadas por um tempo notavelmente longo, não será possível fazer uma verificação de integridade (também conhecida como teste de prontidão / inicialização).

All Articles