Editar

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

Azure
Azure Logic Apps

Dissociar o processamento de back-end de um host front-end onde o processamento de back-end tem de ser assíncrono mas o front-end continua a necessitar de uma resposta clara.

Contexto e problema

No desenvolvimento de aplicativos modernos, é normal que aplicativos cliente — geralmente código executado em um web-client (navegador) — dependam de APIs remotas para fornecer lógica de negócios e funcionalidade de composição. Essas APIs podem estar diretamente relacionadas ao aplicativo ou podem ser serviços compartilhados fornecidos por terceiros. Normalmente, essas chamadas de API ocorrem pelo protocolo HTTP(S) e seguem a semântica REST.

Na maioria dos casos, as APIs para um aplicativo cliente são projetadas para responder rapidamente, na ordem de 100 ms ou menos. Muitos fatores podem afetar a latência da resposta, incluindo:

  • Pilha de hospedagem de um aplicativo.
  • Componentes de segurança.
  • A localização geográfica relativa do chamador e do back-end.
  • Infraestrutura de rede.
  • Carga atual.
  • O tamanho da carga útil da solicitação.
  • Comprimento da fila de processamento.
  • O tempo para o back-end processar a solicitação.

Qualquer um desses fatores pode adicionar latência à resposta. Alguns podem ser atenuados pela expansão do back-end. Outros, como a infraestrutura de rede, estão em grande parte fora do controle do desenvolvedor de aplicativos. A maioria das APIs pode responder com rapidez suficiente para que as respostas cheguem de volta pela mesma conexão. O código do aplicativo pode fazer uma chamada de API síncrona de forma sem bloqueio, dando a aparência de processamento assíncrono, o que é recomendado para operações vinculadas a E/S.

Em alguns cenários, no entanto, o trabalho feito pelo back-end pode ser de longa duração, na ordem de segundos, ou pode ser um processo em segundo plano que é executado em minutos ou até horas. Nesse caso, não é viável esperar que o trabalho seja concluído antes de responder ao pedido. Essa situação é um problema potencial para qualquer padrão de solicitação-resposta síncrono.

Algumas arquiteturas resolvem esse problema usando um agente de mensagens para separar os estágios de solicitação e resposta. Essa separação geralmente é obtida pelo uso do padrão de Nivelamento de Carga Baseado em Fila. Essa separação pode permitir que o processo do cliente e a API de back-end sejam dimensionados de forma independente. Mas essa separação também traz complexidade adicional quando o cliente requer notificação de sucesso, pois essa etapa precisa se tornar assíncrona.

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

Solução

Uma solução para esse problema é usar sondagem HTTP. A sondagem é útil para o código do lado do cliente, pois pode ser difícil fornecer pontos de extremidade de retorno de chamada ou usar conexões de longa duração. Mesmo quando retornos de chamada são possíveis, as bibliotecas e serviços extras que são necessários às vezes podem adicionar muita complexidade extra.

  • O aplicativo cliente faz uma chamada síncrona para a API, acionando uma operação de longa execução 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), reconhecendo que a solicitação foi recebida para processamento.

    Nota

    A API deve validar a solicitação e a ação a ser executada antes de iniciar o processo de longa execução. Se a solicitação for inválida, responda imediatamente com um código de erro, como HTTP 400 (Solicitação incorreta).

  • A resposta contém uma referência de local apontando para um ponto de extremidade que o cliente pode sondar para verificar o resultado da operação de longa duração.

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

  • Para cada chamada bem-sucedida para o ponto de extremidade de status, ele retorna HTTP 200. Enquanto o trabalho ainda está pendente, o ponto de extremidade de status retorna um recurso que indica que o trabalho ainda está em andamento. Quando o trabalho estiver concluído, o ponto de extremidade de status poderá retornar um recurso que indique a conclusão ou redirecionar para outra URL de recurso. Por exemplo, se a operação assíncrona criar um novo recurso, o ponto de extremidade de status será redirecionado para a URL desse recurso.

O diagrama a seguir mostra um fluxo típico:

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 (Aceito).
  2. O cliente envia uma solicitação HTTP GET para o ponto de extremidade de status. O trabalho ainda está pendente, então essa chamada retorna HTTP 200.
  3. Em algum momento, o trabalho é concluído e o ponto de extremidade de status retorna 302 (Encontrado) redirecionando para o recurso.
  4. O cliente busca o recurso na URL especificada.

Problemas e considerações

  • Há várias maneiras possíveis de implementar esse padrão por HTTP e nem todos os serviços upstream têm a mesma semântica. Por exemplo, a maioria dos serviços não retornará uma resposta HTTP 202 de volta de um método GET quando um processo remoto não tiver sido concluído. Seguindo a semântica REST pura, eles devem retornar HTTP 404 (Não encontrado). Essa resposta faz sentido quando você considera que o resultado da chamada ainda não está presente.

  • Uma resposta HTTP 202 deve indicar o local e a frequência com que o cliente deve pesquisar a resposta. Ele deve ter os seguintes cabeçalhos adicionais:

    Cabeçalho Description Notas
    Localização Um URL que o cliente deve pesquisar para obter um status de resposta. Esse URL pode ser um token SAS com o Padrão de Chave Valet sendo apropriado se esse local precisar de controle de acesso. O padrão de chave de manobrista também é válido quando a sondagem de resposta precisa ser descarregada para outro back-end
    Repetir-Depois Uma estimativa de quando o processamento será concluído Esse cabeçalho foi projetado para evitar que os clientes de sondagem sobrecarreguem o back-end com tentativas.
  • Pode ser necessário usar um proxy ou fachada de processamento para manipular os cabeçalhos de resposta ou a carga útil, dependendo dos serviços subjacentes usados.

  • Se o ponto de extremidade de status redireciona na conclusão, HTTP 302 ou HTTP 303 são códigos de retorno apropriados, dependendo da semântica exata suportada.

  • Após o processamento bem-sucedido, o recurso especificado pelo cabeçalho Location deve retornar um código de resposta HTTP apropriado, como 200 (OK), 201 (Criado) ou 204 (Sem Conteúdo).

  • Se ocorrer um erro durante o processamento, persista o erro na URL do recurso descrita no cabeçalho Location e, idealmente, retorne um código de resposta apropriado para o cliente a partir desse recurso (código 4xx).

  • Nem todas as soluções implementarão esse padrão da mesma maneira e alguns serviços incluirão cabeçalhos adicionais ou alternativos. Por exemplo, o Azure Resource Manager usa uma variante modificada desse padrão. Para obter mais informações, consulte Operações assíncronas do Azure Resource Manager.

  • Os clientes herdados podem não suportar esse 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, os Aplicativos Lógicos do Azure dão suporte a esse padrão nativamente e podem ser usados como uma camada de integração entre uma API assíncrona e um cliente que faz chamadas síncronas. Consulte Executar tarefas de longa duração com o padrão de ação webhook.

  • Em alguns cenários, talvez você queira fornecer uma maneira para os clientes cancelarem uma solicitação de longa execução. Nesse caso, o serviço de back-end deve suportar alguma forma de instrução de cancelamento.

Quando utilizar este padrão

Use este padrão para:

  • O código do lado do cliente, como aplicativos de navegador, onde é difícil fornecer pontos de extremidade de retorno de chamada ou o uso de conexões de longa execução adiciona muita complexidade adicional.

  • Chamadas de serviço em que apenas o protocolo HTTP está disponível e o serviço de retorno não pode disparar retornos de chamada devido a restrições de firewall no lado do cliente.

  • Chamadas de serviço que precisam ser integradas com arquiteturas herdadas que não suportam tecnologias modernas de retorno de chamada, como WebSockets ou webhooks.

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

  • Em vez disso, você pode usar um serviço criado para notificações assíncronas, como a Grade de Eventos do Azure.
  • As respostas devem ser transmitidas em tempo real para o cliente.
  • O cliente precisa coletar muitos resultados, e a latência recebida desses resultados é importante. Em vez disso, considere um padrão de barramento de serviço.
  • Você pode usar conexões de rede persistentes do lado do servidor, como WebSockets ou SignalR. Esses serviços podem ser usados para notificar o chamador do resultado.
  • O design de rede permite que você abra portas para receber retornos de chamada assíncronos ou webhooks.

Design da carga de trabalho

Um arquiteto deve avaliar como o padrão Solicitação-Resposta Assíncrona pode ser usado no design de sua carga de trabalho para abordar as metas e os princípios abordados nos pilares do Azure Well-Architected Framework. Por exemplo:

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. Dissociar as fases de solicitação e resposta das interações para processos que não precisam de respostas imediatas melhora a capacidade de resposta e a escalabilidade dos sistemas. Como um aplicativo assíncrono, você pode maximizar a simultaneidade no lado do servidor e agendar o trabalho para ser concluído conforme a capacidade permitir.

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

Como em qualquer decisão de design, considere quaisquer compensações em relação aos objetivos dos outros pilares que possam ser introduzidos com esse padrão.

Exemplo

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

  • O ponto de extremidade assíncrono da API.
  • O ponto de extremidade de status.
  • Uma função de back-end que pega itens de trabalho enfileirados e os executa.

Imagem da estrutura do padrão Async Request Reply em Functions

Logótipo do GitHub Este exemplo está disponível no GitHub.

Função AsyncProcessingWorkAcceptor

A AsyncProcessingWorkAcceptor função implementa um ponto de extremidade que aceita trabalho de um aplicativo cliente e o coloca em uma fila para processamento.

  • A função gera um ID de solicitação e o adiciona como metadados à mensagem de fila.
  • A resposta HTTP inclui um cabeçalho de local apontando para um ponto de extremidade de status. O ID da solicitação faz parte do caminho da 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}");
    }
}

Função AsyncProcessingBackgroundWorker

A AsyncProcessingBackgroundWorker função pega a operação da fila, faz algum trabalho com base na carga útil da mensagem e grava o resultado em uma conta de armazenamento.

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

Função AsyncOperationStatusChecker

A AsyncOperationStatusChecker função implementa o ponto de extremidade de status. Esta função verifica primeiro se o pedido foi concluído

  • Se a solicitação foi concluída, a função retorna uma chave de manobrista para a resposta ou redireciona a chamada imediatamente para a URL da chave de manobrista.
  • Se a solicitação ainda estiver pendente, devemos retornar um código 200, incluindo o estado atual.
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
}

Próximos passos

As seguintes informações podem ser relevantes ao implementar este padrão: