Modello Request-Reply asincrono

Azure
App per la logica di Azure

Separare l'elaborazione back-end da un host front-end, in cui l'elaborazione back-end deve essere asincrona, ma il front-end necessita ancora di una risposta chiara.

Contesto e problema

Nello sviluppo di applicazioni moderne, è normale che le applicazioni client, spesso il codice in esecuzione in un client Web (browser) dipende dalle API remote per fornire la logica di business e comporre funzionalità. Queste API possono essere direttamente correlate all'applicazione o possono essere servizi condivisi forniti da terze parti. In genere queste chiamate API vengono eseguite sul protocollo HTTP(S) e seguono la semantica REST.

Nella maggior parte dei casi, le API per un'applicazione client sono progettate per rispondere rapidamente, in base all'ordine di 100 ms o meno. Molti fattori possono influire sulla latenza della risposta, tra cui:

  • Stack di hosting di un'applicazione.
  • Componenti di sicurezza.
  • Posizione geografica relativa del chiamante e del back-end.
  • Infrastruttura di rete.
  • Caricamento corrente.
  • Dimensioni del payload della richiesta.
  • Lunghezza della coda di elaborazione.
  • Tempo per l'elaborazione della richiesta da parte del back-end.

Uno di questi fattori può aggiungere latenza alla risposta. Alcuni possono essere mitigati aumentando il numero di istanze del back-end. Altri, come l'infrastruttura di rete, sono in gran parte fuori dal controllo dello sviluppatore di applicazioni. La maggior parte delle API può rispondere abbastanza rapidamente per consentire alle risposte di tornare sulla stessa connessione. Il codice dell'applicazione può effettuare una chiamata API sincrona in modo non bloccante, dando l'aspetto dell'elaborazione asincrona, consigliata per le operazioni associate a I/O.

In alcuni scenari, tuttavia, il lavoro eseguito dal back-end può essere a esecuzione prolungata, nell'ordine di secondi o potrebbe essere un processo in background eseguito in minuti o persino ore. In tal caso, non è possibile attendere il completamento del lavoro prima di rispondere alla richiesta. Questa situazione è un potenziale problema per qualsiasi modello di richiesta-risposta sincrona.

Alcune architetture risolvono questo problema usando un broker di messaggi per separare le fasi di richiesta e risposta. Questa separazione viene spesso ottenuta tramite il modello di livellamento del carico basato su coda. Questa separazione può consentire il ridimensionamento indipendente del processo client e dell'API back-end. Tuttavia, questa separazione comporta anche una maggiore complessità quando il client richiede una notifica di esito positivo, perché questo passaggio deve diventare asincrono.

Molte delle stesse considerazioni illustrate per le applicazioni client si applicano anche alle chiamate API REST da server a server nei sistemi distribuiti, ad esempio in un'architettura di microservizi.

Soluzione

Una soluzione a questo problema consiste nell'usare il polling HTTP. Il polling è utile per il codice lato client, perché può essere difficile fornire endpoint di callback o usare connessioni a esecuzione prolungata. Anche quando i callback sono possibili, le librerie e i servizi aggiuntivi necessari possono talvolta aggiungere una complessità eccessiva.

  • L'applicazione client effettua una chiamata sincrona all'API, attivando un'operazione a esecuzione prolungata nel back-end.

  • L'API risponde in modo sincrono il più rapidamente possibile. Restituisce un codice di stato HTTP 202 (accettato), confermando che la richiesta è stata ricevuta per l'elaborazione.

    Nota

    L'API deve convalidare sia la richiesta che l'azione da eseguire prima di avviare il processo a esecuzione prolungata. Se la richiesta non è valida, rispondere immediatamente con un codice di errore, ad esempio HTTP 400 (richiesta non valida).

  • La risposta contiene un riferimento alla posizione che punta a un endpoint che il client può eseguire il polling per verificare il risultato dell'operazione a esecuzione prolungata.

  • L'API esegue l'offload dell'elaborazione in un altro componente, ad esempio una coda di messaggi.

  • Per ogni chiamata riuscita all'endpoint di stato, restituisce HTTP 200. Mentre il lavoro è ancora in sospeso, l'endpoint di stato restituisce una risorsa che indica che il lavoro è ancora in corso. Al termine del lavoro, l'endpoint di stato può restituire una risorsa che indica il completamento o reindirizzare a un altro URL della risorsa. Ad esempio, se l'operazione asincrona crea una nuova risorsa, l'endpoint di stato verrà reindirizzato all'URL per tale risorsa.

Il diagramma seguente illustra un flusso tipico:

Flusso di richiesta e risposta per le richieste HTTP asincrone

  1. Il client invia una richiesta e riceve una risposta HTTP 202 (accettata).
  2. Il client invia una richiesta HTTP GET all'endpoint di stato. Il lavoro è ancora in sospeso, quindi questa chiamata restituisce HTTP 200.
  3. A un certo punto, il lavoro è completo e l'endpoint di stato restituisce il reindirizzamento 302 (trovato) alla risorsa.
  4. Il client recupera la risorsa nell'URL specificato.

Considerazioni e problemi

  • Esistono diversi modi possibili per implementare questo modello su HTTP e non tutti i servizi upstream hanno la stessa semantica. Ad esempio, la maggior parte dei servizi non restituirà una risposta HTTP 202 da un metodo GET al termine di un processo remoto. Dopo la semantica REST pura, devono restituire HTTP 404 (Non trovato). Questa risposta ha senso quando si considera il risultato della chiamata non è ancora presente.

  • Una risposta HTTP 202 deve indicare la posizione e la frequenza in cui il client deve eseguire il polling per la risposta. Deve avere le intestazioni aggiuntive seguenti:

    Intestazione Descrizione Note
    Ufficio Url in cui il client deve eseguire il polling per ottenere uno stato di risposta. Questo URL potrebbe essere un token di firma di accesso condiviso con il modello di chiave del passeparte se questa posizione richiede il controllo di accesso. Il modello di chiave di controllo è valido anche quando il polling delle risposte richiede l'offload in un altro back-end.
    Retry-After Stima del completamento dell'elaborazione Questa intestazione è progettata per impedire ai client di polling di sovraccaricare il back-end con tentativi.

    Il comportamento previsto del client deve essere considerato durante la progettazione di questa risposta. Anche se un client sotto il controllo può essere codificato per rispettare questi valori di risposta in modo esplicito, i client che non sono creati dall'utente o usano un approccio senza codice o a basso codice (ad esempio App per la logica di Azure) sono liberi di avere la propria gestione della logica HTTP 202.

  • Potrebbe essere necessario usare un proxy di elaborazione o una facciata per modificare le intestazioni o il payload della risposta a seconda dei servizi sottostanti usati.

  • Se l'endpoint di stato viene reindirizzato al completamento, HTTP 302 o HTTP 303 sono codici restituiti appropriati, a seconda della semantica esatta supportata.

  • Al termine dell'elaborazione, la risorsa specificata dall'intestazione Location deve restituire un codice di risposta HTTP appropriato, ad esempio 200 (OK), 201 (Creato) o 204 (Nessun contenuto).

  • Se si verifica un errore durante l'elaborazione, rendere persistente l'errore nell'URL della risorsa descritto nell'intestazione Percorso e restituire idealmente un codice di risposta appropriato al client da tale risorsa (codice 4xx).

  • Non tutte le soluzioni implementeranno questo modello nello stesso modo e alcuni servizi includeranno intestazioni aggiuntive o alternative. Ad esempio, Azure Resource Manager usa una variante modificata di questo modello. Per altre informazioni, vedere Operazioni asincrone di Azure Resource Manager.

  • I client legacy potrebbero non supportare questo modello. In tal caso, potrebbe essere necessario posizionare una facciata sull'API asincrona per nascondere l'elaborazione asincrona dal client originale. Ad esempio, App per la logica di Azure supporta questo modello in modo nativo può essere usato come livello di integrazione tra un'API asincrona e un client che effettua chiamate sincrone. Vedere Eseguire attività a esecuzione prolungata con il modello di azione webhook.

  • In alcuni scenari, potrebbe essere necessario fornire ai client un modo per annullare una richiesta a esecuzione prolungata. In tal caso, il servizio back-end deve supportare una forma di istruzione di annullamento.

Quando usare questo modello

Usare questo modello per:

  • Il codice lato client, ad esempio le applicazioni del browser, in cui è difficile fornire endpoint di callback o l'uso di connessioni a esecuzione prolungata aggiunge una complessità eccessiva.

  • Chiamate al servizio in cui è disponibile solo il protocollo HTTP e il servizio di restituzione non può generare callback a causa di restrizioni del firewall sul lato client.

  • Chiamate di servizio che devono essere integrate con architetture legacy che non supportano tecnologie di callback moderne, ad esempio WebSocket o webhook.

Questo modello potrebbe non essere adatto quando:

  • È invece possibile usare un servizio creato per le notifiche asincrone, ad esempio Griglia di eventi di Azure.
  • Le risposte devono trasmettere in tempo reale al client.
  • Il client deve raccogliere molti risultati e la latenza ricevuta di tali risultati è importante. Si consideri invece un modello di bus di servizio.
  • È possibile usare connessioni di rete persistenti sul lato server, ad esempio WebSocket o SignalR. Questi servizi possono essere usati per notificare al chiamante il risultato.
  • La progettazione di rete consente di aprire le porte per ricevere callback asincroni o webhook.

Progettazione del carico di lavoro

Un architetto deve valutare il modo in cui il modello Asincrono Request-Reply può essere usato nella progettazione del carico di lavoro per soddisfare gli obiettivi e i principi trattati nei pilastri di Azure Well-Architected Framework. Ad esempio:

Concetto fondamentale Come questo modello supporta gli obiettivi di pilastro
L'efficienza delle prestazioni consente al carico di lavoro di soddisfare in modo efficiente le richieste tramite ottimizzazioni in termini di scalabilità, dati, codice. Separare le fasi di richiesta e risposta delle interazioni per i processi che non necessitano di risposte immediate migliora la velocità di risposta e la scalabilità dei sistemi. Come approccio asincrono, è possibile ottimizzare la concorrenza sul lato server e pianificare il lavoro da completare in base alle capacità consentite.

- PE:05 Ridimensionamento e partizionamento
- PE:07 Codice e infrastruttura

Come per qualsiasi decisione di progettazione, prendere in considerazione eventuali compromessi rispetto agli obiettivi degli altri pilastri che potrebbero essere introdotti con questo modello.

Esempio

Il codice seguente mostra estratti da un'applicazione che usa Funzioni di Azure per implementare questo modello. Nella soluzione sono presenti tre funzioni:

  • Endpoint API asincrono.
  • Endpoint di stato.
  • Funzione back-end che accetta gli elementi di lavoro in coda ed esegue tali elementi.

Immagine della struttura del modello Async Request Reply in Funzioni

Logo GitHub Questo esempio è disponibile in GitHub.

Funzione AsyncProcessingWorkAcceptor

La AsyncProcessingWorkAcceptor funzione implementa un endpoint che accetta il lavoro da un'applicazione client e lo inserisce in una coda per l'elaborazione.

  • La funzione genera un ID richiesta e lo aggiunge come metadati al messaggio della coda.
  • La risposta HTTP include un'intestazione della posizione che punta a un endpoint di stato. L'ID richiesta fa parte del percorso 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}");
    }
}

Funzione AsyncProcessingBackgroundWorker

La AsyncProcessingBackgroundWorker funzione preleva l'operazione dalla coda, esegue alcune operazioni in base al payload del messaggio e scrive il risultato in un account di archiviazione.

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

Funzione AsyncOperationStatusChecker

La AsyncOperationStatusChecker funzione implementa l'endpoint di stato. Questa funzione controlla innanzitutto se la richiesta è stata completata

  • Se la richiesta è stata completata, la funzione restituisce una chiave di servizio alla risposta oppure reindirizza immediatamente la chiamata all'URL del passepart-key.
  • Se la richiesta è ancora in sospeso, verrà restituito un codice 200, incluso lo stato corrente.
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
}

Passaggi successivi

Per l'implementazione di questo modello possono risultare utili anche le informazioni seguenti: