[译] 异步请求-应答模式
原文:https://learn.microsoft.com/en-us/azure/architecture/patterns/async-request-reply
将后端处理逻辑与前端主机解耦,在后端需要异步处理但前端仍需明确响应的场景下使用。
背景与问题
在现代应用开发中,客户端应用(通常是运行在浏览器中的代码)依赖远程 API 来提供业务逻辑和组合功能已是常态。这些 API 可能直接关联到应用本身,也可能是第三方提供的共享服务。通常这些 API 调用通过 HTTP(S) 协议进行,并遵循 REST 语义。
多数情况下,为客户端应用设计的 API 都能快速响应,响应时间在 100 毫秒或更短。但许多因素会影响响应延迟,包括:
- 应用的托管技术栈
- 安全组件
- 调用方与后端的相对地理位置
- 网络基础设施
- 当前负载
- 请求负载的大小
- 处理队列长度
- 后端处理请求所需的时间
任何一个因素都可能增加响应延迟。其中一些可以通过扩展后端来缓解,而另一些(比如网络基础设施)则很大程度上超出了应用开发者的控制范围。大多数 API 能够足够快速地响应,使响应可以通过同一连接返回。应用代码可以以非阻塞方式进行同步 API 调用,呈现出异步处理的效果,这也是 I/O 密集型操作的推荐做法。
然而在某些场景下,后端执行的工作可能是长时间运行的,需要几秒钟,或者可能是一个后台进程,需要执行数分钟甚至数小时。在这种情况下,等待工作完成再响应请求是不可行的。这种情况对任何同步请求-应答模式都是一个潜在问题。
有些架构通过使用消息代理来分离请求和响应阶段来解决这个问题。这种分离通常通过使用基于队列的负载均衡模式来实现。这种分离可以让客户端进程和后端 API 独立扩展。但当客户端需要成功通知时,这种分离也会带来额外的复杂性,因为这一步骤需要变成异步的。
对于客户端应用讨论的许多相同考虑也适用于分布式系统中的服务器到服务器 REST API 调用——例如在微服务架构中。
解决方案
解决这个问题的一种方案是使用 HTTP 轮询。轮询对客户端代码很有用,因为很难提供回调端点或使用长连接。即使回调是可能的,所需的额外库和服务有时也会增加太多额外的复杂性。
- 客户端应用对 API 进行同步调用,触发后端的长时间运行操作。
- API 尽可能快地同步响应。它返回 HTTP 202 (Accepted) 状态码,确认请求已被接收并将进行处理。
注意
API 应在启动长时间运行的进程之前验证请求和要执行的操作。如果请求无效,应立即回复错误代码,例如 HTTP 400 (Bad Request)。
- 响应包含一个位置引用,指向客户端可以轮询以检查长时间运行操作结果的端点。
- API 将处理工作卸载到另一个组件,例如消息队列。
- 对状态端点的每次成功调用都会返回 HTTP 200。当工作仍在进行时,状态端点返回一个资源,表示工作仍在进行中。工作完成后,状态端点可以返回一个表示完成的资源,或重定向到另一个资源 URL。例如,如果异步操作创建了一个新资源,状态端点将重定向到该资源的 URL。
下图展示了一个典型的流程:

