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

Azure
Aplicativos Lógicos do Azure

Desvincule o processamento de back-end de um host de front-end: quando o processamento de back-end precisa ser assíncrono, mas o front-end ainda precisa de uma resposta clara.

Contexto e problema

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

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

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

Qualquer um desses fatores pode aumentar a latência da resposta. É possível dimensionar o back-end para mitigar alguns deles. Outros, como a infraestrutura de rede, estão em grande parte fora do controle do desenvolvedor de aplicativos. A maioria das APIs responde rapidamente o suficiente para que as respostas retornem pela mesma conexão. O código do aplicativo pode fazer uma chamada de API síncrona de forma não bloqueada, fazendo parecer que o processamento é assíncrono. Recomendamos isso para operações associadas a E/S.

Em alguns cenários, no entanto, o trabalho feito pelo back-end tem execução prolongada, na ordem de segundos, ou é um processo em segundo plano executado em minutos ou até horas. Nesse caso, não é viável aguardar a conclusão do trabalho para responder à solicitação. Essa situação é um problema potencial para qualquer padrão síncrono de solicitação-resposta.

Algumas arquiteturas resolvem esse problema usando um agente de mensagens para separar os estágios de solicitação e resposta. Para isso, geralmente se usa o padrão de nivelamento de carga baseado em fila. A separação permite o dimensionamento independente do cliente e da API de back-end. Mas a separação também aumenta a complexidade quando o cliente requer notificação de êxito, pois essa etapa precisa se tornar assíncrona.

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

Solução

Uma solução para o problema é usar a 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 execução prolongada. Mesmo quando os retornos de chamada são possíveis, as bibliotecas extras e os serviços necessários às vezes aumentam demais a complexidade.

  • O aplicativo cliente faz uma chamada síncrona à API, disparando uma operação de execução prolongada no back-end.

  • A API responde de forma síncrona o mais rápido possível. Ela retorna o código de status HTTP 202 (Aceito), reconhecendo que a solicitação foi recebida para processamento.

    Observação

    A API precisa validar a solicitação e a ação a ser executada antes de iniciar o processo de execução prolongada. 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 execução prolongada.

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

  • Para cada chamada bem-sucedida ao ponto de extremidade de status, ela 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 termina, o ponto de extremidade de status pode retornar um recurso que indica a conclusão ou redirecionar a outra URL de recurso. Por exemplo, se a operação assíncrona cria um recurso, o ponto de extremidade de status redirecionaria à URL desse recurso.

O diagrama a seguir mostra um fluxo comum:

Fluxo de solicitação e resposta para solicitações HTTP assíncronas

  1. O cliente envia uma solicitação e recebe a resposta HTTP 202 (Aceita).
  2. O cliente envia uma solicitação HTTP GET ao ponto de extremidade de status. O trabalho ainda está pendente, portanto, a chamada retorna HTTP 200.
  3. Em algum momento, o trabalho é concluído, e o ponto de extremidade de status retorna 302 (Encontrado) e redireciona ao 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 em HTTP, e nem todos os serviços upstream têm a mesma semântica. Por exemplo, a maioria dos serviços não retorna a resposta HTTP 202 para um método GET quando o processo remoto não terminou. Seguindo a semântica REST pura, eles deveriam retornar HTTP 404 (Não Encontrado). Essa resposta faz sentido quando você considera que o resultado da chamada ainda não está presente.

  • A resposta HTTP 202 deve indicar o local e a frequência que o cliente deve sondar para obter a resposta. Ela deve ter os seguintes cabeçalhos adicionais:

    parâmetro Descrição Observações
    Localidade Uma URL que o cliente deve sondar para obter um status de resposta. A URL pode ser um token SAS, e o padrão de chave limitada é apropriado quando o local precisa de controle de acesso. O padrão de chave limitada também é válido quando é necessário transferir a sondagem da resposta a outro back-end
    Retry-After A estimativa de quando o processamento será concluído O cabeçalho serve para impedir que os clientes de sondagem sobrecarreguem o back-end com novas tentativas.
  • Talvez seja necessário usar um proxy de processamento ou fachada para lidar com os cabeçalhos de resposta ou o conteúdo, dependendo dos serviços subjacentes usados.

  • Se o ponto de extremidade de status for redirecionado na conclusão, HTTP 302 ou HTTP 303 serão códigos de retorno apropriados, dependendo da semântica exata compatível.

  • Quando o processamento é concluído, o recurso especificado pelo cabeçalho Local deve retornar um código de resposta HTTP apropriado, como 200 (OK), 201 (Criado) ou 204 (Sem Conteúdo).

  • Se ocorrer um erro no processamento, persista o erro na URL de recurso descrita no cabeçalho Local e, idealmente, retorne um código de resposta apropriado ao cliente naquele recurso (código 4xx).

  • Nem todas as soluções implementam o padrão da mesma maneira, e alguns serviços incluem cabeçalhos adicionais ou alternativos. Por exemplo, o Azure Resource Manager usa uma variante modificada do padrão. Para saber mais, confira Rastrear operações assíncronas no Azure.

  • Talvez clientes herdados não deem suporte a 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. Confira Executar tarefas de longa execução com o padrão de ação do webhook.

  • Em alguns cenários, os clientes precisam de alguma maneira de cancelar as solicitações de execução prolongada. Nesse caso, o serviço de back-end precisa dar suporte a alguma forma de instrução de cancelamento.

Quando usar esse padrão

Use o padrão para:

  • O código do lado do cliente, como aplicativos de navegador, em que é difícil fornecer pontos de extremidade de back de chamada ou o uso de conexões de execução prolongada aumenta demais a complexidade.

  • 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 a arquiteturas herdadas que não dão suporte a tecnologias modernas de retorno de chamada, como WebSockets ou webhooks.

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

  • É possível usar um serviço criado para notificações assíncronas, como a Grade de Eventos do Azure.
  • É preciso transmitir as respostas ao cliente em tempo real.
  • O cliente precisa coletar muitos resultados, e a latência do recebimento deles é importante. Em vez disso, considere um padrão de barramento de serviço.
  • É possível 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 abrir portas para receber retornos de chamada assíncronos ou webhooks.

Design de 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 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 em dimensionamento, dados e código. O desacoplamento das 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 uma abordagem assíncrona, você pode maximizar a simultaneidade 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

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

Exemplo

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

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

Imagem da estrutura do padrão de resposta assíncrona a solicitações no Functions

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

Função AsyncProcessingWorkAcceptor

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

  • A função gera uma ID de solicitação e a adiciona como metadados à mensagem da fila.
  • A resposta HTTP inclui um cabeçalho de local que aponta para um ponto de extremidade de status. A 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 função AsyncProcessingBackgroundWorker capta a operação da fila, faz algum trabalho com base no conteúdo 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 função AsyncOperationStatusChecker implementa o ponto de extremidade de status. A função primeiro verifica se a solicitação foi concluída

  • Se a solicitação foi concluída, a função retorna uma chave limitada à resposta ou redireciona a chamada imediatamente à URL de chave limitada.
  • 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óximas etapas

As informações a seguir também podem ser relevantes ao implementar esse padrão: