Asynchrones Anforderung-Antwort-Muster

Azure
Azure Logic Apps

Entkoppeln Sie die Back-End-Verarbeitung von einem Front-End-Host, wobei die Back-End-Verarbeitung asynchron sein muss, das Front-End jedoch eine eindeutige Antwort benötigt.

Kontext und Problem

In der modernen Anwendungsentwicklung ist es normal, dass Clientanwendungen – häufig Code, der in einem Webclient (Browser) ausgeführt wird – davon abhängig sind, dass Remote-APIs Geschäftslogik und Erstellungsfunktionen bereitstellen. Diese APIs können direkt mit der Anwendung verknüpft oder von Drittanbietern bereitgestellte freigegebene Dienste sein. Diese API-Aufrufe erfolgen häufig über das HTTP(S)-Protokoll und folgen der REST-Semantik.

In den meisten Fällen sind die APIs für eine Clientanwendung so konzipiert, dass sie schnell reagieren, d. h. in spätestens 100 ms. Unter anderem können sich die folgenden Faktoren auf die Antwortlatenz auswirken:

  • Der Hoststapel einer Anwendung.
  • Sicherheitskomponenten.
  • Der relative geografische Standort des Aufrufers und des Back-Ends.
  • Netzwerkinfrastruktur.
  • Aktuelle Auslastung.
  • Die Größe der Anforderungsnutzlast.
  • Länge der Verarbeitungswarteschlange.
  • Der Zeitpunkt, zu dem das Back-End die Anforderung verarbeitet.

Jeder dieser Faktoren kann zur Latenz der Antwort beitragen. Einige können durch horizontales Hochskalieren des Back-Ends abgeschwächt werden. Andere, wie z. B. die Netzwerkinfrastruktur, liegen im Wesentlichen außerhalb der Kontrolle des Anwendungsentwicklers. Die meisten APIs können schnell genug reagieren, damit Antworten über dieselbe Verbindung zurückkommen. Anwendungscode kann einen synchronen API-Aufruf auf nicht blockierende Weise durchführen, wie bei der asynchronen Verarbeitung, die für E/A-gebundene Vorgänge empfohlen wird.

In einigen Szenarien kann es jedoch vorkommen, dass die Arbeit, die vom Back-End ausgeführt wird, lange dauert, d. h. im Sekundenbereich liegt, oder ein Hintergrundprozess ist, der innerhalb von Minuten oder sogar Stunden ausgeführt wird. In diesem Fall ist es nicht möglich, auf den Abschluss der Arbeit zu warten, bevor auf die Anforderung geantwortet wird. Diese Situation ist ein potenzielles Problem bei allen synchronen Anforderung-Antwort-Mustern.

Einige Architekturen lösen dieses Problem mithilfe eines Nachrichtenbrokers, um Anforderungs- und Antwortphasen zu trennen. Diese Trennung wird häufig durch die Verwendung des warteschlangenbasierten Lastenausgleichsmusters erreicht. Diese Trennung kann die unabhängige Skalierung von Clientprozess und Back-End-API ermöglichen. Allerdings bringt diese Trennung auch zusätzliche Komplexität mit sich, wenn der Client eine Erfolgsbenachrichtigung verlangt, da dieser Schritt asynchron werden muss.

Viele Überlegungen, die für Clientanwendungen angestellt werden, gelten auch für Server-zu-Server-REST-API-Aufrufe in verteilten Systemen, z. B. in einer Microservicesarchitektur.

Lösung

