Code asynchrone dans Startup ASP.NET Core: 4 façons de contourner GetAwaiter (). GetResult ()

Depuis que le mécanisme asynchrone / attente a été introduit dans C # 5.0, nous avons constamment appris dans tous les articles et documents que l'utilisation de code asynchrone en synchronisme est très mauvaise. Et ils appellent à la peur comme les constructions GetAwaiter (). GetResult (). Cependant, il existe un cas où les programmeurs Microsoft eux-mêmes ne dédaignent pas cette conception.



Contexte de la tâche de travail


Nous sommes en train de passer de l'authentification héritée à OAuth 2.0, qui est déjà une norme dans notre industrie. Le service sur lequel je travaille maintenant est devenu un pilote pour l'intégration avec le nouveau système et pour la transition vers l'authentification JWT.

Au cours du processus d'intégration, nous avons expérimenté, en considérant diverses options, comment réduire la charge sur le fournisseur de jetons (IdentityServer dans notre cas) et assurer une plus grande fiabilité de l'ensemble du système. La connexion de la validation basée sur JWT à ASP.NET Core est très simple et n'est pas liée à une implémentation spécifique du fournisseur de jeton:

services
      .AddAuthentication()
      .AddJwtBearer(); 

Mais qu'est-ce qui se cache derrière ces deux lignes? Sous leur capot, un JWTBearerHandler est créé, qui traite déjà avec JWT du client API.


Interaction du client, de l'API et du fournisseur de jetons lors de la demande

Lorsque le JWTBearerHandler reçoit le jeton du client, il n'envoie pas le jeton au fournisseur pour validation, mais demande au contraire au fournisseur de clés de signature - la partie publique de la clé avec laquelle le jeton est signé. Sur la base de cette clé, il est vérifié que le jeton est signé par le bon fournisseur.

À l'intérieur de JWTBearerHandler se trouve HttpClient, qui interagit avec le fournisseur sur le réseau. Mais, si nous supposons que la clé de signature de notre fournisseur ne prévoit pas de changer souvent, vous pouvez la récupérer une fois lorsque vous démarrez l'application, vous mettre en cache et vous débarrasser des demandes réseau constantes.

J'ai obtenu ce code pour la clé de signature:

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

À la ligne 12, nous rencontrons .GetAwaiter (). GetResult (). En effet, AuthenticationBuilder est configuré à l'intérieur de ConfigureServices void public (services IServiceCollection) {...} de la classe Startup, et cette méthode n'a pas de version asynchrone. Difficulté.

À partir de C # 7.1, nous avons un Main () asynchrone. Mais les méthodes de configuration de démarrage asynchrone dans Asp.NET Core n'ont pas encore été livrées. J'ai été esthétiquement gêné d'écrire GetAwaiter (). GetResult () (on m'a appris à ne pas faire ça!), Alors je suis allé en ligne pour chercher comment les autres gèrent ce problème.

Je suis gêné par GetAwaiter (). GetResult (), mais Microsoft n'est pas


L'un des premiers, j'ai trouvé une option que les programmeurs Microsoft ont utilisée dans une tâche similaire pour obtenir des secrets d'Azure KeyVault . Si vous descendez à travers plusieurs couches d'abstraction, alors nous verrons:

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

Bonjour Ă  nouveau, GetAwaiter (). GetResult ()! Y a-t-il d'autres solutions?

Après un court google, j'ai trouvé une série d'excellents articles d' Andrew Lock, qui, il y a un an, a pensé au même problème que moi. Même pour les mêmes raisons - il n'aime pas esthétiquement l'invocation synchrone de code asynchrone.

En général, je recommande à tous ceux qui s'intéressent à ce sujet de lire l'intégralité de la série de cinq articles d'Andrew. Là, il analyse en détail quelles tâches de travail conduisent à ce problème, puis considère plusieurs approches incorrectes et ne décrit que des solutions. Dans mon article, je vais essayer de présenter une brève compression de ses recherches, en me concentrant davantage sur les solutions.

Le rôle des tâches asynchrones dans le démarrage d'un service Web


Faites un pas en arrière pour voir l'image entière. Quel est le problème conceptuel que j'ai essayé de résoudre, quel que soit le cadre?
Problème: il est nécessaire de démarrer le service web pour qu'il traite les demandes de ses clients, mais en même temps il y a un ensemble de quelques opérations (relativement) longues, sans lesquelles le service ne peut pas répondre au client, ou ses réponses seront incorrectes.
Exemples de telles opérations:

  • Validation de configurations fortement typĂ©es.
  • Remplir le cache.
  • Connexion prĂ©liminaire Ă  la base de donnĂ©es ou Ă  d'autres services.
  • Chargement JIT et Assembly (prĂ©chauffage de service).
  • Migration de la base de donnĂ©es. C'est l'un des exemples d'Andrew Lock, mais lui-mĂŞme admet qu'après tout, cette opĂ©ration n'est pas souhaitable lors du dĂ©marrage du service .

Je voudrais trouver une solution qui vous permettra d'effectuer des tâches asynchrones arbitraires au démarrage de l'application, et de manière naturelle pour elles, sans GetAwaiter (). GetResult ().

Ces tâches doivent être terminées avant que l'application ne commence à accepter les demandes, mais pour leur travail, elles peuvent avoir besoin de la configuration et des services enregistrés de l'application. Par conséquent, ces tâches doivent être effectuées après la configuration DI.

