Compartir a través de


Patrón asincrónico de solicitud-respuesta

Desacoplar el procesamiento de back-end del host front-end cuando el procesamiento de back-end debe ejecutarse asincrónicamente, pero el front-end necesita una respuesta clara.

Contexto y problema

En el desarrollo de aplicaciones modernas, las aplicaciones cliente suelen depender de las API remotas para proporcionar lógica de negocios y funcionalidad de redacción. Muchas aplicaciones ejecutan código en un explorador web y otros entornos también hospedan código de cliente. Las API pueden relacionarse directamente con la aplicación o funcionar como servicios compartidos desde un servicio externo. La mayoría de las llamadas API usan HTTP o HTTPS y siguen la semántica de REST.

En la mayoría de los casos, las API de una aplicación cliente responden en unos 100 milisegundos (ms) o menos. Muchos factores pueden afectar a la latencia de respuesta:

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

Estos factores pueden agregar latencia a la respuesta. Puede mitigar algunos factores ampliando el back-end horizontalmente. Otros factores, como la infraestructura de red, están fuera del control del desarrollador de la aplicación. La mayoría de las API responden rápidamente para que la respuesta vuelva a través de la misma conexión. El código de aplicación puede realizar una llamada API sincrónica de forma no desbloqueada para dar la apariencia del procesamiento asincrónico. Se recomienda este enfoque para las operaciones enlazadas a entrada y salida (E/S).

En algunos escenarios, el back-end realiza tareas que son de larga duración y tarda unos segundos. En otros escenarios, el back-end realiza un trabajo en segundo plano de larga duración durante minutos o durante períodos prolongados. En estos casos, no puede esperar a que finalice el trabajo antes de enviar una respuesta. Esta situación puede crear un problema para patrones de solicitud-respuesta sincrónicos. Para obtener instrucciones sobre cómo diseñar el procesamiento de back-end, consulte Trabajos en segundo plano.

Algunas arquitecturas solucionan este problema mediante el uso de un agente de mensajes para separar las fases de solicitud y respuesta. Muchos sistemas logran esta separación a través del patrón de nivelación de carga basado en colas. Esta separación permite que el proceso de cliente y la API de back-end se escalen de forma independiente. También presenta una complejidad adicional cuando el cliente requiere una notificación exitosa, porque ese paso también debe convertirse en asincrónico.

Muchas de las mismas consideraciones que se aplican a las aplicaciones cliente también se aplican a las llamadas API REST de servidor a servidor en sistemas distribuidos, como en una arquitectura de microservicios.

Solución

Una solución a este problema es usar el sondeo HTTP. El sondeo funciona bien para el código del lado del cliente cuando los endpoints de callback no están disponibles o cuando las conexiones de larga duración agregan demasiada complejidad. Incluso cuando los callbacks son posibles, las bibliotecas y servicios adicionales que requieren pueden aumentar la complejidad.

En los pasos siguientes se describe la solución:

  • La aplicación cliente realiza una llamada sincrónica a la API para desencadenar 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) para confirmar que recibió la solicitud de procesamiento.

    Nota:

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

  • La respuesta incluye 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 exitosa al endpoint de estado, el endpoint devuelve HTTP 200 (OK). Mientras el trabajo está en curso, el punto final de estado proporciona un recurso que indica ese estado. El cuerpo de la respuesta de estado debe incluir suficiente información para que el cliente comprenda el estado actual de la operación.

    Cuando se completa el trabajo, el punto de conexión de estado devuelve un recurso que indica la finalización o redirige a otra dirección URL de recurso. Por ejemplo, si la operación asincrónica crea un nuevo recurso, el punto de conexión de estado redirige a la dirección URL de ese recurso.

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

Diagrama que muestra el flujo de solicitud y respuesta para las solicitudes HTTP asincrónicas.

  1. El cliente envía una solicitud y recibe una respuesta HTTP 202 (aceptado).

  2. El cliente envía la solicitud HTTP GET al endpoint de estado. Esta llamada devuelve HTTP 200 porque el trabajo está pendiente.

  3. En algún momento, el trabajo se completa y el punto de conexión de estado devuelve HTTP 303 (consulte Otros) para redirigir al recurso.

  4. El cliente captura el recurso en la dirección URL especificada.

Problemas y consideraciones

Tenga en cuenta los siguientes puntos a medida que decida cómo implementar este patrón:

  • Existen varias maneras de implementar este patrón a través de HTTP y los servicios ascendentes no siempre usan la misma semántica. Por ejemplo, algunas implementaciones no usan un punto de conexión de estado independiente. En su lugar, el cliente sondea directamente la dirección URL del recurso de destino y recibe HTTP 404 (no encontrado) hasta que se crea el recurso. Esta respuesta se genera porque el recurso aún no existe. Sin embargo, este enfoque puede no estar claro porque los identificadores de solicitud no válidos también devuelven HTTP 404. Un punto de conexión de estado dedicado que devuelve HTTP 200 con un cuerpo de estado, como se describe en este patrón, evita esta confusión.

  • Una respuesta HTTP 202 indica dónde sondea el cliente y con qué frecuencia. Debe incluir los siguientes encabezados.

    Header Descripción Notas
    Location Dirección URL que el cliente sondea para obtener un estado de respuesta Esta dirección URL puede ser un token de firma de acceso compartido (SAS). El patrón Valet Key funciona bien cuando esta ubicación necesita control de acceso. El patrón también se aplica cuando es necesario mover el sondeo de respuesta a otro back-end.
    Retry-After Tiempo de finalización estimado para el procesamiento Este encabezado ayuda a que los clientes que realizan sondeos eviten enviar demasiadas solicitudes al back-end.

    Considere el comportamiento esperado del cliente al diseñar esta respuesta. Un cliente bajo su control puede seguir estos valores de respuesta exactamente. Los clientes que otros crean, incluidos los clientes creados mediante herramientas sin código o de poco código, como Azure Logic Apps, pueden aplicar su propio control para HTTP 202.

  • Considere incluir los siguientes campos en la respuesta del endpoint de estado.

    Campo Descripción Notas
    status Estado actual de la operación, como Pendiente, En ejecución, Correcto, Erróneo o Cancelado Usa un conjunto coherente y documentado de valores terminales y no determinales
    createdAt Hora en que se aceptó la operación Ayuda a los clientes a detectar operaciones obsoletas o abandonadas
    lastUpdatedAt Hora en que se actualizó por última vez el estado Ayuda a los clientes a distinguir entre las operaciones estancadas y las operaciones en curso.
    percentComplete Indicador de progreso opcional Útil cuando el back-end puede calcular el progreso
    error Objeto de error estructurado cuando el estado es Failed Para la coherencia, considere la posibilidad de usar el formato RFC 9457 .
  • Es posible que tenga que usar un proxy de procesamiento para ajustar los encabezados de respuesta o la carga, en función de los servicios subyacentes que use.

  • Si el punto de conexión de estado se redirige después de la finalización, use HTTP 303 (vea otro). Un 303 indica al cliente que emita una solicitud GET a la dirección URL de redireccionamiento, independientemente del método de solicitud original. Este comportamiento es la semántica correcta de este patrón porque el cliente recupera un recurso de resultado distinto, no vuelve a enviar la operación original. HTTP 302 (encontrado) no garantiza un cambio de método. Algunos clientes reproducen el método original en el redireccionamiento. Este comportamiento puede causar efectos secundarios no deseados, como solicitudes POST duplicadas.

  • Después de que el servidor procese correctamente la solicitud, el recurso que especifica el Location encabezado devuelve un código de estado HTTP como 200, 201 (creado) o 204 (sin contenido).

  • Si se produce un error durante el procesamiento, registre el error en la URL del recurso que el encabezado Location especifica y devuelve un código de estado 4xx desde el recurso que corresponda con el fallo. Use un formato de error estructurado, como RFC 9457 (Detalles del problema para las API HTTP) para que los clientes puedan analizar y controlar errores mediante programación.

  • El recurso de estado y los resultados almacenados consumen almacenamiento y proceso. Defina una política de retención para limpiarlas después de un periodo razonable. Para informar a los clientes de la ventana de retención, puede agregar un Expires encabezado a la respuesta de estado.

  • Las soluciones no implementan este patrón de la misma manera y algunos servicios incluyen encabezados adicionales o alternativos. Por ejemplo, Azure Resource Manager usa una variante modificada de este patrón. Para obtener más información, consulte Operaciones asincrónicas del Administrador de Recursos.

  • Es posible que los clientes heredados no soporten 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, Logic Apps admite este patrón de forma nativa y puede usarlo como una capa de integración entre una API asincrónica y un cliente que realiza llamadas sincrónicas. Para más información, consulte Comportamiento asincrónico de solicitud-respuesta en Logic Apps.

  • Para proporcionar una manera de que los clientes cancelen una solicitud de larga duración, exponga una operación DELETE en el recurso de punto de conexión de estado. Esta solicitud debe reenviar una instrucción de cancelación al componente de procesamiento de back-end. Una vez que el back-end controla la cancelación, debe actualizar el recurso de estado para reflejar el estado cancelado. Este proceso ayuda a evitar que el trabajo incompleto consuma recursos indefinidamente. Determine si la operación admite la reversión parcial o requiere una transacción de compensación.

  • Puede exigir que los clientes proporcionen una clave de idempotencia, por ejemplo, en un Idempotency-Key encabezado de solicitud, cuando envíen la solicitud inicial. Si el backend recibe una clave duplicada, debería devolver el recurso de estado existente en lugar de encolar una segunda tarea. Este enfoque protege contra errores de red que hacen que el cliente vuelva a intentar un POST que el servidor ya ha aceptado. Es especialmente importante en este patrón porque el cliente no puede distinguir entre una respuesta perdida y una solicitud que nunca se recibió.

Nota:

Este patrón describe el sondeo HTTP, en el que el cliente emite periódicamente nuevas solicitudes para comprobar el estado. En sondeo largo, el cliente envía una solicitud y el servidor mantiene abierta la conexión hasta que se produzcan nuevos datos o se produzca un tiempo de espera. El sondeo largo reduce la latencia de respuesta en comparación con el sondeo periódico, pero presenta complejidad en torno a la administración de conexiones y los tiempos de espera.

Cuándo usar este patrón

Use este patrón en los siguientes supuestos:

  • Trabajas con código del lado del cliente, como aplicaciones de navegador, y esas restricciones dificultan proporcionar puntos de conexión para devoluciones de llamada o hacen que las conexiones prolongadas añadan demasiada complejidad.

  • Llama a un servicio que usa solo el protocolo HTTP y el servicio de retorno no puede enviar callbacks debido a restricciones de firewall en el lado del cliente.

  • Se integra con cargas de trabajo que no admiten mecanismos de devolución de llamada modernos, como WebSockets o webhooks.

Este patrón podría no ser adecuado cuando:

  • En su lugar, puede usar un servicio creado para notificaciones asincrónicas, como Azure Event Grid.

  • Las respuestas se deben transmitir en tiempo real al cliente. Considere la posibilidad de usar eventos de Server-Sent (SSE), que proporcionan un canal de inserción unidireccional ligero y nativo HTTP desde el servidor al cliente sin necesidad de que el cliente sondee.

  • El cliente debe recopilar muchos resultados y la latencia de esos resultados es importante. Considere la posibilidad de usar un agente de mensajes en su lugar.

  • Hay conexiones de red persistentes del lado servidor como WebSockets o SignalR disponibles. Puede usar estas conexiones para notificar al autor de la llamada del resultado.

  • El diseño de red admite puertos abiertos para recibir llamadas de retorno asincrónicas o webhooks.

Diseño de cargas de trabajo

Un arquitecto debe evaluar cómo pueda usar el patrón de solicitud-respuesta asincrónico en el diseño de la carga de trabajo para abordar los objetivos y principios descritos en los pilares de Azure Well-Architected Framework.

Fundamento Cómo apoya este patrón los objetivos de los pilares
Eficiencia del rendimiento ayuda a su carga de trabajo a satisfacer eficientemente las demandas mediante optimizaciones en el escalado, los datos y el código. Para mejorar la capacidad de respuesta y la escalabilidad, desacopla las fases de solicitud y respuesta de los procesos que no requieren una respuesta inmediata. Un enfoque asincrónico aumenta la simultaneidad y permite que la programación del servidor funcione a medida que la capacidad esté disponible.

- PE:05 Escalado y particionamiento
- PE:07 Código e infraestructura

Al igual que con cualquier decisión de diseño, considere el equilibrio con los objetivos de los otros pilares que este patrón podría introducir.

Ejemplo

El código siguiente muestra extractos de una aplicación que usa Azure Functions para implementar este patrón. Esta solución tiene tres funciones:

  • El endpoint asincrónico de la API
  • El endpoint de estado
  • Una función de back-end que toma elementos de trabajo en cola y los ejecuta.

Diagrama de la estructura del patrón de respuesta de solicitud asincrónica en Functions.

GitHub logo. Este ejemplo está disponible en GitHub.

La implementación usa la identidad administrada para autenticarse con Azure Service Bus y Azure Blob Storage, lo que evita almacenar cadenas de conexión o claves de cuenta. Las dependencias se registran en Program.cs mediante DefaultAzureCredential y se insertan mediante constructores primarios.

Función AsyncProcessingWorkAcceptor

La AsyncProcessingWorkAcceptor función implementa un punto de conexión que acepta el trabajo de una aplicación cliente y lo pone en 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 una Location cabecera que apunta a un punto final de estado y una Retry-After cabecera que sugiere un intervalo de sondeo. El identificador de solicitud aparece en la ruta de acceso de la dirección 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);
    }
}

Función AsyncProcessingBackgroundWorker

La AsyncProcessingBackgroundWorker función lee la operación de la cola, la procesa en función de la carga del mensaje y escribe el resultado en una cuenta de almacenamiento.

public class AsyncProcessingBackgroundWorker(BlobContainerClient _blobContainerClient)
{
    [Function("AsyncProcessingBackgroundWorker")]
    public async Task Run(
        [ServiceBusTrigger("outqueue", Connection = "ServiceBusConnection")] ServiceBusReceivedMessage message)
    {
        // Perform an action against the blob data source for the async readers to check against.
        // This is where your 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);
        }
    }
}

Función AsyncOperationStatusChecker

La función AsyncOperationStatusChecker implementa el endpoint de estado. Esta función comprueba el estado de la solicitud:

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 by 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
}

Pasos siguientes