Modèle de demande-réponse asynchrone

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 (navigateur) —, de dépendre des API distantes pour fournir la logique métier et la fonctionnalité 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.

  • Tandis que le travail est toujours en attente, le point de terminaison d’état retourne HTTP 202. 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 202.
  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.
  • 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.

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<Message> OutMessage,
        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);
        Message m = new Message(Encoding.UTF8.GetBytes(messagePayload));
        m.UserProperties["RequestGUID"] = reqid;
        m.UserProperties["RequestSubmittedAt"] = DateTime.Now;
        m.UserProperties["RequestStatusURL"] = rqs;

        await OutMessage.AddAsync(m);  

        return (ActionResult) 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 l’emplacement de la signature SAS.

public static class AsyncProcessingBackgroundWorker
{
    [FunctionName("AsyncProcessingBackgroundWorker")]
    public static void Run(
        [ServiceBusTrigger("outqueue", Connection = "ServiceBusConnectionAppSetting")]Message myQueueItem,
        [Blob("data", FileAccess.ReadWrite, Connection = "StorageConnectionAppSetting")] CloudBlobContainer inputBlob,
        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.UserProperties["RequestGUID"] as string;

        CloudBlockBlob cbb = inputBlob.GetBlockBlobReference($"{id}.blobdata");

        // Now write the results to blob storage.
        cbb.UploadFromByteArrayAsync(myQueueItem.Body, 0, myQueueItem.Body.Length);
    }
}

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, elle devrait renvoyer un code d’état 202 Accepté avec un en-tête d’emplacement auto-référencé, plaçant un ETA pour une réponse terminée dans l’en-tête http Retry-After.
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")] CloudBlockBlob 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, CloudBlockBlob 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.DownloadTextAsync());
                }

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

public enum OnCompleteEnum {

    Redirect,
    Stream
}

public enum OnPendingEnum {

    Accepted,
    Synchronous
}

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