Condividi tramite


Modello Request-Reply asincrono

Separare l'elaborazione back-end da un host front-end quando l'elaborazione back-end deve essere eseguita in modo asincrono, ma il front-end richiede una risposta chiara.

Contesto e problema

Nello sviluppo di applicazioni moderne, le applicazioni client spesso si basano su API remote per fornire la logica di business e comporre funzionalità. Molte applicazioni eseguono codice in un Web browser e altri ambienti ospitano anche il codice client. Le API possono essere correlate direttamente all'applicazione o che operano come servizi condivisi da un servizio esterno. La maggior parte delle chiamate API usa HTTP o HTTPS e segue la semantica REST.

Nella maggior parte dei casi, le API per un'applicazione client rispondono in circa 100 millisecondi (ms) o meno. Molti fattori possono influire sulla latenza della risposta:

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

Questi fattori possono aggiungere latenza alla risposta. È possibile attenuare alcuni fattori aumentando il back-end. Altri fattori, ad esempio l'infrastruttura di rete, non sono al di fuori del controllo dello sviluppatore dell'applicazione. La maggior parte delle API risponde abbastanza rapidamente per restituire la risposta sulla stessa connessione. Il codice dell'applicazione può effettuare una chiamata API sincrona in modo non bloccante per dare l'aspetto dell'elaborazione asincrona. È consigliabile usare questo approccio per le operazioni associate a input e output (I/O).

In alcuni scenari, il back-end esegue operazioni prolungate e può richiedere alcuni secondi. In altri scenari, il backend esegue operazioni in background a lungo termine per alcuni minuti o per periodi estesi. In questi casi, non è possibile attendere il completamento del lavoro prima di inviare una risposta. Questa situazione può creare un problema per i modelli di richiesta-risposta sincroni.

Alcune architetture risolvono questo problema usando un broker di messaggi per separare le fasi di richiesta e risposta. Molti sistemi ottengono questa separazione tramite il modello di livellamento del caricoQueue-Based. Questa separazione consente al processo client e all'API back-end di essere ridimensionati in modo indipendente. Introduce anche una maggiore complessità quando il client richiede una notifica di esito positivo perché anche questo passaggio deve diventare asincrono.

Molte delle stesse considerazioni che si applicano alle 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 funziona bene per il codice lato client quando gli endpoint di callback non sono disponibili o quando le connessioni persistenti aggiungono troppa complessità. Anche quando i callback sono possibili, le librerie e i servizi aggiuntivi necessari possono aumentare la complessità.

I passaggi seguenti descrivono la soluzione:

  • L'applicazione client effettua una chiamata sincrona all'API per attivare un'operazione a esecuzione prolungata sul back-end.

  • L'API risponde in modo sincrono il più rapidamente possibile. Restituisce un codice di stato HTTP 202 (accettato) per confermare che ha ricevuto la richiesta di elaborazione.

    Annotazioni

    L'API convalida la richiesta e l'azione da eseguire prima di avviare il processo a esecuzione prolungata. Se la richiesta non è valida, rispondere immediatamente con un codice di errore come HTTP 400 (richiesta non valida).

  • La risposta include un riferimento alla posizione che punta a un endpoint che il client può utilizzare per controllare il risultato dell'operazione a lunga durata.

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

  • Per una chiamata riuscita all'endpoint di stato, l'endpoint restituisce HTTP 200 (OK). Mentre il lavoro è in corso, l'endpoint restituisce una risorsa che indica tale stato. Al termine del lavoro, l'endpoint restituisce una risorsa che indica il completamento o il reindirizzamento a un altro URL della risorsa. Ad esempio, se l'operazione asincrona crea una nuova risorsa, l'endpoint di stato reindirizza all'URL per tale risorsa.

Il diagramma seguente mostra un flusso tipico.

Diagramma che mostra il flusso di richiesta e risposta per le richieste HTTP asincrone.

  1. Il client invia una richiesta e riceve una risposta HTTP 202.

  2. Il client invia una richiesta HTTP GET all'endpoint di stato. Il lavoro è in sospeso, quindi questa chiamata restituisce HTTP 200.

  3. Il lavoro viene completato e l'endpoint di stato restituisce HTTP 302 (trovato) per il reindirizzamento alla risorsa.

  4. Il client recupera la risorsa nell'URL specificato.

Problemi e considerazioni

Quando si decide come implementare questo modello, tenere presente quanto segue:

  • Esistono più modi per implementare questo modello su HTTP e i servizi upstream non usano sempre la stessa semantica. Ad esempio, la maggior parte dei servizi restituisce HTTP 404 (Non trovato) da un metodo GET quando un processo remoto non è completo, anziché HTTP 202. In base alla semantica REST standard, HTTP 404 è la risposta corretta perché il risultato della chiamata non esiste ancora.

  • Una risposta HTTP 202 indica dove il client esegue il polling e la frequenza. Comprende le seguenti intestazioni.

    Intestazione Descrizione Note
    Location URL a cui il client controlla lo stato della risposta Questo URL può essere un token di firma di accesso condiviso. Il modello Valet Key funziona bene quando questa posizione richiede il controllo di accesso. Il modello si applica anche quando il polling delle risposte deve essere trasferito a un altro back-end.
    Retry-After Stima di quando l'elaborazione sarà completata Questa intestazione impedisce ai client di polling di inviare troppe richieste al server di backend.

    Prendere in considerazione il comportamento previsto del client quando si progetta questa risposta. Un client controllato può seguire esattamente questi valori di risposta. I client creati da altri utenti, inclusi i client creati tramite strumenti senza codice o con poco codice come Azure Logic Apps, possono applicare la propria gestione per HTTP 202.

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

  • Se l'endpoint di stato viene reindirizzato dopo il completamento, HTTP 302 o HTTP 303 (vedere Altro) sono codici restituiti validi, a seconda della semantica supportata.

  • Dopo che il server elabora la richiesta, la risorsa specificata dall'intestazione Location restituisce un codice di stato HTTP come 200, 201 (Creato) o 204 (Nessun contenuto).

  • Se si verifica un errore durante l'elaborazione, rendere persistente l'errore nell'URL della risorsa specificato dall'intestazione Location e restituire un codice di stato 4xx da tale risorsa che corrisponde all'errore.

  • Le soluzioni non implementano tutti questo modello allo stesso modo e alcuni servizi includono intestazioni aggiuntive o alternative. Ad esempio, Azure Resource Manager usa una variante modificata di questo modello. Per ulteriori informazioni, vedere le operazioni asincrone del Resource Manager.

  • I client legacy potrebbero non supportare questo modello. In tal caso, potrebbe essere necessario inserire un proxy di elaborazione sull'API asincrona per nascondere l'elaborazione asincrona dal client originale. Ad esempio, App per la logica supporta questo modello in modo nativo ed è possibile usarlo come livello di integrazione tra un'API asincrona e un client che effettua chiamate sincrone. Per altre informazioni, 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 quando:

  • Lavori con codice lato client, come le applicazioni browser, e questi vincoli rendono difficile fornire endpoint di callback, o le connessioni di lunga durata aggiungono troppa complessità.

  • Viene chiamato un servizio che utilizza solo il protocollo HTTP e il servizio di ritorno non può inviare callback a causa delle restrizioni del firewall dal lato del client.

  • Si integra con architetture legacy che non supportano meccanismi di callback moderni, ad esempio WebSocket o webhook.

Questo modello potrebbe non essere adatto quando:

  • È invece possibile usare un servizio compilato per le notifiche asincrone, ad esempio Azure Event Grid.

  • Le risposte devono essere trasmesse in tempo reale al client.

  • Il client deve raccogliere molti risultati e la latenza di questi risultati è importante. Si consideri invece un modello di bus di servizio.

  • Sono disponibili connessioni di rete persistenti sul lato server, ad esempio WebSocket o SignalR. È possibile usare queste connessioni per notificare al chiamante il risultato.

  • La progettazione di rete supporta porte aperte per ricevere callback asincroni o webhook.

Progettazione del carico di lavoro

Un architetto deve valutare come usare il modello asincrono Request-Reply nella progettazione del carico di lavoro per soddisfare gli obiettivi e i principi trattati nei pilastri del framework Azure Well-Architected.

Pilastro Come questo modello supporta gli obiettivi di pilastro
l'efficienza delle prestazioni consente al carico di lavoro soddisfare in modo efficiente le richieste tramite ottimizzazioni di ridimensionamento, dati e codice. È possibile migliorare la velocità di risposta e la scalabilità separando le fasi di richiesta e risposta per i processi che non richiedono una risposta immediata. Un approccio asincrono aumenta la concorrenza e consente al server di pianificare il lavoro man mano che la capacità diventa disponibile.

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

Come per qualsiasi decisione di progettazione, prendere in considerazione compromessi rispetto agli obiettivi degli altri pilastri che questo modello potrebbe introdurre.

Esempio

Il codice seguente mostra estratti da un'applicazione che usa Azure Functions per implementare questo modello. Questa soluzione ha tre funzioni:

  • API endpoint asincrono
  • Endpoint di stato
  • Funzione back-end che accetta elementi di lavoro in coda e li esegue

Diagramma della struttura del modello di richiesta e risposta asincrona in funzioni.

GitHub logo. Questo esempio è disponibile in GitHub.

Funzione AsyncProcessingWorkAcceptor