Eine Lösung dieses Problems ist die Verwendung des HTTP-Abrufs. Der Abruf ist für clientseitigen Code nützlich, da es schwierig sein kann, Rückrufendpunkte bereitzustellen oder zeitintensive Verbindungen zu verwenden. Auch wenn Rückrufe möglich sind, steigern die erforderlichen zusätzlichen Bibliotheken und Dienste manchmal die Komplexität zu sehr.

  • Die Clientanwendung richtet einen synchronen Aufruf an die API und löst einen zeitintensiven Vorgang am Back-End aus.

  • Die API reagiert so schnell wie möglich synchron. Sie gibt den Statuscode „HTTP 202 (Akzeptiert)“ zurück, der bestätigt, dass die Anforderung zur Verarbeitung empfangen wurde.

    Hinweis

    Die API sollte sowohl die Anforderung als auch die Aktion überprüfen, die vor dem Starten des zeitintensiven Prozesses ausgeführt werden soll. Wenn die Anforderung ungültig ist, sollte sofort mit einem Fehlercode wie z. B. „HTTP 400 (ungültige Anforderung)“ geantwortet werden.

  • Die Antwort enthält einen Speicherortverweis, der auf einen Endpunkt verweist, den der Client abfragen kann, um das Ergebnis des zeitintensiven Vorgangs zu überprüfen.

  • Die API verlagert die Verarbeitung an eine andere Komponente, z. B. an eine Nachrichtenwarteschlange.

  • Für jeden erfolgreichen Aufruf des Statusendpunkts wird „HTTP 200“ zurückgegeben. Während die Arbeit noch aussteht, gibt der Statusendpunkt eine Ressource zurück, die angibt, dass die Arbeit noch ausgeführt wird. Nachdem die Arbeit abgeschlossen ist, kann der Statusendpunkt entweder eine Ressource zurückgeben, die den Abschluss angibt, oder eine Umleitung zu einer anderen Ressourcen-URL durchführen. Wenn der asynchrone Vorgang z. B. eine neue Ressource erstellt, würde der Statusendpunkt eine Umleitung zur URL für diese Ressource durchführen.

Dieses Diagramm zeigt einen typischen Flow:

Anforderungs- und Antwortflow für asynchrone HTTP-Anforderungen

  1. Der Client sendet eine Anforderung und empfängt die Antwort „HTTP 202 (Akzeptiert)“.
  2. Der Client sendet eine HTTP-GET-Anforderung an den Statusendpunkt. Da die Arbeit noch aussteht, gibt dieser Aufruf „HTTP 200“ zurück.
  3. Zu einem späteren Zeitpunkt ist die Arbeit abgeschlossen, der Statusendpunkt gibt „302 (Gefunden)“ zurück und führt eine Umleitung zur Ressource durch.
  4. Der Client ruft die Ressource an der angegebenen URL ab.

Probleme und Überlegungen

  • Es gibt mehrere Möglichkeiten, dieses Muster über HTTP zu implementieren, und nicht alle Upstreamdienste haben dieselbe Semantik. Die meisten Dienste geben z. B. keine HTTP 202-Antwort von einer GET-Methode zurück, wenn ein Remoteprozess noch nicht abgeschlossen ist. Gemäß der reinen REST-Semantik sollte „HTTP 404 (Nicht gefunden)“ zurückgegeben werden. Diese Antwort ist sinnvoll, wenn Sie berücksichtigen, dass das Ergebnis des Aufrufes noch nicht vorhanden ist.

  • Eine HTTP 202-Antwort sollte den Speicherort und die Häufigkeit angeben, mit der der Client die Antwort abrufen sollte. Sie sollte folgende zusätzliche Header enthalten:

    Header BESCHREIBUNG Notizen
    Standort Eine URL, von wo der Client einen Antwortstatus abrufen sollte. Diese URL könnte ein SAS-Token mit dem Valetschlüsselmuster sein, was geeignet ist, wenn dieser Speicherort die Zugriffssteuerung benötigt. Das Valetschlüsselmuster ist auch gültig, wenn der Antwortabruf auf ein anderes Back-End verlagert werden muss.
    Retry-After Eine Schätzung, wann die Verarbeitung abgeschlossen sein wird. Dieser Header soll verhindern, dass abrufende Clients das Back-End mit Wiederholungsversuchen überfordern.

    Das erwartete Clientverhalten muss beim Entwerfen dieser Antwort berücksichtigt werden. Während ein von Ihnen kontrollierter Client so kodiert werden kann, dass er diese Antwortwerte explizit respektiert, steht es Clients, die nicht von Ihnen erstellt wurden oder einen No- oder Low-Code-Ansatz verwenden (wie Azure Logic Apps), frei, ihre eigene HTTP 202-Logik zu verwenden.

  • Abhängig von den verwendeten zugrunde liegenden Diensten müssen Sie möglicherweise einen Verarbeitungsproxy oder eine Fassade verwenden, um die Antwortheader oder die Nutzlast zu bearbeiten.

  • Wenn der Statusendpunkt beim Abschluss eine Umleitung durchführt, sind HTTP 302 oder HTTP 303 geeignete Rückgabecodes, abhängig von der exakten Semantik, die Sie unterstützen.

  • Nach erfolgreicher Verarbeitung sollte die vom Location-Header angegebene Ressource einen entsprechenden HTTP-Antwortcode wie „200 (OK)“, „201 (Erstellt)“ oder „204 (Kein Inhalt)“ zurückgeben.

  • Wenn während der Verarbeitung ein Fehler auftritt, speichern Sie den Fehler an der im Location-Header beschriebenen Ressourcen-URL, und geben Sie im Idealfall einen passenden Antwortcode von dieser Ressource (4xx-Code) an den Client zurück.

  • Nicht alle Lösungen implementieren dieses Muster auf die gleiche Weise, und einige Dienste beziehen zusätzliche oder alternative Header ein. Azure Resource Manager verwendet beispielsweise eine geänderte Variante dieses Musters. Weitere Informationen finden Sie unter Nachverfolgen asynchroner Vorgänge in Azure.

  • Dieses Muster wird von Legacyclients möglicherweise nicht unterstützt. In diesem Fall müssen Sie möglicherweise eine Fassade über der asynchronen API platzieren, um die asynchrone Verarbeitung vor dem ursprünglichen Client auszublenden. Azure Logic Apps unterstützt dieses Muster z. B. nativ als Integrationsebene zwischen einer asynchronen API und einem Client, der synchrone Aufrufe ausführt. Siehe Ausführen zeitaufwändiger Aufgaben mit dem Webhookaktionsmuster.

  • In einigen Szenarien möchten Sie möglicherweise Clients die Möglichkeit bieten, eine zeitintensive Anforderung abzubrechen. In diesem Fall muss der Back-End-Dienst eine Form der Abbruchsanweisung unterstützen.

