Modèle de demande-réponse asynchrone

Azure
Azure Logic Apps

Découplez le traitement de back-end à partir d’un hôte front-end, où le traitement de back-end doit être asynchrone, mais le front-end a quand même besoin d’une réponse claire.

Contexte et problème

Dans le développement d’applications modernes, il est normal que les applications clientes (souvent du code s’exécutant dans un client web, ou navigateur) dépendent des API distantes pour fournir une logique métier et des fonctionnalités de composition. Ces API peuvent être directement liées à l’application ou peuvent être des services partagés fournis par un tiers. Ces appels d’API sont généralement effectués sur le protocole HTTP(S) et suivent la sémantique REST.

Dans la plupart des cas, les API d’une application cliente sont conçues pour répondre rapidement, environ 100 ms ou moins. De nombreux facteurs peuvent affecter la latence de réponse, notamment :

  • La pile d’hébergement d’une application.
  • Les composants de sécurité.
  • L’emplacement géographique relatif de l’appelant et du serveur principal.
  • Infrastructure réseau.
  • Chargement actuel.
  • Taille de la charge utile de la requête.
  • Longueur de la file d’attente de traitement.
  • L’heure à laquelle le serveur principal doit traiter la requête.

Chacun de ces facteurs peut ajouter de la latence à la réponse. Certains peuvent être atténués en diminuant le back-end. D’autres, tels que l’infrastructure réseau, sont largement hors du contrôle du développeur de l’application. La plupart des API peuvent répondre suffisamment rapidement pour que les réponses arrivent sur la même connexion. Le code d’application peut effectuer un appel d’API synchrone en mode non bloquant, en donnant l’apparence d’un traitement asynchrone, ce qui est recommandé pour les opérations liées aux E/S.

Dans certains scénarios, toutefois, le travail effectué par le serveur principal peut être long, de l’ordre de quelques secondes, ou peut être un processus en arrière-plan qui s’exécute en quelques minutes, voire des heures. Dans ce cas, il n’est pas possible d’attendre la fin du travail avant de répondre à la requête. Cette situation est un problème potentiel pour un modèle de demande-réponse synchrone.

Certaines architectures résolvent ce problème à l’aide d’un courtier de messages permettant de séparer les étapes de demande et de réponse. Cette séparation est souvent obtenue en utilisant le modèle de nivellement de la charge basé sur une file d’attente. Cette séparation peut permettre au processus client et à l’API du serveur principal de s’adapter de manière indépendante. Toutefois, cette séparation augmente également la complexité lorsque le client demande une notification de réussite, car cette étape doit être asynchrone.

La plupart des considérations abordées pour les applications clientes s’appliquent également aux appels d’API REST de serveur à serveur dans les systèmes distribués, par exemple dans une architecture de microservices.

Solution

Une des solutions à ce problème consiste à utiliser l’interrogation HTTP. L’interrogation est utile pour le code côté client, car il peut être difficile de fournir des points de terminaison de rappel ou d’utiliser des connexions à long terme. Même lorsque les rappels sont possibles, les bibliothèques et les services supplémentaires qui sont requis peuvent parfois ajouter trop de complexité supplémentaire.

  • L’application cliente effectue un appel synchrone à l’API, ce qui déclenche une opération de longue durée sur le serveur principal.

  • L’API répond de manière synchrone aussi rapidement que possible. Elle retourne un code d’état HTTP 202 (Accepté), en confirmant que la requête a été reçue pour traitement.

    Notes

    L’API doit valider à la fois la requête et l’action à effectuer avant le démarrage du processus de longue durée. Si la requête n’est pas valide, répondez-y immédiatement avec un code d’erreur tel que HTTP 400 (requête incorrecte).

  • La réponse contient une référence d’emplacement pointant vers un point de terminaison que le client peut interroger pour vérifier le résultat de l’opération de longue durée.

  • L’API décharge le traitement vers un autre composant, tel qu’une file d’attente de messages.

  • Pour chaque appel réussi à l’état du point de terminaison, HTTP 200 est retourné. Tandis que le travail est toujours en attente, l’état du point de terminaison renvoie une ressource qui indique que le travail est toujours en cours. Une fois le travail terminé, le point de terminaison d’état peut retourner une ressource qui indique l’achèvement ou rediriger vers une autre URL de ressource. Par exemple, si l’opération asynchrone crée une nouvelle ressource, le point de terminaison d’état redirige vers l’URL de cette ressource.

