Asynchrones Anforderung-Antwort-Muster

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 202“ 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:

Request and response flow for asynchronous HTTP requests

  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.
  • 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.

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.

Image of the structure of the Async Request Reply pattern in Functions

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["RequestGUID"] = reqid;
        message.ApplicationProperties["RequestSubmittedAt"] = DateTime.Now;
        message.ApplicationProperties["RequestStatusURL"] = rqs;

        await OutMessages.AddAsync(message);  

        return (ActionResult) 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 an den Speicherort der SAS-Signatur.

public static class AsyncProcessingBackgroundWorker
{
    [FunctionName("AsyncProcessingBackgroundWorker")]
    public static async Task RunAsync(
        [ServiceBusTrigger("outqueue", Connection = "ServiceBusConnectionAppSetting")]ServiceBusMessage myQueueItem,
        [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 = myQueueItem.ApplicationProperties["RequestGUID"] as string;

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

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

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, sollten wir einen „202 Akzeptiert“-Code mit einem auf sich selbst verweisenden Location-Header zurückgeben, wobei eine ETA für eine abgeschlossene Antwort im HTTP-Retry-After-Header abgelegt wird.
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() ?? "Accepted");

        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, check the optional "OnPending" parameter.
            string rqs = $"http://{Environment.GetEnvironmentVariable("WEBSITE_HOSTNAME")}/api/RequestStatus/{thisGUID}";

            switch (OnPending)
            {
                case OnPendingEnum.Accepted:
                    {
                        // Return an HTTP 202 status code.
                        return (ActionResult)new AcceptedResult() { 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 (ActionResult)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 (ActionResult)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 (ActionResult)new OkObjectResult(await inputBlob.DownloadContentAsync());
                }

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

public enum OnCompleteEnum {

    Redirect,
    Stream
}

public enum OnPendingEnum {

    Accepted,
    Synchronous
}

Nächste Schritte

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