Verwendung dieses Musters

Verwenden Sie dieses Muster für folgende Zwecke:

  • Clientseitiger Code, z. B. Browseranwendungen, bei denen es schwierig ist, Rückrufendpunkte bereitzustellen, oder die Verwendung zeitintensiver Verbindungen bringt zusätzliche Komplexität mit sich.

  • Dienstaufrufe, bei denen nur das HTTP-Protokoll verfügbar ist und der Rückgabedienst aufgrund clientseitiger Firewallbeschränkungen keine Rückrufe auslösen kann.

  • Dienstaufrufe, die in Legacyarchitekturen integriert werden müssen, die keine modernen Rückruftechnologien wie WebSockets oder Webhooks unterstützen.

Dieses Muster ist in folgenden Fällen möglicherweise nicht geeignet:

  • Sie können stattdessen einen Dienst wie z. B. Azure Event Grid verwenden, der für asynchrone Benachrichtigungen erstellt wurde.
  • Antworten müssen in Echtzeit an den Client gestreamt werden.
  • Der Client muss viele Ergebnisse erfassen, und die von diesen Ergebnissen empfangene Latenz ist wichtig. Verwenden Sie stattdessen ein Service Bus-Muster.
  • Sie können serverseitige persistente Netzwerkverbindungen wie WebSockets oder SignalR verwenden. Diese Dienste können verwendet werden, um den Aufrufer über das Ergebnis zu benachrichtigen.
  • Der Netzwerkentwurf ermöglicht Ihnen, Ports zu öffnen, um asynchrone Rückrufe oder Webhooks zu empfangen.

Workloadentwurf

Ein Architekt sollte evaluieren, wie das Asynchronous Request-Reply-Pattern im Design seiner Workloads verwendet werden kann, um die Ziele und Prinzipien zu adressieren, die in den Azure Well-Architected Framework-Säulen behandelt werden. Zum Beispiel:

