Código asincrónico en Startup ASP.NET Core: 4 formas de evitar GetAwaiter (). GetResult ()

Desde que se introdujo el mecanismo asíncrono / espera en C # 5.0, en todos los artículos y documentos se nos ha enseñado constantemente que usar código asíncrono en sincronía es muy malo. Y llaman al miedo como las construcciones GetAwaiter (). GetResult (). Sin embargo, hay un caso en el que los programadores de Microsoft mismos no desdeñan este diseño.



Antecedentes sobre la tarea laboral


Ahora estamos en el proceso de transición de la autenticación heredada a OAuth 2.0, que ya es un estándar en nuestra industria. El servicio en el que estoy trabajando ahora se ha convertido en un piloto para la integración con el nuevo sistema y para la transición a la autenticación JWT.

Durante el proceso de integración, experimentamos, considerando varias opciones, cómo reducir la carga en el proveedor de tokens (IdentityServer en nuestro caso) y garantizar una mayor confiabilidad de todo el sistema. Conectar la validación basada en JWT a ASP.NET Core es muy simple y no está vinculado a una implementación específica del proveedor de tokens:

services
      .AddAuthentication()
      .AddJwtBearer(); 

Pero, ¿qué se esconde detrás de estas dos líneas? Bajo su capó, se crea un JWTBearerHandler, que ya se ocupa de JWT desde el cliente API.


Interacción del cliente, la API y el proveedor de tokens cuando se solicita

Cuando JWTBearerHandler recibe el token del cliente, no envía el token al proveedor para su validación, sino que, por el contrario, solicita el proveedor de la clave de firma, la parte pública de la clave con la que se firma el token. En función de esta clave, se verifica que el token esté firmado por el proveedor correcto.

Dentro de JWTBearerHandler se encuentra HttpClient, que interactúa con el proveedor a través de la red. Pero, si suponemos que la Clave de firma de nuestro proveedor no planea cambiar con frecuencia, puede recogerla una vez que inicie la aplicación, guardar en caché y deshacerse de las constantes solicitudes de red.

Tengo este código para la clave de firma:

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

En la línea 12 nos encontramos con .GetAwaiter (). GetResult (). Esto se debe a que AuthenticationBuilder está configurado dentro de Public Void ConfigureServices (servicios IServiceCollection) {...} de la clase Startup, y este método no tiene una versión asincrónica. Problema.

Comenzando con C # 7.1, tenemos un Main () asíncrono. Pero los métodos de configuración de inicio asíncrono en Asp.NET Core aún no se han entregado. Estéticamente me molestó escribir GetAwaiter (). GetResult () (¡Me enseñaron a no hacer esto!), Así que me conecté en línea para buscar cómo otros lidian con este problema.

GetAwaiter (). GetResult () me molesta, pero Microsoft no


Uno de los primeros que encontré una opción que los programadores de Microsoft usaron en una tarea similar para obtener secretos de Azure KeyVault . Si atraviesas varias capas de abstracción, veremos:

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

Hola de nuevo, GetAwaiter (). GetResult ()! ¿Hay alguna otra solución?

Después de un breve google, encontré una serie de excelentes artículos de Andrew Lock, quien hace un año pensó en el mismo problema que yo. Incluso por las mismas razones: estéticamente no le gusta invocar sincrónicamente código asincrónico.

En general, recomiendo a todos los que estén interesados ​​en este tema que lean la serie completa de cinco artículos de Andrew. Allí, analiza en detalle qué tareas de trabajo conducen a este problema, luego considera varios enfoques incorrectos y solo luego describe las soluciones. En mi artículo intentaré presentar un breve resumen de su investigación, concentrándome más en las soluciones.

El papel de las tareas asincrónicas al iniciar un servicio web


Da un paso atrás para ver la imagen completa. ¿Cuál es el problema conceptual que intenté resolver, independientemente del marco?
Problema: es necesario iniciar el servicio web para que procese las solicitudes de sus clientes, pero al mismo tiempo hay un conjunto de operaciones (relativamente) largas, sin las cuales el servicio no puede responder al cliente, o sus respuestas serán incorrectas.
Ejemplos de tales operaciones:

  • Validación de configuraciones fuertemente tipadas.
  • Llenar el caché.
  • Conexión preliminar a la base de datos u otros servicios.
  • JIT y carga de ensamblaje (servicio de calentamiento).
  • Migración de bases de datos. Este es uno de los ejemplos de Andrew Lock, pero él mismo admite que, después de todo, esta operación no es deseable al iniciar el servicio .

Me gustaría encontrar una solución que le permita realizar tareas asincrónicas arbitrarias al inicio de la aplicación, y de forma natural para ellas, sin GetAwaiter (). GetResult ().

Estas tareas deben completarse antes de que la aplicación comience a aceptar solicitudes, pero para su trabajo pueden necesitar la configuración y los servicios registrados de la aplicación. Por lo tanto, estas tareas deben realizarse después de la configuración DI.

Esta idea se puede representar en forma de diagrama:


Solución n. ° 1: una solución de trabajo que puede confundir a los herederos


La primera solución de trabajo ofrecida por 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>();
}

Este enfoque fue posible gracias al advenimiento de Main () asíncrono de C # 7.1. Lo único negativo es que transferimos la parte de configuración de Startup.cs a Program.cs. Una solución no estándar para el marco ASP.NET puede confundir a una persona que heredará nuestro código.

Solución # 2: incrustar operaciones asincrónicas en DI


Por lo tanto, Andrew propuso una versión mejorada de la solución. Se declara una interfaz para tareas asincrónicas:

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

Además de un método de extensión que registra estas tareas en DI:

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

A continuación, se declara otro método de extensión, ya 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);
    }
}

Y en Program.cs cambiamos solo una línea. En lugar:

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

Nosotros llamamos:

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

En mi opinión, un gran enfoque que hace que trabajar con operaciones largas sea lo más transparente posible al iniciar la aplicación.

Solución # 3: para aquellos que cambiaron a ASP.NET Core 3.x


Pero si está utilizando ASP.NET Core 3.x, entonces hay otra opción. Me referiré nuevamente a un artículo de Andrew Lock .

Aquí está el código de inicio de WebHost de 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
    }
}

Y aquí está el mismo método en 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
    }
}

En ASP.NET Core 3.x, HostedServices se inicia por primera vez, y solo luego el WebHost principal, y anteriormente era exactamente lo contrario. ¿Qué nos da esto? Ahora todas las operaciones asincrónicas se pueden invocar dentro del método StartAsync (CancellationToken) de la interfaz IHostedService y lograr el mismo efecto sin crear interfaces y métodos de extensión separados.

Solución # 4: la historia del chequeo de salud y Kubernetes


Uno podría calmarse con esto, pero hay otro enfoque, y de repente resulta ser importante en las realidades actuales. Este es el uso del chequeo de salud.

La idea básica es iniciar el servidor Kestrel lo antes posible para informar al equilibrador de carga que el servicio está listo para aceptar solicitudes. Pero al mismo tiempo, todas las solicitudes de verificación que no sean de salud devolverán 503 (Servicio no disponible). Hay un artículo bastante extenso en el sitio de Microsoft sobre cómo usar la comprobación de estado en ASP.NET Core. Quería considerar este enfoque sin detalles especiales aplicados a nuestra tarea.

Andrew Lock tiene un artículo separado para este enfoque. Su principal ventaja es que evita los tiempos de espera de la red.
, , , . Kestrel , , « ».

No daré aquí la solución completa de Andrew Lock para el enfoque de verificación de salud. Es bastante voluminoso, pero no tiene nada de complicado.

Te lo diré en pocas palabras: debes iniciar el servicio web sin esperar a que finalicen las operaciones asincrónicas. En este caso, el punto final de comprobación de estado debe conocer el estado de estas operaciones, emitir 503 mientras se ejecutan y 200 cuando ya se han completado.

Honestamente, cuando estudié esta opción, tuve cierto escepticismo. Toda la solución parecía engorrosa en comparación con los enfoques anteriores. Y si dibujamos una analogía, entonces esta es la forma de usar nuevamente el enfoque EAP con la suscripción de eventos, en lugar de la async / wait ya familiar.

Pero entonces Kubernetes entró en juego. Él tiene su propio concepto de sonda de preparación. Citaré el libro "Kubernetes in Action" en mi presentación gratuita:
Siempre determine la sonda de preparación.

Si no tiene una sonda de preparación, sus pods se convierten en puntos finales de los servicios casi al instante. Si su aplicación tarda demasiado tiempo en prepararse para aceptar solicitudes entrantes, las solicitudes de los clientes para el servicio también podrán iniciar pods que aún no están listos para aceptar conexiones entrantes. Como resultado, los clientes recibirán un error de "Conexión rechazada".

Realicé un experimento simple: creé un servicio ASP.NET Core 3 con una tarea asincrónica larga en 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)
    {...}
}

Cuando comencé este servicio usando minikube, y luego aumenté el número a dos, dentro de los 5 segundos del retraso, cada segundo pedido mío no produjo información útil, pero "Conexión rechazada".

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

recomendaciones


¿Qué conclusión se puede sacar de todos estos estudios? Quizás todos deberían decidir para su proyecto qué solución es la más adecuada. Si se supone que la operación asincrónica no tomará demasiado tiempo y los clientes tienen algún tipo de política de reintento, puede usar todos los enfoques, comenzando con GetAwaiter (). GetResult () y terminando con IHostedService en ASP.NET Core 3.x.

Por otro lado, si usa Kubernetes y sus operaciones asincrónicas se pueden realizar durante un tiempo notablemente largo, entonces no puede prescindir de un control de estado (también conocido como preparación / sonda de inicio).

All Articles