Le diagramme suivant montre un flux classique :

Flux de requête et de réponse pour les requêtes HTTP asynchrones

  1. Le client envoie une requête et reçoit une réponse HTTP 202 (Accepté).
  2. Le client envoie une requête HTTP GET au point de terminaison d’état. Le travail étant toujours en attente, cet appel retourne également HTTP 200.
  3. À un moment donné, le travail est terminé et le point de terminaison d’état retourne 302 (Trouvé) et redirige vers la ressource.
  4. Le client extrait la ressource à l’URL spécifiée.

Problèmes et considérations

  • Il existe plusieurs façons d’implémenter ce modèle sur HTTP et tous les services en amont n’ont pas la même sémantique. Par exemple, la plupart des services ne retournent pas une réponse HTTP 202 à partir d’une méthode GET lorsqu’un processus distant n’est pas terminé. D’après la sémantique REST, ils doivent retourner HTTP 404 (Introuvable). Cette réponse est logique si vous considérez que le résultat de l’appel n’est pas encore présent.

  • Une réponse HTTP 202 doit indiquer l’emplacement et la fréquence que le client doit interroger pour la réponse. Elle doit disposer des en-têtes supplémentaires suivants :

    En-tête Description Notes
    Emplacement Une URL que le client doit interroger pour obtenir un état de réponse. Cette URL peut être un jeton SAP avec le modèle de clé de valet qui convient, si cet emplacement nécessite un contrôle d’accès. Le modèle de clé de valet est également valide lorsque l’interrogation de réponse doit être déchargée sur un autre serveur principal.
    Retry-After Une estimation de la fin du traitement Cet en-tête est conçu pour empêcher les clients réalisant l’interrogation de surcharger le serveur principal avec les nouvelles tentatives.

    Le comportement du client attendu doit être pris en compte lors de la conception de cette réponse. Bien qu’un client sous votre contrôle puisse être codé pour respecter ces valeurs de réponse de manière explicite, les clients qui ne sont pas créés par vous ou qui n’utilisent pas d’approche low-code (par exemple, Azure Logic Apps) sont libres d’avoir leur propre gestion logique HTTP 202.

  • Vous devrez peut-être utiliser un proxy de traitement ou une façade pour manipuler les en-têtes de réponse ou la charge utile en fonction des services sous-jacents utilisés.

  • Si le point de terminaison d’état redirige à l’achèvement, HTTP 302 ou HTTP 303 sont des codes de retour appropriés, en fonction de la sémantique exacte que vous prenez en charge.

  • En cas de réussite du traitement, la ressource spécifiée par l’en-tête Emplacement doit retourner un code de réponse HTTP approprié, tel que 200 (OK), 201 (Créé) ou 204 (Aucun contenu).

  • Si une erreur se produit au cours du traitement, conservez l’erreur à l’URL de ressource décrite dans l’en-tête Emplacement et, dans l’idéal, renvoyez un code de réponse approprié au client à partir de cette ressource (code 4xx).

  • Toutes les solutions n’implémenteront pas ce modèle de la même façon et certains services incluront des en-têtes supplémentaires ou de remplacement. Par exemple, Azure Resource Manager utilise une variante modifiée de ce modèle. Pour plus d’informations, consultez Opérations asynchrones Azure Resource Manager.

  • Les clients hérités ne prennent peut-être pas en charge ce modèle. Dans ce cas, vous devrez peut-être placer une façade sur l’API asynchrone pour masquer le traitement asynchrone du client d’origine. Par exemple, Azure Logic Apps prend en charge ce modèle en mode natif et peut être utilisé en tant que couche d’intégration entre une API asynchrone et un client qui effectue des appels synchrones. Consultez Effectuer des tâches longues avec le modèle d’action Webhook.

  • Dans certains scénarios, vous souhaiterez peut-être offrir aux clients un moyen d’annuler une requête de longue durée. Dans ce cas, le service principal doit prendre en charge une forme d’instruction d’annulation.

Quand utiliser ce modèle

Utilisez ce modèle pour :

  • Le code côté client, tel que les applications de navigateur, où il est difficile de fournir des points de terminaison de rappel et ou l’utilisation de connexions de longue durée ajoute trop de complexité supplémentaire.

  • Les appels de service où seul le protocole HTTP est disponible et le service de retour ne peut pas déclencher de rappels en raison de restrictions de pare-feu côté client.

  • Les appels de service devant être intégrés à des architectures héritées qui ne prennent pas en charge les technologies de rappel modernes, telles que WebSockets ou webhook.

Ce modèle peut ne pas convenir lorsque :

  • Vous pouvez utiliser un service généré pour les notifications asynchrones à la place, par exemple Azure Event Grid.
  • Les réponses doivent être transmises en temps réel au client.
  • Le client doit collecter de nombreux résultats et la latence reçue de ces résultats est importante. Envisagez un modèle Service Bus à la place.
  • Vous pouvez utiliser des connexions réseau persistantes côté serveur telles que WebSockets ou SignalR. Ces services peuvent être utilisés pour notifier l’appelant du résultat.
  • La conception du réseau vous permet d’ouvrir des ports pour recevoir des rappels ou des webhooks asynchrones.

Conception de la charge de travail

Un architecte doit évaluer la façon dont le modèle de demande-réponse asynchrone peut être utilisé dans la conception de leurs charges de travail pour se conformer aux objectifs et principes abordés dans les piliers d’Azure Well-Architected Framework. Par exemple :

Pilier Comment ce modèle soutient les objectifs des piliers.
L’efficacité des performances permet à votre charge de travail de répondre efficacement aux demandes grâce à des optimisations de la mise à l’échelle, des données, du code. Le découplage des phases de demande et de réponse des interactions pour les processus qui n’ont pas besoin de réponses immédiates améliore la réactivité et l’évolutivité des systèmes. Cette approche asynchrone permet de maximiser la concurrence du côté du serveur et de programmer le travail à effectuer en fonction de la capacité.

- PE :05 Mise à l’échelle et partitionnement
- PE :07 Code et infrastructure

Comme pour toute autre décision de conception, il convient de prendre en compte les compromis par rapport aux objectifs des autres piliers qui pourraient être introduits avec ce modèle.

Exemple

Le code suivant affiche des extraits d’une application qui utilise Azure Functions pour implémenter ce modèle. La solution comporte trois fonctions :

  • Le point de terminaison de l’API asynchrone.
  • Le point de terminaison de l’état.
  • Une fonction principale qui prend les éléments de travail mis en file d’attente et les exécute.

Image de la structure du modèle de réponse de requête asynchrone dans Azure Functions

Logo GitHub Cet exemple est disponible sur GitHub.

Fonction AsyncProcessingWorkAcceptor

La fonction AsyncProcessingWorkAcceptor implémente un point de terminaison qui accepte le travail d’une application cliente et la place dans une file d’attente à des fins de traitement.

  • La fonction génère un ID de requête et l’ajoute en tant que métadonnée au message de la file d’attente.
  • La réponse HTTP comprend un en-tête d’emplacement pointant vers un point de terminaison d’état. L’ID de la requête fait partie du chemin d’accès de l’URL.
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}");
    }
}

Fonction AsyncProcessingBackgroundWorker

La fonction AsyncProcessingBackgroundWorker récupère l’opération dans la file d’attente, effectue un travail en fonction de la charge utile du message, et écrit le résultat dans un compte de stockage.

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

Fonction AsyncOperationStatusChecker

La fonction AsyncOperationStatusChecker implémente le point de terminaison d’état. Cette fonction vérifie d’abord si la requête est terminée

  • Si la requête est terminée, la fonction retourne une clé de valet à la réponse, ou redirige immédiatement l’appel vers l’URL de clé de valet.
  • Si la requête est toujours en attente, nous devons retourner un code 200, y compris l’état actuel.
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
}

Étapes suivantes

Les informations suivantes peuvent également être pertinentes durant l’implémentation de ce modèle :