Säule So unterstützt dieses Muster die Säulenziele
Die Leistungseffizienz hilft Ihrer Workload, Anforderungen effizient durch Optimierungen in Skalierung, Daten und Code zu erfüllen. Durch entkoppeln der Anforderungs- und Antwortphasen von Interaktionen für Prozesse, die keine sofortigen Antworten benötigen, wird die Reaktionsfähigkeit und Skalierbarkeit von Systemen verbessert. Als asynchronen Ansatz können Sie die Parallelität auf der Serverseite maximieren und die Arbeit so planen, dass sie entsprechend der Kapazität erledigt wird.

- PE:05 Skalierung und Partitionierung
- PE:07 Code und Infrastruktur

Berücksichtigen Sie wie bei jeder Designentscheidung alle Kompromisse im Hinblick auf die Ziele der anderen Säulen, die mit diesem Muster eingeführt werden könnten.

Beispiel

Der folgende Code zeigt Ausschnitte aus einer Anwendung, die Azure Functions verwendet, um dieses Muster zu implementieren. Die Lösung enthält drei Funktionen:

  • Den asynchronen API-Endpunkt.
  • Den Statusendpunkt.
  • Eine Back-End-Funktion, die Arbeitselemente aus der Warteschlange entgegennimmt und ausführt.

Abbildung der Struktur des asynchronen Anforderung-Antwort-Musters in Funktionen

GitHub-Logo Dieses Beispiel ist auf GitHub verfügbar.

AsyncProcessingWorkAcceptor-Funktion

Die AsyncProcessingWorkAcceptor-Funktion implementiert einen Endpunkt, der Arbeitselemente von einer Clientanwendung annimmt und sie zur Verarbeitung in eine Warteschlange einfügt.

  • Die Funktion generiert eine Anforderungs-ID und fügt sie der Warteschlangennachricht als Metadaten hinzu.
  • Die HTTP-Antwort enthält einen Location-Header, der auf einen Statusendpunkt zeigt. Die Anforderungs-ID ist Teil des URL-Pfads.
public static class AsyncProcessingWorkAcceptor
{
    [FunctionName("AsyncProcessingWorkAcceptor")]
    public static async Task<IActionResult> Run(
        [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = null)] CustomerPOCO customer,
        [ServiceBus("outqueue", Connection = "ServiceBusConnectionAppSetting")] IAsyncCollector<ServiceBusMessage> OutMessages,
        ILogger log)
    {
        if (String.IsNullOrEmpty(customer.id) || string.IsNullOrEmpty(customer.customername))
        {
            return new BadRequestResult();
        }

        string reqid = Guid.NewGuid().ToString();

        string rqs = $"http://{Environment.GetEnvironmentVariable("WEBSITE_HOSTNAME")}/api/RequestStatus/{reqid}";

        var messagePayload = JsonConvert.SerializeObject(customer);
        var message = new ServiceBusMessage(messagePayload);
        message.ApplicationProperties.Add("RequestGUID", reqid);
        message.ApplicationProperties.Add("RequestSubmittedAt", DateTime.Now);
        message.ApplicationProperties.Add("RequestStatusURL", rqs);

        await OutMessages.AddAsync(message);

        return new AcceptedResult(rqs, $"Request Accepted for Processing{Environment.NewLine}ProxyStatus: {rqs}");
    }
}

AsyncProcessingBackgroundWorker-Funktion

Die AsyncProcessingBackgroundWorker-Funktion übernimmt den Vorgang aus der Warteschlange, führt auf der Grundlage der Nachrichtennutzlast einige Arbeitsschritte aus und schreibt das Ergebnis in ein Speicherkonto.

public static class AsyncProcessingBackgroundWorker
{
    [FunctionName("AsyncProcessingBackgroundWorker")]
    public static async Task RunAsync(
        [ServiceBusTrigger("outqueue", Connection = "ServiceBusConnectionAppSetting")] BinaryData customer,
        IDictionary<string, object> applicationProperties,
        [Blob("data", FileAccess.ReadWrite, Connection = "StorageConnectionAppSetting")] BlobContainerClient inputContainer,
        ILogger log)
    {
        // Perform an actual action against the blob data source for the async readers to be able to check against.
        // This is where your actual service worker processing will be performed

        var id = applicationProperties["RequestGUID"] as string;

        BlobClient blob = inputContainer.GetBlobClient($"{id}.blobdata");

        // Now write the results to blob storage.
        await blob.UploadAsync(customer);
    }
}

AsyncOperationStatusChecker-Funktion

Die AsyncOperationStatusChecker-Funktion implementiert den Statusendpunkt. Diese Funktion prüft zunächst, ob die Anforderung abgeschlossen wurde.

  • Wenn die Anforderung abgeschlossen wurde, gibt die Funktion entweder einen Valetschlüssel an die Antwort zurück oder leitet den Befehl direkt zur Valetschlüssel-URL um.
  • Wenn die Anforderung noch aussteht, sollte der Code 200 mit dem aktuellen Zustand zurückgegeben werden.
public static class AsyncOperationStatusChecker
{
    [FunctionName("AsyncOperationStatusChecker")]
    public static async Task<IActionResult> Run(
        [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "RequestStatus/{thisGUID}")] HttpRequest req,
        [Blob("data/{thisGuid}.blobdata", FileAccess.Read, Connection = "StorageConnectionAppSetting")] BlockBlobClient inputBlob, string thisGUID,
        ILogger log)
    {

        OnCompleteEnum OnComplete = Enum.Parse<OnCompleteEnum>(req.Query["OnComplete"].FirstOrDefault() ?? "Redirect");
        OnPendingEnum OnPending = Enum.Parse<OnPendingEnum>(req.Query["OnPending"].FirstOrDefault() ?? "OK");

        log.LogInformation($"C# HTTP trigger function processed a request for status on {thisGUID} - OnComplete {OnComplete} - OnPending {OnPending}");

        // Check to see if the blob is present
        if (await inputBlob.ExistsAsync())
        {
            // If it's present, depending on the value of the optional "OnComplete" parameter choose what to do.
            return await OnCompleted(OnComplete, inputBlob, thisGUID);
        }
        else
        {
            // If it's NOT present, then we need to back off. Depending on the value of the optional "OnPending" parameter, choose what to do.
            string rqs = $"http://{Environment.GetEnvironmentVariable("WEBSITE_HOSTNAME")}/api/RequestStatus/{thisGUID}";

            switch (OnPending)
            {
                case OnPendingEnum.OK:
                    {
                        // Return an HTTP 200 status code.
                        return new OkObjectResult(new { status = "In progress", Location = rqs });
                    }

                case OnPendingEnum.Synchronous:
                    {
                        // Back off and retry. Time out if the backoff period hits one minute.
                        int backoff = 250;

                        while (!await inputBlob.ExistsAsync() && backoff < 64000)
                        {
                            log.LogInformation($"Synchronous mode {thisGUID}.blob - retrying in {backoff} ms");
                            backoff = backoff * 2;
                            await Task.Delay(backoff);
                        }

                        if (await inputBlob.ExistsAsync())
                        {
                            log.LogInformation($"Synchronous Redirect mode {thisGUID}.blob - completed after {backoff} ms");
                            return await OnCompleted(OnComplete, inputBlob, thisGUID);
                        }
                        else
                        {
                            log.LogInformation($"Synchronous mode {thisGUID}.blob - NOT FOUND after timeout {backoff} ms");
                            return new NotFoundResult();
                        }
                    }

                default:
                    {
                        throw new InvalidOperationException($"Unexpected value: {OnPending}");
                    }
            }
        }
    }

    private static async Task<IActionResult> OnCompleted(OnCompleteEnum OnComplete, BlockBlobClient inputBlob, string thisGUID)
    {
        switch (OnComplete)
        {
            case OnCompleteEnum.Redirect:
                {
                    // Redirect to the SAS URI to blob storage

                    return new RedirectResult(inputBlob.GenerateSASURI());
                }

            case OnCompleteEnum.Stream:
                {
                    // Download the file and return it directly to the caller.
                    // For larger files, use a stream to minimize RAM usage.
                    return new OkObjectResult(await inputBlob.DownloadContentAsync());
                }

            default:
                {
                    throw new InvalidOperationException($"Unexpected value: {OnComplete}");
                }
        }
    }
}

public enum OnCompleteEnum
{

    Redirect,
    Stream
}

public enum OnPendingEnum
{

    OK,
    Synchronous
}

Nächste Schritte

Die folgenden Informationen sind unter Umständen auch relevant, wenn dieses Muster implementiert wird: