Asynchroner Code in Startup ASP.NET Core: 4 Möglichkeiten, um GetAwaiter () zu umgehen. GetResult ()

Seit der Einführung des Async / Await-Mechanismus in C # 5.0 wurde uns in allen Artikeln und Dokumenten ständig beigebracht, dass die synchrone Verwendung von asynchronem Code sehr schlecht ist. Und sie fordern Angst wie GetAwaiter (). GetResult () -Konstruktionen. Es gibt jedoch einen Fall, in dem Microsoft-Programmierer selbst dieses Design nicht verachten.



Hintergrund zur Arbeitsaufgabe


Wir sind gerade dabei, von der Legacy-Authentifizierung auf OAuth 2.0 umzusteigen, was in unserer Branche bereits Standard ist. Der Dienst, an dem ich gerade arbeite, ist zu einem Pilotprojekt für die Integration in das neue System und für den Übergang zur JWT-Authentifizierung geworden.

Während des Integrationsprozesses haben wir unter Berücksichtigung verschiedener Optionen experimentiert, wie die Belastung des Token-Anbieters (in unserem Fall IdentityServer) verringert und eine höhere Zuverlässigkeit des gesamten Systems sichergestellt werden kann. Das Verbinden der JWT-basierten Validierung mit ASP.NET Core ist sehr einfach und nicht an eine bestimmte Implementierung des Token-Anbieters gebunden:

services
      .AddAuthentication()
      .AddJwtBearer(); 

Aber was verbirgt sich hinter diesen beiden Zeilen? Unter ihrer Haube wird ein JWTBearerHandler erstellt, der sich bereits mit JWT vom API-Client aus befasst.


Interaktion von Client, API und Token-Anbieter beim Anfordern

Wenn der JWTBearerHandler das Token vom Client empfängt, sendet er das Token nicht zur Validierung an den Anbieter, sondern fordert im Gegenteil den Signing Key-Anbieter an - den öffentlichen Teil des Schlüssels, mit dem das Token signiert ist. Basierend auf diesem Schlüssel wird überprüft, ob das Token vom richtigen Anbieter signiert ist.

In JWTBearerHandler befindet sich HttpClient, der über das Netzwerk mit dem Anbieter interagiert. Wenn wir jedoch davon ausgehen, dass sich der Signaturschlüssel unseres Anbieters nicht häufig ändert, können Sie ihn beim Starten der Anwendung einmal abholen, sich selbst zwischenspeichern und ständige Netzwerkanforderungen beseitigen.

Ich habe diesen Code für den Signaturschlüssel erhalten:

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

In Zeile 12 treffen wir .GetAwaiter (). GetResult (). Dies liegt daran, dass AuthenticationBuilder in public void ConfigureServices (IServiceCollection-Dienste) {...} der Startup-Klasse konfiguriert ist und diese Methode keine asynchrone Version hat. Ärger.

Ab C # 7.1 haben wir ein asynchrones Main (). Die asynchronen Startkonfigurationsmethoden in Asp.NET Core wurden jedoch noch nicht bereitgestellt. Ich war ästhetisch bemüht, GetAwaiter () zu schreiben. GetResult () (mir wurde beigebracht, dies nicht zu tun!), Also ging ich online, um herauszufinden, wie andere mit diesem Problem umgehen.

GetAwaiter () stört mich. GetResult (), Microsoft jedoch nicht


Als eine der ersten fand ich eine Option, die Microsoft-Programmierer in einer ähnlichen Aufgabe verwendeten, um Geheimnisse von Azure KeyVault abzurufen . Wenn Sie mehrere Abstraktionsebenen durchlaufen, werden wir sehen:

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

Hallo nochmal, GetAwaiter (). GetResult ()! Gibt es noch andere Lösungen?

Nach einer kurzen Google-Suche fand ich eine Reihe großartiger Artikel von Andrew Lock, der vor einem Jahr über das gleiche Thema wie ich nachdachte. Selbst aus den gleichen Gründen mag er es ästhetisch nicht, asynchronen Code synchron aufzurufen.

Im Allgemeinen empfehle ich jedem, der sich für dieses Thema interessiert, die gesamte Serie von fünf Artikeln von Andrew zu lesen. Dort analysiert er detailliert, welche Arbeitsaufgaben zu diesem Problem führen, berücksichtigt dann mehrere falsche Ansätze und beschreibt erst dann Lösungen. In meinem Artikel werde ich versuchen, einen kurzen Überblick über seine Forschung zu geben und mich mehr auf Lösungen zu konzentrieren.

Die Rolle asynchroner Aufgaben beim Starten eines Webdienstes


Machen Sie einen Schritt zurück, um das ganze Bild zu sehen. Was ist das konzeptionelle Problem, das ich unabhängig vom Framework zu lösen versucht habe?
Problem: Es ist erforderlich, den Webdienst so zu starten, dass er die Anforderungen seiner Clients verarbeitet. Gleichzeitig gibt es eine Reihe von (relativ) langwierigen Vorgängen, ohne die der Dienst dem Client nicht antworten kann, oder seine Antworten sind falsch.
Beispiele für solche Operationen:

  • Validierung stark typisierter Konfigurationen.
  • Den Cache füllen.
  • Vorläufige Verbindung zur Datenbank oder zu anderen Diensten.
  • Laden von JIT und Baugruppe (Aufwärmen des Dienstes).
  • Datenbankmigration. Dies ist eines der Beispiele für Andrew Lock, aber er selbst gibt zu, dass dieser Vorgang beim Starten des Dienstes schließlich unerwünscht ist .

Ich möchte eine Lösung finden, mit der Sie beim Start der Anwendung beliebige asynchrone Aufgaben auf natürliche Weise ohne GetAwaiter () ausführen können. GetResult ().

Diese Aufgaben müssen abgeschlossen sein, bevor die Anwendung Anforderungen akzeptiert. Für ihre Arbeit benötigen sie jedoch möglicherweise die Konfiguration und die registrierten Dienste der Anwendung. Daher müssen diese Aufgaben nach der DI-Konfiguration ausgeführt werden.

Diese Idee kann in Form eines Diagramms dargestellt werden:


Lösung 1: Eine funktionierende Lösung, die die Erben verwirren kann


Die erste funktionierende Lösung von 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>();
}

Dieser Ansatz wurde durch das Aufkommen des asynchronen Main () aus C # 7.1 ermöglicht. Das einzig Negative ist, dass wir den Konfigurationsteil von Startup.cs nach Program.cs übertragen haben. Eine solche nicht standardmäßige Lösung für das ASP.NET-Framework kann eine Person verwirren, die unseren Code erbt.

Lösung 2: Betten Sie asynchrone Operationen in DI ein


Daher schlug Andrew eine verbesserte Version der Lösung vor. Eine Schnittstelle für asynchrone Aufgaben wird deklariert:

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

Sowie eine Erweiterungsmethode, die diese Aufgaben in DI registriert:

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

Als nächstes wird bereits für IWebHost eine andere Erweiterungsmethode deklariert:

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

Und in Program.cs ändern wir nur eine Zeile. Stattdessen:

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

Wir nennen:

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

Meiner Meinung nach ein großartiger Ansatz, der das Arbeiten mit langen Vorgängen beim Starten der Anwendung so transparent wie möglich macht.

Lösung 3: Für diejenigen, die zu ASP.NET Core 3.x gewechselt sind


Wenn Sie jedoch ASP.NET Core 3.x verwenden, gibt es eine andere Option. Ich werde noch einmal auf einen Artikel von Andrew Lock verweisen .

Hier ist der WebHost-Startcode von 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
    }
}

Und hier ist die gleiche Methode in 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
    }
}

In ASP.NET Core 3.x wird HostedServices zuerst gestartet und erst dann der Haupt-WebHost, und früher war es genau umgekehrt. Was gibt uns das? Jetzt können alle asynchronen Operationen innerhalb der StartAsync-Methode (CancellationToken) der IHostedService-Schnittstelle aufgerufen werden und erzielen den gleichen Effekt, ohne separate Schnittstellen und Erweiterungsmethoden zu erstellen.

Lösung 4: Die Geschichte von Health Check und Kubernetes


Man könnte sich beruhigen, aber es gibt einen anderen Ansatz, und es stellt sich plötzlich heraus, dass er in der gegenwärtigen Realität wichtig ist. Dies ist die Verwendung von Gesundheitscheck.

Die Grundidee besteht darin, den Kestrel-Server so früh wie möglich zu starten, um den Load Balancer darüber zu informieren, dass der Dienst bereit ist, Anforderungen anzunehmen. Gleichzeitig geben alle Nicht-Integritätsprüfungsanforderungen 503 zurück (Dienst nicht verfügbar). Auf der Microsoft-Website finden Sie einen ziemlich ausführlichen Artikel zur Verwendung der Integritätsprüfung in ASP.NET Core. Ich wollte diesen Ansatz ohne besondere Details für unsere Aufgabe betrachten.

Andrew Lock hat einen separaten Artikel für diesen Ansatz. Der Hauptvorteil besteht darin, dass Netzwerk-Timeouts vermieden werden.
, , , . Kestrel , , « ».

Ich werde hier nicht die vollständige Andrew Lock-Lösung für den Health-Check-Ansatz angeben. Es ist ziemlich umfangreich, aber es ist nichts kompliziertes daran.

Ich sage es kurz: Sie müssen den Webdienst starten, ohne auf den Abschluss asynchroner Vorgänge warten zu müssen. In diesem Fall sollte der Endpunkt der Integritätsprüfung den Status dieser Vorgänge kennen, 503 ausgeben, während sie ausgeführt werden, und 200, wenn sie bereits abgeschlossen sind.

Ehrlich gesagt, als ich diese Option studierte, hatte ich eine gewisse Skepsis. Die gesamte Lösung sah im Vergleich zu früheren Ansätzen umständlich aus. Und wenn wir eine Analogie ziehen, können Sie auf diese Weise erneut den EAP-Ansatz mit Ereignisabonnement anstelle des bereits bekannten asynchronen / wartenden verwenden.

Aber dann kam Kubernetes ins Spiel. Er hat sein eigenes Konzept der Bereitschaftssonde. Ich werde in meiner kostenlosen Präsentation aus dem Buch „Kubernetes in Action“ zitieren:
Bestimmungssonde immer bestimmen.

Wenn Sie keine Bereitschaftsprüfung haben, werden Ihre Pods fast sofort zu Endpunkten von Diensten. Wenn Ihre Anwendung zu viel Zeit benötigt, um eingehende Anfragen anzunehmen, können Kundenanfragen für den Service auch Pods starten, die noch nicht bereit sind, eingehende Verbindungen anzunehmen. Infolgedessen erhalten Clients den Fehler "Verbindung abgelehnt".

Ich habe ein einfaches Experiment durchgeführt: Ich habe einen ASP.NET Core 3-Dienst mit einer langen asynchronen Aufgabe in HostedService erstellt:

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

Als ich diesen Dienst mit minikube startete und dann die Anzahl unter zwei erhöhte, lieferte jede zweite Anfrage innerhalb von 5 Sekunden nach der Verzögerung keine nützlichen Informationen, sondern "Verbindung abgelehnt".

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

Ergebnisse


Welche Schlussfolgerung kann aus all diesen Studien gezogen werden? Vielleicht sollte jeder für sein Projekt entscheiden, welche Lösung am besten geeignet ist. Wenn davon ausgegangen wird, dass der asynchrone Vorgang nicht zu lange dauert und die Clients über eine Wiederholungsrichtlinie verfügen, können Sie alle Ansätze verwenden, beginnend mit GetAwaiter (). GetResult () und endend mit IHostedService in ASP.NET Core 3.x.

Wenn Sie dagegen Kubernetes verwenden und Ihre asynchronen Vorgänge über einen bemerkenswert langen Zeitraum ausgeführt werden können, können Sie nicht auf eine Integritätsprüfung (auch als Bereitschafts- / Startsonde bezeichnet) verzichten.

All Articles