- 客户端发送请求并收到 HTTP 202 (Accepted) 响应。
- 客户端向状态端点发送 HTTP GET 请求。工作仍在进行中,因此此调用返回 HTTP 200。
- 在某个时间点,工作完成,状态端点返回 302 (Found) 重定向到资源。
- 客户端在指定的 URL 获取资源。
问题与注意事项
通过 HTTP 实现此模式有多种可能的方式,并非所有上游服务都具有相同的语义。例如,当远程进程尚未完成时,大多数服务不会从 GET 方法返回 HTTP 202 响应。遵循纯 REST 语义,它们应该返回 HTTP 404 (Not Found)。当你考虑到调用的结果尚不存在时,这个响应是有道理的。
HTTP 202 响应应指示客户端应轮询响应的位置和频率。它应具有以下附加标头:
| 标头 | 描述 | 注意事项 |
|---|---|---|
| Location | 客户端应轮询响应状态的 URL | 此 URL 可以是 SAS 令牌,Valet Key 模式在此位置需要访问控制时是合适的。当响应轮询需要卸载到另一个后端时,valet key 模式也是有效的。 |
| Retry-After | 对处理何时完成的估计 | 此标头旨在防止轮询客户端因重试而压垮后端。设计此响应时必须考虑预期的客户端行为。虽然你控制的客户端可以编码为明确遵循这些响应值,但不是你编写的客户端或使用无代码/低代码方法(如 Azure Logic Apps)的客户端可以自由地拥有自己的 HTTP 202 逻辑处理。 |
根据所使用的底层服务,你可能需要使用处理代理或外观来操作响应标头或负载。
如果状态端点在完成时重定向,则 HTTP 302 或 HTTP 303 都是适当的返回代码,具体取决于你支持的确切语义。
成功处理后,Location 标头指定的资源应返回适当的 HTTP 响应代码,例如 200 (OK)、201 (Created) 或 204 (No Content)。
如果在处理过程中发生错误,将错误持久化到 Location 标头中描述的资源 URL,并理想地从该资源向客户端返回适当的响应代码(4xx 代码)。
并非所有解决方案都会以相同的方式实现此模式,某些服务将包含额外或替代的标头。例如,Azure Resource Manager 使用此模式的修改变体。有关更多信息,请参阅 Azure Resource Manager 异步操作。
旧版客户端可能不支持此模式。在这种情况下,你可能需要在异步 API 上放置一个外观,以对原始客户端隐藏异步处理。例如,Azure Logic Apps 原生支持此模式,可用作异步 API 与进行同步调用的客户端之间的集成层。参见使用 webhook 操作模式执行长时间运行的任务。
在某些场景下,你可能希望为客户端提供一种取消长时间运行请求的方法。在这种情况下,后端服务必须支持某种形式的取消指令。
何时使用此模式
在以下情况使用此模式:
- 客户端代码(如浏览器应用程序)难以提供回调端点,或使用长连接会增加太多额外复杂性。
- 服务调用只有 HTTP 协议可用,并且由于客户端的防火墙限制,返回服务无法触发回调。
- 服务调用需要与不支持现代回调技术(如 WebSockets 或 webhooks)的旧架构集成。
此模式可能不适用于以下情况:
- 你可以改用为异步通知而构建的服务,例如 Azure Event Grid。
- 响应必须实时流式传输到客户端。
- 客户端需要收集许多结果,并且这些结果的接收延迟很重要。可以考虑使用服务总线模式。
- 你可以使用服务器端持久网络连接,例如 WebSockets 或 SignalR。这些服务可用于将结果通知调用方。
- 网络设计允许你打开端口以接收异步回调或 webhooks。
工作负载设计
架构师应评估如何在其工作负载设计中使用异步请求-应答模式,以实现 Azure 架构完善框架支柱中涵盖的目标和原则。例如:
| 支柱 | 此模式如何支持支柱目标 |
|---|---|
| 性能效率——在满足系统要求的前提下高效扩展 | 通过将长时间运行的操作与 API 请求解耦,工作负载可以独立于客户端处理更多请求并执行工作。 - PE:05 扩展和分区 - PE:07 代码和基础设施 |
与任何设计决策一样,请考虑此模式可能对其他支柱目标产生的任何权衡。
示例
以下代码展示了使用 Azure Functions 实现此模式的应用程序的摘录。解决方案中有三个函数:
- 异步 API 端点
- 状态端点
- 获取队列工作项并执行它们的后端函数

此示例可在 GitHub 上获取。
AsyncProcessingWorkAcceptor 函数
AsyncProcessingWorkAcceptor 函数实现了一个端点,该端点接受来自客户端应用程序的工作并将其放入队列进行处理。
- 该函数生成一个请求 ID 并将其作为元数据添加到队列消息中。
- HTTP 响应包含一个指向状态端点的位置标头。请求 ID 是 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 = $"https://{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}");
}
}
AsyncProcessingBackgroundWorker 函数
AsyncProcessingBackgroundWorker 函数从队列中获取操作,根据消息负载执行某些工作,并将结果写入存储账户。
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)
{
// 对 blob 数据源执行实际操作,以便异步读取器能够进行检查。
// 这是实际服务工作线程处理的位置
var id = applicationProperties["RequestGUID"] as string;
BlobClient blob = inputContainer.GetBlobClient($"{id}.blobdata");
// 现在将结果写入 blob 存储。
await blob.UploadAsync(customer);
}
}
AsyncOperationStatusChecker 函数
AsyncOperationStatusChecker 函数实现了状态端点。此函数首先检查请求是否已完成:
- 如果请求已完成,该函数要么返回响应的 valet-key,要么直接将调用重定向到 valet-key URL。
- 如果请求仍在进行中,则应返回 200 代码,包括当前状态。
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}");
// 检查 blob 是否存在
if (await inputBlob.ExistsAsync())
{
// 如果存在,根据可选的 "OnComplete" 参数的值选择要执行的操作。
return await OnCompleted(OnComplete, inputBlob, thisGUID);
}
else
{
// 如果不存在,则需要退避。根据可选的 "OnPending" 参数的值,选择要执行的操作。
string rqs = $"https://{Environment.GetEnvironmentVariable("WEBSITE_HOSTNAME")}/api/RequestStatus/{thisGUID}";
switch (OnPending)
{
case OnPendingEnum.OK:
{
// 返回 HTTP 200 状态码。
return new OkObjectResult(new { status = "In progress", Location = rqs });
}
case OnPendingEnum.Synchronous:
{
// 退避并重试。如果退避时间达到一分钟则超时。
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:
{
// 重定向到 blob 存储的 SAS URI
return new RedirectResult(inputBlob.GenerateSASURI());
}
case OnCompleteEnum.Stream:
{
// 下载文件并直接返回给调用者。
// 对于较大的文件,使用流以最小化 RAM 使用。
return new OkObjectResult(await inputBlob.DownloadContentAsync());
}
default:
{
throw new InvalidOperationException($"Unexpected value: {OnComplete}");
}
}
}
}
public enum OnCompleteEnum
{
Redirect,
Stream
}
public enum OnPendingEnum
{
OK,
Synchronous
}
后续步骤
以下信息在实现此模式时可能相关:
- Azure Logic Apps - 使用轮询操作模式执行长时间运行的任务
- 有关设计 Web API 的一般最佳实践,请参阅 Web API 设计

浙公网安备 33010602011771号