La AsyncProcessingWorkAcceptor funzione implementa un endpoint che accetta un task da un'applicazione client e lo accoda per l'elaborazione.

  • La funzione genera un ID di richiesta e lo aggiunge come metadati al messaggio della coda.

  • La risposta HTTP include un'intestazione Location che punta a un endpoint di stato. L'ID richiesta viene visualizzato nel percorso URL.

    public class AsyncProcessingWorkAcceptor(ServiceBusClient _serviceBusClient)
    {
        [Function("AsyncProcessingWorkAcceptor")]
        public async Task<IActionResult> RunAsync([HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = null)] HttpRequest req, [FromBody] CustomerPOCO customer)
        {
            if (string.IsNullOrEmpty(customer.id) || string.IsNullOrEmpty(customer.customername))
            {
                return new BadRequestResult();
            }

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

            string scheme = Environment.GetEnvironmentVariable("AZURE_FUNCTIONS_ENVIRONMENT") == "Development" ? "http" : "https";
            var rqs = $"{scheme}://{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);
            var sender = _serviceBusClient.CreateSender("outqueue");

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

La funzione AsyncProcessingBackgroundWorker

La AsyncProcessingBackgroundWorker funzione legge l'operazione dalla coda, la elabora in base al payload del messaggio e scrive il risultato in un account di archiviazione.

    public class AsyncProcessingBackgroundWorker(BlobContainerClient _blobContainerClient)
    {
        [Function(nameof(AsyncProcessingBackgroundWorker))]
        public async Task Run([ServiceBusTrigger("outqueue", Connection = "ServiceBusConnection")] ServiceBusReceivedMessage message)
        {
            var requestGuid = message.ApplicationProperties["RequestGUID"].ToString();
            string blobName = $"{requestGuid}.blobdata";

            await _blobContainerClient.CreateIfNotExistsAsync();

            var blobClient = _blobContainerClient.GetBlobClient(blobName);
            using (MemoryStream memoryStream = new MemoryStream())
            using (StreamWriter writer = new StreamWriter(memoryStream))
            {
                writer.Write(message.Body.ToString());
                writer.Flush();
                memoryStream.Position = 0;

                await blobClient.UploadAsync(memoryStream, overwrite: true);
            }
        }
    }

Funzione AsyncOperationStatusChecker

La AsyncOperationStatusChecker funzione implementa l'endpoint di stato. Questa funzione controlla lo stato della richiesta:

    public class AsyncOperationStatusChecker(ILogger<AsyncOperationStatusChecker> _logger)
    {  
        [Function("AsyncOperationStatusChecker")]
        public async Task<IActionResult> Run([HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "RequestStatus/{thisGUID}")] HttpRequest req,
             [BlobInput("data/{thisGUID}.blobdata", Connection = "DataStorage")] BlockBlobClient inputBlob, string thisGUID)
        {
            OnCompleteEnum OnComplete = Enum.Parse<OnCompleteEnum>(req.Query["OnComplete"].FirstOrDefault() ?? "Redirect");
            OnPendingEnum OnPending = Enum.Parse<OnPendingEnum>(req.Query["OnPending"].FirstOrDefault() ?? "OK");

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

            // Check whether the blob exists.
            if (await inputBlob.ExistsAsync())
            {
                // If the blob exists, the function uses the OnComplete parameter to determine the next action.
                return await OnCompleted(OnComplete, inputBlob, thisGUID);
            }
            else
            {
                // If the blob doesn't exist, the function uses the OnPending parameter to determine the next action.
                string scheme = Environment.GetEnvironmentVariable("AZURE_FUNCTIONS_ENVIRONMENT") == "Development" ? "http" : "https";
                string rqs = $"{scheme}://{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 back-off period reaches one minute.
                            int backoff = 250;

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

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

                    default:
                        {
                            throw new InvalidOperationException($"Unexpected value: {OnPending}");
                        }
                }
            }
        }
        private async Task<IActionResult> OnCompleted(OnCompleteEnum OnComplete, BlockBlobClient inputBlob, string thisGUID)
        {
            switch (OnComplete)
            {
                case OnCompleteEnum.Redirect:

                    {
                        // The typical way to generate a shared access signature token in code requires the storage account key.
                        // If you need to use a managed identity to control access to your storage accounts in code, which is a recommended best practice, you should do so when possible.
                        // In this scenario, you don't have a storage account key, so you need to find another way to generate the shared access signatures.
                        // To generate shared access signatures, use a user delegation shared access signature. This approach lets you sign the shared access signature by using Microsoft Entra ID credentials instead of the storage account key.

                        BlobServiceClient blobServiceClient = inputBlob.GetParentBlobContainerClient().GetParentBlobServiceClient();
                        var userDelegationKey = await blobServiceClient.GetUserDelegationKeyAsync(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddDays(7));
                        // Redirect the shared access signature uniform resource identifier (URI) to blob storage.
                        return new RedirectResult(inputBlob.GenerateSASURI(userDelegationKey));
                    }

                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
    }

La seguente classe CloudBlockBlobExtensions fornisce un metodo di estensione che il verificatore dello stato usa per generare un URI (Uniform Resource Identifier) della firma di accesso condiviso di delega utente per il blob dei risultati.

    public static class CloudBlockBlobExtensions
    {
        public static string GenerateSASURI(this BlockBlobClient inputBlob, UserDelegationKey userDelegationKey)
        {
            BlobServiceClient blobServiceClient = inputBlob.GetParentBlobContainerClient().GetParentBlobServiceClient();

            BlobSasBuilder blobSasBuilder = new BlobSasBuilder()
            {
                BlobContainerName = inputBlob.BlobContainerName,
                BlobName = inputBlob.Name,
                Resource = "b",
                StartsOn = DateTimeOffset.UtcNow,
                ExpiresOn = DateTimeOffset.UtcNow.AddMinutes(10)
            };
            blobSasBuilder.SetPermissions(BlobSasPermissions.Read);

            var blobUriBuilder = new BlobUriBuilder(inputBlob.Uri)
            {
                Sas = blobSasBuilder.ToSasQueryParameters(userDelegationKey, blobServiceClient.AccountName)
            };

            // Generate the shared access signature on the blob, which sets the constraints directly on the signature.
            Uri sasUri = blobUriBuilder.ToUri();

            // Return the URI string for the container, including the shared access signature token.
            return sasUri.ToString();
        }
    }

Passaggi successivi