Patrón asincrónico de solicitud-respuesta

Azure
Azure Logic Apps

Desacople el procesamiento de back-end de un host de front-end en el que el procesamiento de back-end tiene que ser asincrónico, pero en el que, aún así, el front-end necesita una respuesta clara.

Contexto y problema

En el desarrollo de las modernas aplicaciones, es normal que las aplicaciones cliente (a menudo, código que se ejecuta en un cliente web [explorador]) dependan de las API remotas para proporcionar lógica de negocios y componer la funcionalidad. Estas API pueden estar directamente relacionadas con la aplicación o pueden ser servicios compartidos proporcionados por terceros. Normalmente, estas llamadas API tienen lugar a través del protocolo HTTP(S) y siguen la semántica de REST.

En la mayoría de los casos, las API para una aplicación cliente están diseñadas para responder rápidamente, en el orden de 100 ms o menos. Muchos factores pueden afectar a la latencia de respuesta, entre los que se incluyen:

  • Pila de hospedaje de una aplicación
  • Componentes de seguridad.
  • Ubicación geográfica relativa del autor de la llamada y del back-end
  • Infraestructura de red
  • Carga actual
  • Tamaño de la carga de la solicitud
  • Longitud de la cola de procesamiento
  • Tiempo para que el back-end procese la solicitud

Cualquiera de estos factores puede agregar latencia a la respuesta. Algunos se pueden mitigar mediante el escalado horizontal del back-end. Otros, como la infraestructura de red, se encuentran en gran medida del control del desarrollador de la aplicación. La mayoría de las API pueden responder con la rapidez suficiente para que las respuestas vuelvan a la misma conexión. El código de aplicación puede realizar una llamada de API sincrónica sin bloqueos, lo que permite el procesamiento asincrónico, lo que se recomienda para las operaciones enlazadas a E/S.

En algunos escenarios, sin embargo, el trabajo realizado por el back-end puede ser de larga duración, en el orden de segundos, o puede ser un proceso en segundo plano que se ejecute en minutos o incluso horas. En ese caso, no es factible esperar a que el trabajo se complete antes de responder a la solicitud. Esta situación es un problema potencial de cualquier patrón sincrónico de solicitud-respuesta.

Algunas arquitecturas solucionan este problema mediante el uso de un agente de mensajes para separar las fases de solicitud y respuesta. Esta separación se consigue a menudo mediante el uso del patrón de nivelación de carga basado en cola. Esta separación puede permitir que el proceso de cliente y la API de back-end escalen de forma independiente. Pero esta separación también aporta complejidad adicional cuando el cliente requiere una notificación correcta, ya que este paso debe ser asincrónico.

Muchas de las mismas consideraciones que se describen en las aplicaciones cliente también se aplican a las llamadas de la API de REST entre servidores en sistemas distribuidos, por ejemplo, en una arquitectura de microservicios.

Solución

Una solución a este problema es usar el sondeo HTTP. El sondeo es útil para el código de cliente, ya que puede ser difícil proporcionar puntos de conexión de devolución de llamada o usar conexiones de larga duración. Incluso cuando son posibles las devoluciones de llamada, las bibliotecas y servicios adicionales que se requieren a veces pueden agregar demasiada complejidad adicional.

  • La aplicación cliente realiza una llamada sincrónica a la API, lo que desencadena una operación de larga duración en el back-end.

  • La API responde sincrónicamente lo más rápido posible. Devuelve un código de estado HTTP 202 (aceptado), que confirma que la solicitud se ha recibido para su procesamiento.

    Nota

    La API debe validar la solicitud y la acción que se debe realizar antes de iniciar el proceso de larga duración. Si la solicitud no es válida, responda inmediatamente a un código de error como HTTP 400 (solicitud incorrecta).

  • La respuesta contiene una referencia de ubicación que apunta a un punto de conexión que el cliente puede sondear para comprobar el resultado de la operación de larga duración.

  • La API descarga el procesamiento en otro componente, como una cola de mensajes.

  • Para cada llamada correcta al punto de conexión de estado, devuelve HTTP 200. Mientras el trabajo sigue pendiente, el punto de conexión de estado devuelve un recurso que indica que el trabajo todavía está en curso. Una vez completado el trabajo, el punto de conexión de estado puede devolver un recurso que indique la finalización o redirigir a otra dirección URL del recurso. Por ejemplo, si la operación asincrónica crea un nuevo recurso, el punto de conexión de estado se redirigirá a la dirección URL de ese recurso.

En el diagrama siguiente se muestra un flujo típico:

Flujo de solicitud y respuesta para solicitudes HTTP asincrónicas

  1. El cliente envía una solicitud y recibe una respuesta HTTP 202 (aceptado).
  2. El cliente envía una solicitud HTTP GET al punto de conexión de estado. El trabajo sigue pendiente, por lo que esta llamada devuelve HTTP 200.
  3. En algún momento, el trabajo se ha completado y el punto de conexión de estado devuelve 302 (Encontrado) que redirige al recurso.
  4. El cliente captura el recurso en la dirección URL especificada.

Problemas y consideraciones

  • Hay varias formas de implementar este patrón a través de HTTP y no todos los servicios de nivel superior tienen la misma semántica. Por ejemplo, la mayoría de los servicios no devolverán una respuesta HTTP 202 desde un método GET cuando un proceso remoto no haya finalizado. A continuación de la semántica de REST pura, deben devolver HTTP 404 (No encontrado). Esta respuesta tiene sentido cuando se considera que el resultado de la llamada todavía no está presente.

  • Una respuesta HTTP 202 debe indicar la ubicación y la frecuencia con la que el cliente debe sondear la respuesta. Debe tener los siguientes encabezados adicionales:

    Encabezado Descripción Notas
    Location Una dirección URL que el cliente debe sondear para obtener el estado de respuesta. Esta dirección URL puede ser un token de SAS con el patrón de clave de acceso limitado que es adecuado si esta ubicación necesita control de acceso. El patrón de clave de acceso limitado también es válido cuando el sondeo de respuesta necesita descargarse en otro back-end.
    Retry-After Una estimación de cuándo se completará el procesamiento Este encabezado está diseñado para evitar que los clientes de sondeo sobrecarguen el back-end con reintentos.
  • Puede que tenga que usar un proxy de procesamiento o una fachada para manipular los encabezados de respuesta o la carga en función de los servicios subyacentes usados.

  • Si el punto de conexión de estado redirige al final, HTTP 302 o HTTP 303 son códigos de retorno adecuados, en función de la semántica exacta que admita.

  • Cuando el procesamiento se realiza correctamente, el recurso especificado por el encabezado Location debe devolver un código de respuesta HTTP adecuado, como 200 (Aceptar), 201 (Creado) o 204 (Sin contenido).

  • Si se produce un error durante el procesamiento, conserve el error en la dirección URL del recurso que se describe en el encabezado Location y, idealmente, devuelva un código de respuesta adecuado al cliente desde ese recurso (código 4xx).

  • No todas las soluciones implementarán este patrón de la misma manera y algunos servicios incluirán encabezados adicionales o alternativos. Por ejemplo, Azure Resource Manager utiliza una variante modificada de este patrón. Para más información, consulte Seguimiento de las operaciones asincrónicas de Azure.

  • Es posible que los clientes heredados no admitan este patrón. En ese caso, es posible que deba colocar una fachada sobre la API asincrónica para ocultar el procesamiento asincrónico del cliente original. Por ejemplo, Azure Logic Apps admite este patrón de forma nativa y se puede usar como una capa de integración entre una API asincrónica y un cliente que realiza llamadas sincrónicas. Consulte Realización de tareas prolongadas con el patrón de acción de webhook

  • En algunos escenarios, es posible que desee proporcionar a los clientes una forma de cancelar una solicitud de larga duración. En ese caso, el servicio back-end debe admitir alguna forma de instrucción de cancelación.

Cuándo usar este patrón

Use este patrón en los siguientes casos:

  • El código de cliente, como las aplicaciones de explorador, donde es difícil proporcionar puntos de conexión de devolución de llamada o el uso de conexiones de larga duración aporta demasiada complejidad adicional.

  • Llamadas de servicio en las que solo está disponible el protocolo HTTP y el servicio de devolución no puede activar las devoluciones de llamada debido a las restricciones del firewall en el lado cliente.

  • Llamadas de servicio que deben integrarse con arquitecturas heredadas que no admiten tecnologías de devolución de llamada modernas, como WebSockets o webhooks.

Este patrón podría no ser adecuado en los siguientes casos:

  • En su lugar, puede usar un servicio creado para las notificaciones asincrónicas, como Azure Event Grid.
  • Las respuestas se deben transmitir en tiempo real al cliente.
  • El cliente necesita recopilar muchos resultados y la latencia recibida de esos resultados es importante. Considere un patrón de Service Bus en su lugar.
  • Puede usar conexiones de red persistentes del lado servidor, como WebSockets o SignalR. Estos servicios se pueden usar para notificar al autor de la llamada del resultado.
  • El diseño de red permite abrir puertos para recibir devoluciones de llamada asincrónicas o webhooks.

Diseño de cargas de trabajo

El arquitecto debe evaluar cómo se puede usar el patrón de solicitud y respuesta asincrónica en el diseño de su carga de trabajo para abordar los objetivos y principios tratados en los pilares del Marco de la Well-Architected de Azure. Por ejemplo:

Fundamento Cómo apoya este patrón los objetivos de los pilares
La eficiencia del rendimiento ayuda a que la carga de trabajo satisfaga eficazmente las demandas mediante optimizaciones en el escalado, los datos y el código. Desacoplar las fases de solicitud y respuesta de las interacciones para procesos que no necesitan respuestas inmediatas mejora la capacidad de respuesta y la escalabilidad de los sistemas. Como enfoque asincrónico, puede maximizar la simultaneidad en el lado del servidor y programar el trabajo para que se complete cuando la capacidad lo permita.

- PE:05 Escapado y particiones
- PE:07 Código e infraestructura

Al igual que con cualquier decisión de diseño, hay que tener en cuenta las ventajas y desventajas con respecto a los objetivos de los otros pilares que podrían introducirse con este patrón.

Ejemplo

En el código siguiente se muestran fragmentos de una aplicación que usa Azure Functions para implementar este patrón. Hay tres funciones en la solución:

  • El punto de conexión de API asincrónica.
  • El punto de conexión de estado.
  • La función de back-end que toma los elementos de trabajo en cola y los ejecuta.

Imagen de la estructura del patrón de solicitud-respuesta asincrónico en las funciones

Logotipo de GitHub Este ejemplo está disponible en GitHub.

Función AsyncProcessingWorkAcceptor

La función AsyncProcessingWorkAcceptor implementa un punto de conexión que acepta el trabajo de una aplicación cliente y lo coloca en una cola para su procesamiento.

  • La función genera un identificador de solicitud y lo agrega como metadatos al mensaje en cola.
  • La respuesta HTTP incluye un encabezado de ubicación que apunta a un punto de conexión de estado. El identificador de la solicitud forma parte de la ruta de acceso de la dirección 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}");
    }
}

Función AsyncProcessingBackgroundWorker

La función AsyncProcessingBackgroundWorker toma la operación de la cola, realiza algún trabajo basado en la carga del mensaje y escribe el resultado en una cuenta de almacenamiento.

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

Función AsyncOperationStatusChecker

La función AsyncOperationStatusChecker implementa el punto de conexión de estado. Esta función comprueba primero si la solicitud se completó.

  • Si la solicitud se completó, la función devuelve una clave valet a la respuesta o redirige la llamada inmediatamente a la dirección URL de la clave de acceso limitado.
  • Si la solicitud sigue pendiente, deberíamos devolver un código 200, incluido el estado actual.
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
}

Pasos siguientes

La siguiente información puede resultarle de interés al implementar este patrón: