Partilhar via


Padrão assíncrono de solicitação-resposta

Desacoplar o processamento backend de um host frontend quando o processamento backend precisa de ser executado de forma assíncrona, mas o frontend necessita de uma resposta clara.

Contexto e problema

No desenvolvimento moderno de aplicações, as aplicações cliente dependem frequentemente de APIs remotas para fornecer lógica de negócio e funcionalidades de composição. Muitas aplicações executam código num navegador web, e outros ambientes também alojam código cliente. As APIs podem estar diretamente relacionadas com a aplicação ou operar como serviços partilhados a partir de um serviço externo. A maioria das chamadas de API utiliza HTTP ou HTTPS e segue a semântica REST.

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

  • A pilha de alojamento da aplicação
  • Componentes de segurança
  • A localização geográfica relativa do chamador e do back-end
  • Infraestrutura de rede
  • Carga de corrente
  • O tamanho da carga útil do pedido
  • Comprimento da fila de processamento
  • O tempo para o back-end processar o pedido

Estes fatores podem adicionar latência à resposta. Podes mitigar alguns fatores escalando o back-end. Outros fatores, como a infraestrutura de rede, estão fora do controlo do programador da aplicação. A maioria das APIs responde rapidamente o suficiente para que a resposta retorne pela mesma ligação. O código de aplicação pode fazer uma chamada de API síncrona de forma não bloqueante para dar a aparência de processamento assíncrono. Recomendamos esta abordagem para operações ligadas à entrada e saída (I/O).

Em alguns cenários, o backend faz um trabalho de longa duração e demora alguns segundos. Noutros cenários, o back-end realiza trabalho de fundo prolongado que dura minutos ou até mais. Nestes casos, não pode esperar que o trabalho termine antes de enviar uma resposta. Esta situação pode criar um problema para padrões síncronos de pedido-resposta. Para orientações sobre a conceção do processamento back-end, veja Tarefas em segundo plano.

Algumas arquiteturas resolvem esse problema usando um agente de mensagens para separar os estágios de solicitação e resposta. Muitos sistemas conseguem esta separação através do padrão de Nivelamento de Carga com Fila. Esta separação permite que o cliente processe e a API back-end escale de forma independente. Também introduz complexidade adicional quando o cliente necessita de notificação de sucesso, porque esse passo também tem de se tornar assíncrono.

Muitas das mesmas considerações que se aplicam às aplicações cliente também se aplicam a chamadas de API REST entre servidores em sistemas distribuídos, como numa arquitetura de microserviços.

Solução

Uma solução para esse problema é usar sondagem HTTP. O polling funciona bem para código do lado do cliente quando os endpoints de callback não estão disponíveis ou quando ligações de longa duração acrescentam demasiada complexidade. Mesmo quando os callbacks são possíveis, as bibliotecas e serviços adicionais que necessitam podem aumentar a complexidade.

Os passos seguintes descrevem a solução:

  • A aplicação cliente faz uma chamada síncrona à API para desencadear uma operação de longa duração no backend.

  • A API responde de forma síncrona o mais rápido possível. Devolve um código de estado HTTP 202 (Aceite) para confirmar que recebeu o pedido de processamento.

    Observação

    A API deve validar o pedido e a ação a realizar antes de iniciar o processo de longa duração. Se o pedido não for válido, responda imediatamente com um código de erro como HTTP 400 (Pedido Mau).

  • A resposta inclui uma referência de localização que aponta para um endpoint que o cliente pode consultar para verificar o resultado da operação de longa duração.

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

  • Por cada chamada bem-sucedida ao endpoint de estado, o endpoint devolve HTTP 200 (OK). Enquanto o trabalho está em andamento, o endpoint de estado devolve um recurso que indica esse estado. O órgão de resposta ao estado deve incluir informação suficiente para que o cliente compreenda o estado atual da operação.

    Quando o trabalho termina, o endpoint de estado devolve um recurso que indica conclusão ou redireciona para outro URL de recurso. Por exemplo, se a operação assíncrona criar um novo recurso, o endpoint de estado redireciona para a URL desse recurso.

O diagrama seguinte mostra um fluxo típico.

Diagrama que mostra o fluxo de pedidos e respostas para pedidos HTTP assíncronos.

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

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

  3. Em determinado momento, o trabalho termina e o endpoint de estado devolve HTTP 303 (Ver Outro) 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 este padrão:

  • Existem várias formas de implementar este padrão sobre HTTP, e os serviços a montante nem sempre usam a mesma semântica. Por exemplo, algumas implementações não usam um endpoint de estado separado. Em vez disso, o cliente consulta diretamente a URL do recurso de destino e recebe HTTP 404 (Não Encontrado) até que o recurso seja criado. Esta resposta faz sentido porque o recurso genuinamente ainda não existe. No entanto, esta abordagem pode ser ambígua se o 404 também for retornado para IDs de pedido inválidos. Um endpoint de estado dedicado que devolve HTTP 200 com um corpo de estado, como descrito neste padrão, evita essa ambiguidade.

  • Uma resposta HTTP 202 indica onde o cliente consulta e com que frequência. Deve incluir os seguintes cabeçalhos.

    Cabeçalho Descrição Notes
    Location Uma URL que o cliente consulta para o estado da resposta Este URL pode ser um token de assinatura de acesso partilhado. O padrão Valet Key funciona bem quando este local precisa de controlo de acesso. O padrão também se aplica quando o response polling precisa de ser transferido para outro backend.
    Retry-After Uma estimativa de quando o processamento será concluído Este cabeçalho foi concebido para evitar que clientes de polling enviem demasiados pedidos para o back-end.

    Considere o comportamento esperado do cliente ao desenhar esta resposta. Um cliente que você controla pode seguir estes valores de resposta exatamente. Clientes criados por outros, incluindo clientes construídos usando ferramentas no-code ou low-code como o Azure Logic Apps, podem aplicar o seu próprio tratamento para HTTP 202.

  • Considere incluir os seguintes campos na resposta do endpoint de estado.

    Campo Descrição Notes
    status O estado atual da operação, como Pendente, Em Execução, Sucesso, Falhada ou Cancelada. Use um conjunto consistente e documentado de valores terminais e não terminais.
    createdAt A data em que a operação foi aceite. Ajuda os clientes a detetar operações obsoletas ou abandonadas.
    lastUpdatedAt A hora em que o estado foi atualizado pela última vez. Permite aos clientes distinguir uma operação parada de uma que está a progredir ativamente.
    percentComplete Um indicador opcional de progresso. É útil quando o backend consegue estimar o progresso de forma significativa.
    error Um objeto de erro estruturado quando o estado é Falhado. Considere usar o formato RFC 9457 para maior consistência.
  • Pode precisar de usar um proxy de processamento para ajustar os cabeçalhos de resposta ou a carga útil, dependendo dos serviços subjacentes que utiliza.

  • Se o endpoint de estado redirecionar após a conclusão, use HTTP 303 (ver Outro). Um 303 instrui o cliente a emitir um pedido GET para a URL de redirecionamento, independentemente do método original do pedido. Este comportamento é a semântica correta para este padrão porque o cliente está a recuperar um recurso de resultado distinto, não a submeter novamente a operação original. HTTP 302 (Encontrado) não garante uma alteração de método; alguns clientes reproduzem o método original no redirecionamento, o que pode causar efeitos secundários indesejados, como pedidos POST duplicados.

  • Depois de o servidor processar com sucesso o pedido, o recurso que o Location cabeçalho especifica devolve um código de estado HTTP como 200, 201 (Criado) ou 204 (Sem Conteúdo).

  • Se ocorrer um erro durante o processamento, registe o erro na URL do recurso especificada pelo cabeçalho Location e devolva um código de estado 4xx desse recurso que corresponda à falha. Use um formato estruturado de erro, como o RFC 9457 (Detalhes do Problema para APIs HTTP), para que os clientes possam analisar e lidar com falhas programáticamente.

  • O recurso de estado e quaisquer resultados armazenados consomem armazenamento e computação. Defina uma política de retenção para limpá-los após um período razoável e considere comunicar o período de retenção aos clientes através de um Expires cabeçalho na resposta de estado.

  • As soluções nem todas implementam este padrão da mesma forma, e alguns serviços incluem cabeçalhos extra ou alternativos. Por exemplo, o Azure Resource Manager utiliza uma variante modificada deste padrão. Para mais informações, veja operações assíncronas do Resource Manager.

  • Os clientes legados podem não suportar este padrão. Nesse caso, talvez seja necessário colocar uma fachada sobre a API assíncrona para ocultar o processamento assíncrono do cliente original. Por exemplo, o Logic Apps suporta este padrão de forma nativa, e pode usá-lo como camada de integração entre uma API assíncrona e um cliente que faz chamadas síncronas. Para mais informações, consulte Comportamento de pedido-resposta assíncrono no Azure Logic Apps.

  • Em alguns cenários, pode ser necessário oferecer uma forma para os clientes cancelarem um pedido de longa duração. Nesse caso, expõe uma operação de DELETE no recurso endpoint de estado. Este pedido deve encaminhar uma instrução de cancelamento para o componente de processamento backend. Depois de o backend tratar do cancelamento, deve atualizar o recurso de estado para refletir o estado cancelado. Este processo ajuda a evitar que trabalhos incompletos consumam recursos indefinidamente. Considere se a operação suporta um rollback parcial ou se é melhor tratada como uma transação compensatória.

  • Considere exigir que os clientes forneçam uma chave de idempotência (por exemplo, num cabeçalho do Idempotency-Key pedido) ao submeter o pedido inicial. Se o backend receber uma chave duplicada, deve devolver o recurso de estado existente em vez de enfileirar um segundo item de trabalho. Esta abordagem protege contra falhas de rede que levam o cliente a tentar novamente um POST que o servidor já aceitou. É especialmente importante neste padrão porque o cliente não tem forma de distinguir uma resposta perdida de um pedido que nunca foi recebido.

Observação

Este padrão descreve a sondagem HTTP, onde o cliente periodicamente emite novos pedidos para verificar o estado. A sondagem longa é uma técnica relacionada, mas distinta: o cliente envia um pedido e o servidor mantém a ligação aberta até que novos dados estejam disponíveis ou ocorra um timeout. As sondagens longas reduzem a latência de resposta em comparação com as sondagens periódicas, mas introduzem complexidade em torno da gestão de ligação e dos timeouts.

Quando utilizar este padrão

Utilize este padrão quando:

  • Trabalhas com código do lado do cliente, como aplicações de navegador, e essas restrições tornam os endpoints de callback difíceis de fornecer, ou ligações de longa duração acrescentam demasiada complexidade.

  • Chamas um serviço que usa apenas o protocolo HTTP e o serviço de retorno não pode enviar callbacks devido às restrições do firewall do lado do cliente.

  • Integra-se com workloads que não suportam mecanismos modernos de callback como WebSockets ou webhooks.

Este padrão pode não ser adequado quando:

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

  • As respostas devem ser transmitidas em tempo real para o cliente. Considere os Eventos Enviados pelo Servidor (SSE), que fornecem um canal push unidirecional, nativo em HTTP e leve, do servidor para o cliente, sem necessidade de fazer polling do cliente.

  • O cliente precisa de recolher muitos resultados e a latência desses resultados é importante. Considere antes um intermediário de mensagens.

  • Estão disponíveis ligações persistentes de rede do lado do servidor, como WebSockets ou SignalR. Pode usar estas ligações para notificar o ouvinte do resultado.

  • O design de rede suporta portas abertas para receber chamadas de retorno assíncronas ou webhooks.

Design da carga de trabalho

Um arquiteto deve avaliar como pode utilizar o padrão Asynchronous Request-Reply no design da sua carga de trabalho para alcançar os objetivos e princípios nos pilares do Azure Well-Architected Framework.

Pilar Como esse padrão suporta os objetivos do pilar
A Eficiência de Desempenho ajuda sua carga de trabalho a atender às demandas de forma eficiente por meio de otimizações em escala, dados e código. Melhora a capacidade de resposta e escalabilidade ao desacoplar as fases de pedido e resposta para processos que não exigem resposta imediata. Uma abordagem assíncrona aumenta a concorrência e permite ao servidor agendar o trabalho à medida que a capacidade se torna disponível.

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

Como em qualquer decisão de design, considere as compensações em relação aos objetivos dos outros pilares que este padrão possa introduzir.

Exemplo

O código seguinte mostra excertos de uma aplicação que utiliza o Azure Functions para implementar este padrão. Esta solução tem três funções:

  • O endpoint assíncrono da API
  • O ponto final de estado
  • Uma função de back-end que processa itens de trabalho em fila e executa-os

Diagrama da estrutura do padrão Assíncrono de Resposta a Pedidos em Funções.

GitHub logótipo. Este exemplo está disponível em GitHub.

A implementação utiliza identidade gerida para autenticar com Azure Service Bus e Azure Blob Storage, o que evita armazenar cadeias de ligação ou chaves de conta. As dependências são registadas em Program.cs usando DefaultAzureCredential e injetadas através de construtores primários.

Função AsyncProcessingWorkAcceptor

A AsyncProcessingWorkAcceptor função implementa um endpoint que aceita trabalho de uma aplicação cliente e coloca-o numa fila de espera para processamento.

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

  • A resposta HTTP inclui um Location cabeçalho que aponta para um ponto final de estado e um Retry-After cabeçalho que sugere um intervalo de sondagem. O ID do pedido aparece no caminho da URL.

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

        string requestId = Guid.NewGuid().ToString();

        string statusUrl = $"https://{Environment.GetEnvironmentVariable("WEBSITE_HOSTNAME")}/api/RequestStatus/{requestId}";

        var messagePayload = JsonConvert.SerializeObject(customer);
        var message = new ServiceBusMessage(messagePayload);
        message.ApplicationProperties.Add("RequestGUID", requestId);
        message.ApplicationProperties.Add("RequestSubmittedAt", DateTime.UtcNow);
        message.ApplicationProperties.Add("RequestStatusURL", statusUrl);
        var sender = _serviceBusClient.CreateSender("outqueue");

        await sender.SendMessageAsync(message);

        req.HttpContext.Response.Headers["Retry-After"] = "5";

        return new AcceptedResult(statusUrl, null);
    }
}

Função AsyncProcessingBackgroundWorker

A AsyncProcessingBackgroundWorker função lê a operação da fila, processa-a com base na carga útil da mensagem e grava o resultado numa conta de armazenamento.

public class AsyncProcessingBackgroundWorker(BlobContainerClient _blobContainerClient)
{
    [Function("AsyncProcessingBackgroundWorker")]
    public async Task Run(
        [ServiceBusTrigger("outqueue", Connection = "ServiceBusConnection")] ServiceBusReceivedMessage message)
    {
        // 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 requestGuid = message.ApplicationProperties["RequestGUID"].ToString();
        string blobName = $"{requestGuid}.blobdata";

        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 AsyncOperationStatusChecker função implementa o ponto de extremidade de status. Esta função verifica o estado do pedido:

  • Se o pedido for concluído, a função devolve HTTP 303 (Ver Outro), redirecionando o cliente para uma URL de chave de valet para o resultado.

  • Se o pedido estiver pendente, a função devolve um código HTTP 200 que inclui o estado atual.

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

        _logger.LogInformation("Received status request for {RequestId} - OnComplete {OnComplete} - OnPending {OnPending}",
            requestId, OnComplete, 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, requestId, req);
        }
        else
        {
            // If the blob doesn't exist, the function uses the OnPending parameter to determine the next action.
            switch (OnPending)
            {
                case OnPendingEnum.OK:
                    {
                        // Return an HTTP 200 status code.
                        return new OkObjectResult(new { status = "In progress", Location = rqs });
                    }

                case OnPendingEnum.Synchronous:
                    {
                        // Long polling example: hold the connection open and check for completion
                        // using exponential backoff. Time out after approximately one minute.
                        int backoff = 250;

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

                        if (await inputBlob.ExistsAsync())
                        {
                            _logger.LogInformation("Synchronous mode {RequestId} - completed after {Backoff} ms", requestId, backoff);
                            return await OnCompleted(OnComplete, inputBlob, requestId, req);
                        }
                        else
                        {
                            _logger.LogInformation("Synchronous mode {RequestId} - NOT FOUND after timeout {Backoff} ms", requestId, backoff);
                            return new NotFoundResult();
                        }
                    }

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

    private async Task<IActionResult> OnCompleted(OnCompleteEnum OnComplete, BlockBlobClient inputBlob, string requestId, HttpRequest req)
    {
        switch (OnComplete)
        {
            case OnCompleteEnum.Redirect:
                {
                    // Generate a user delegation SAS URI using managed identity credentials.
                    BlobServiceClient blobServiceClient = inputBlob.GetParentBlobContainerClient().GetParentBlobServiceClient();
                    var userDelegationKey = await blobServiceClient.GetUserDelegationKeyAsync(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddDays(7));

                    // Return 303 See Other to redirect the client to the result resource.
                    // GenerateUserDelegationSasUri is a custom helper; see the full implementation on GitHub.
                    req.HttpContext.Response.Headers.Location = GenerateUserDelegationSasUri(inputBlob, userDelegationKey);;
                    return new StatusCodeResult(StatusCodes.Status303SeeOther);
                }

            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
}

Passos seguintes