Cette idée peut être représentée sous forme de diagramme:


Solution # 1: une solution de travail qui peut confondre les héritiers


La première solution de travail offerte par 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>();
}

Cette approche a été rendue possible par l'avènement de l'asynchrone Main () à partir de C # 7.1. Son seul point négatif est que nous avons transféré la partie configuration de Startup.cs à Program.cs. Une telle solution non standard pour le cadre ASP.NET peut dérouter une personne qui héritera de notre code.

Solution n ° 2: intégrer des opérations asynchrones dans DI


Par conséquent, Andrew a proposé une version améliorée de la solution. Une interface pour les tâches asynchrones est déclarée:

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

Ainsi qu'une méthode d'extension qui enregistre ces tâches dans DI:

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

Ensuite, une autre méthode d'extension est déclarée, déjà pour 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);
    }
}

Et dans Program.cs, nous ne modifions qu'une seule ligne. Au lieu:

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

Nous appelons:

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

À mon avis, une excellente approche qui rend le travail avec de longues opérations aussi transparent que possible lors du démarrage de l'application.

Solution n ° 3: pour ceux qui sont passés à ASP.NET Core 3.x


Mais si vous utilisez ASP.NET Core 3.x, il existe une autre option. Je vais à nouveau faire référence à un article d'Andrew Lock .

Voici le code de lancement WebHost d'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
    }
}

Et voici la même méthode dans 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
    }
}

Dans ASP.NET Core 3.x, HostedServices est d'abord lancé, puis seulement le WebHost principal, et auparavant c'était exactement le contraire. Qu'est-ce que cela nous donne? Toutes les opérations asynchrones peuvent désormais être appelées à l'intérieur de la méthode StartAsync (CancellationToken) de l'interface IHostedService et obtenir le même effet sans créer d'interfaces et de méthodes d'extension distinctes.

Solution n ° 4: l'histoire du bilan de santé et de Kubernetes


On pourrait se calmer là-dessus, mais il y a une autre approche, et elle s'avère soudainement importante dans les réalités actuelles. C'est l'utilisation du bilan de santé.

L'idée de base est de démarrer le serveur Kestrel le plus tôt possible afin d'informer l'équilibreur de charge que le service est prêt à accepter les demandes. Mais en même temps, toutes les demandes de non-contrôle de santé renverront 503 (Service non disponible). Il y a un article assez complet sur le site Microsoft sur la façon d'utiliser le contrôle d'intégrité dans ASP.NET Core. Je voulais considérer cette approche sans détails particuliers appliquée à notre tâche.

Andrew Lock a un article séparé pour cette approche. Son principal avantage est qu'il évite les délais d'expiration du réseau.
, , , . Kestrel , , « ».

Je ne donnerai pas ici la solution complète d'Andrew Lock pour l'approche du bilan de santé. C'est assez volumineux, mais il n'y a rien de compliqué.

Je vais vous le dire en quelques mots: vous devez démarrer le service Web sans attendre la fin des opérations asynchrones. Dans ce cas, le point de terminaison du contrôle d'intégrité doit connaître l'état de ces opérations, émettre 503 pendant leur exécution et 200 lorsqu'elles sont déjà terminées.

Honnêtement, quand j'ai étudié cette option, j'avais un certain scepticisme. L'ensemble de la solution semblait lourd par rapport aux approches précédentes. Et si nous établissons une analogie, alors voici comment utiliser à nouveau l' approche EAP avec abonnement aux événements, au lieu de l'async / wait déjà familier.

Mais ensuite Kubernetes est entré en jeu. Il a son propre concept de sonde de préparation. Je citerai le livre «Kubernetes in Action» dans ma présentation gratuite:
Déterminez toujours la sonde de préparation.

Si vous ne disposez pas d'une sonde de préparation, vos pods deviennent presque instantanément des points d'extrémité de services. Si votre application met trop de temps à se préparer à accepter les demandes entrantes, les demandes des clients pour le service pourront également démarrer des modules qui ne sont pas encore prêts à accepter les connexions entrantes. Par conséquent, les clients recevront une erreur «Connexion refusée».

J'ai mené une expérience simple: j'ai créé un service ASP.NET Core 3 avec une longue tâche asynchrone dans 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)
    {...}
}

Lorsque j'ai commencé ce service à l'aide de minikube, puis augmenté le nombre à moins de deux, puis dans les 5 secondes suivant le retard, chaque seconde demande de la mienne ne produisait pas d'informations utiles, mais «Connexion refusée».

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

résultats


Quelle conclusion peut-on tirer de toutes ces études? Peut-être que tout le monde devrait décider pour son projet quelle solution est la mieux adaptée. Si l'on suppose que l'opération asynchrone ne prendra pas trop de temps et que les clients ont une sorte de stratégie de nouvelle tentative, vous pouvez utiliser toutes les approches, en commençant par GetAwaiter (). GetResult () et en terminant par IHostedService dans ASP.NET Core 3.x.

D'un autre côté, si vous utilisez Kubernetes et que vos opérations asynchrones peuvent être exécutées pendant une durée sensiblement longue, vous ne pouvez pas vous passer d'un contrôle d'intégrité (aka préparation / sonde de démarrage).

All Articles