Compartilhar via


Padrão de solicitação/resposta assíncrona

Desacoplar o processamento de back-end de um host front-end quando o processamento de back-end precisa ser executado de forma assíncrona, mas o front-end precisa de uma resposta clara.

Contexto e problema

No desenvolvimento de aplicativos modernos, os aplicativos cliente geralmente dependem de APIs remotas para fornecer lógica de negócios e compor funcionalidades. Muitos aplicativos executam código em um navegador da Web e outros ambientes também hospedam o código do cliente. As APIs podem se relacionar diretamente com o aplicativo ou operar como serviços compartilhados de um serviço externo. A maioria das chamadas à API usa HTTP ou HTTPS e segue a semântica REST.

Na maioria dos casos, as APIs de um aplicativo cliente respondem em cerca de 100 milissegundos (ms) ou menos. Muitos fatores podem afetar a latência de resposta:

  • A pilha de hospedagem do aplicativo
  • Componentes de segurança
  • A localização geográfica relativa do chamador e do backend
  • Infra-estrutura de rede
  • Carga atual
  • O tamanho do payload da solicitação
  • Comprimento da fila de processamento
  • O tempo para o back-end processar a solicitação

Esses fatores podem adicionar latência à resposta. Você pode atenuar alguns fatores dimensionando o back-end. Outros fatores, como a infraestrutura de rede, estão fora do controle do desenvolvedor de aplicativos. A maioria das APIs responde rapidamente o suficiente para que a resposta retorne pela mesma conexão. O código do aplicativo pode fazer uma chamada de API síncrona de forma não desbloqueada para dar a aparência do processamento assíncrono. Recomendamos essa abordagem para operações associadas a entrada e saída (E/S).

Em alguns cenários, o back-end realiza tarefas que são prolongadas e levam alguns segundos. Em outros cenários, o back-end realiza tarefas de longa duração em segundo plano por minutos ou por períodos estendidos. Nesses casos, você não pode esperar que o trabalho seja concluído antes de enviar uma resposta. Essa situação pode criar um problema para padrões síncronos de solicitação-resposta.

Algumas arquiteturas resolvem esse problema usando um agente de mensagens para separar os estágios de solicitação e resposta. Muitos sistemas conseguem essa separação por meio do Padrão de Nivelamento de Carga Baseado em Fila. Essa separação permite que o processo do cliente e a API de back-end sejam dimensionados de forma independente. Ele também introduz complexidade extra quando o cliente requer notificação de êxito, pois essa etapa também deve se tornar assíncrona.

Muitas das mesmas considerações que se aplicam a aplicativos cliente também se aplicam a chamadas de API REST de servidor para servidor em sistemas distribuídos, como em uma arquitetura de microsserviços.

Solução

Uma solução para o problema é usar a sondagem HTTP. A técnica de sondagem funciona bem para o código cliente quando os endpoints de callback não estão disponíveis ou quando conexões de longa duração adicionam muita complicação. Mesmo quando os retornos de chamada são possíveis, as bibliotecas e serviços extras necessários podem aumentar a complexidade.

As seguintes etapas descrevem a solução:

  • O aplicativo cliente faz uma chamada síncrona para a API para disparar uma operação de execução prolongada no back-end.

  • A API responde de forma síncrona o mais rápido possível. Ele retorna um código de status HTTP 202 (Aceito) para reconhecer que recebeu a solicitação de processamento.

    Observação

    A API valida a solicitação e a ação a ser executada antes de iniciar o processo de execução prolongada. Se a solicitação não for válida, responda imediatamente com um código de erro como HTTP 400 (Solicitação Incorreta).

  • A resposta inclui uma referência de URL que aponta para um endpoint que o cliente pode sondar para verificar o resultado da operação de execução prolongada.

  • A API descarrega o processamento para outro componente, como uma fila de mensagens.

  • Para uma chamada bem-sucedida de status no ponto de extremidade, este retorna HTTP 200 (OK). Enquanto o trabalho está em andamento, o endpoint retorna um recurso que indica esse estado. Quando o trabalho é concluído, o ponto de extremidade retornará um recurso que indica a conclusão ou redireciona para outra URL de recurso. Por exemplo, se a operação assíncrona criar um novo recurso, o endpoint de status redirecionará para a URL desse recurso.

O diagrama a seguir mostra um fluxo típico.

Diagrama que mostra o fluxo de solicitação e resposta para solicitações HTTP assíncronas.

  1. O cliente envia uma solicitação e recebe uma resposta HTTP 202.

  2. O cliente envia uma solicitação HTTP GET ao endpoint de status. O trabalho está pendente, portanto, essa chamada retorna HTTP 200.

  3. O trabalho é concluído e o endpoint de status retorna HTTP 302 (Found) para redirecionar para o recurso.

  4. O cliente busca o recurso na URL especificada.

Problemas e considerações

Considere os seguintes pontos ao decidir como implementar esse padrão:

  • Existem várias maneiras de implementar esse padrão por HTTP e os serviços upstream nem sempre usam a mesma semântica. Por exemplo, a maioria dos serviços retorna HTTP 404 (Não Encontrado) de um método GET quando um processo remoto não é concluído, em vez de HTTP 202. De acordo com a semântica REST padrão, HTTP 404 é a resposta correta porque o resultado da chamada ainda não existe.

  • Uma resposta HTTP 202 indica onde o cliente pesquisa e com que frequência. Ele inclui os cabeçalhos a seguir.

    Cabeçalho Descrição Observações
    Location Uma URL que o cliente sonda para obter um status de resposta Essa URL pode ser um token de assinatura de acesso compartilhado. O padrão de chave de manobrista funciona bem quando esse local precisa de controle de acesso. O padrão também se aplica quando é necessário transferir a sondagem de resposta para outro back-end.
    Retry-After A estimativa de quando o processamento será concluído Esse cabeçalho impede que os clientes de sondagem enviem muitas solicitações para o back-end.

    Considere o comportamento esperado do cliente ao projetar essa resposta. Um cliente que você controla pode seguir esses valores de resposta exatamente. Os clientes que outras pessoas criarem, incluindo clientes criados usando ferramentas sem código ou de baixo código, como Azure Logic Apps, podem aplicar sua própria manipulação para HTTP 202.

  • Talvez seja necessário usar um proxy de processamento para ajustar os cabeçalhos de resposta ou o conteúdo, dependendo dos serviços subjacentes que você usa.

  • Caso o endpoint de status redirecione após a conclusão, HTTP 302 ou HTTP 303 (Ver Outro) são códigos de retorno válidos, dependendo das semânticas que você suporta.

  • Depois que o servidor processa a solicitação, o recurso especificado Location pelo cabeçalho retorna um código de status HTTP como 200, 201 (Criado) ou 204 (Sem Conteúdo).

  • Se ocorrer um erro durante o processamento, persista o erro na URL do recurso que o Location cabeçalho especifica e retorna um código de status 4xx desse recurso que corresponde à falha.

  • As soluções não implementam esse padrão da mesma maneira e alguns serviços incluem cabeçalhos extras ou alternativos. Por exemplo, Azure Resource Manager usa uma variante modificada desse padrão. Para obter mais informações, consulte operações assíncronas do Resource Manager.

  • Clientes legados podem não suportar este padrão. Nesse caso, talvez seja necessário colocar um proxy de processamento sobre a API assíncrona para ocultar o processamento assíncrono do cliente original. Por exemplo, os Aplicativos Lógicos dão suporte a esse padrão nativamente e você pode usá-lo como uma camada de integração entre uma API assíncrona e um cliente que faz chamadas síncronas. Para obter mais informações, consulte Realizar tarefas prolongadas com o padrão de ação de webhook.

  • Em alguns cenários, pode ser desejável oferecer aos clientes uma maneira de cancelar uma solicitação de execução prolongada. Nesse caso, o serviço de back-end deve dar suporte a alguma forma de instrução de cancelamento.

Quando usar esse padrão

Use esse padrão quando:

  • Você trabalha com código cliente, como aplicativos de navegador, e essas restrições tornam difícil fornecer endpoints de callback, ou conexões de longa duração adicionam muita complexidade.

  • Você chama um serviço que usa apenas o protocolo HTTP e o serviço de retorno não pode enviar retornos de chamada devido a restrições de firewall no lado do cliente.

  • Você se integra a arquiteturas herdadas que não suportam mecanismos modernos de callback, como WebSockets ou webhooks.

O padrão pode não ser adequado nestes casos:

  • Você pode usar um serviço criado para notificações assíncronas, como Azure Event Grid.

  • É preciso transmitir as respostas ao cliente em tempo real.

  • O cliente precisa coletar muitos resultados e a latência desses resultados é importante. Em vez disso, considere um padrão de barramento de serviço.

  • Conexões de rede persistente do lado do servidor, como WebSockets ou SignalR, estão disponíveis. Você pode usar essas conexões para notificar o chamador do resultado.

  • O design de rede dá suporte a portas abertas para receber retornos de chamada assíncronos ou webhooks.

Design de carga de trabalho

Um arquiteto deve avaliar como pode implementar o padrão de Solicitação-Resposta Assíncrona no design de sua carga de trabalho para atender às metas e princípios mencionados nos pilares do Azure Well-Architected Framework.

Pilar Como esse padrão apoia os objetivos do pilar
A Eficiência de Desempenho ajuda sua carga de trabalho a atender com eficiência às demandas por meio de otimizações no dimensionamento, nos dados e no código. Você melhora a capacidade de resposta e a escalabilidade desassociando as fases de solicitação e resposta para processos que não exigem uma resposta imediata. Uma abordagem assíncrona aumenta a simultaneidade e permite que o agendamento do servidor funcione conforme a capacidade fica disponível.

- PE:05 Dimensionamento e particionamento
- PE:07 Código e infraestrutura

Assim como acontece com qualquer decisão de design, considere as compensações em relação às metas dos outros pilares que esse padrão pode introduzir.

Exemplo

O código a seguir mostra trechos de um aplicativo que usa Azure Functions para implementar esse padrão. Esta solução tem três funções:

  • O ponto de extremidade da API assíncrona
  • O endpoint de status
  • Uma função de back-end que toma itens de trabalho da fila e os executa

Diagrama da estrutura do padrão de Resposta à Solicitação Assíncrona em Funções.

GitHub logo. Este exemplo está disponível em GitHub.

Função AsyncProcessingWorkAcceptor

A AsyncProcessingWorkAcceptor função implementa um ponto de extremidade que aceita o trabalho de um aplicativo cliente e o enfileira para processamento:

  • A função gera uma ID de solicitação e a adiciona como metadados à mensagem da fila.

  • A resposta HTTP inclui um Location cabeçalho que aponta para um endpoint de status. A ID da solicitação aparece no caminho da 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}");
        }
    }

Função AsyncProcessingBackgroundWorker

A AsyncProcessingBackgroundWorker função lê a operação da fila, processa-a com base no conteúdo da mensagem e grava o resultado em uma conta de armazenamento.

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

Função AsyncOperationStatusChecker

A função AsyncOperationStatusChecker implementa o endpoint de status. Essa função verifica o status da solicitação:

    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
    }

A classe a seguir CloudBlockBlobExtensions fornece um método de extensão que o verificador de status usa para gerar um URI (Uniform Resource Identifier, identificador de recurso uniforme) de assinatura de acesso compartilhado de delegação de usuário para o blob de resultados.

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

Próximas Etapas