當後端處理需要非同步執行,但前端需要明確回應時,應將後端處理與前端主機分離。
內容和問題
在現代應用程式開發中,用戶端應用程式常依賴遠端 API 來提供商業邏輯與組合功能。 許多應用程式在網頁瀏覽器中執行程式碼,其他環境也會承載用戶端程式碼。 這些 API 可能直接與應用程式相關,或作為外部服務的共享服務運作。 大多數 API 呼叫使用 HTTP 或 HTTPS,並遵循 REST 語意。
在大多數情況下,客戶端應用程式的 API 回應時間約為 100 毫秒(ms)或更短。 許多因素會影響響應延遲:
- 應用程式的主機堆疊
- 安全元件
- 呼叫者與後端的相對地理位置
- 網路基礎結構
- 電流負載
- 請求有效載荷的大小
- 處理佇列長度
- 後端處理請求的時間
這些因素會增加回應的延遲。 你可以透過擴大後端規模來減輕一些因素。 其他因素,如網路基礎架構,則超出應用程式開發者的控制範圍。 大多數 API 回應速度足夠快,回應能在同一連線上回傳。 應用程式程式碼可以以非阻塞的方式呼叫同步 API,呈現非同步處理的外觀。 我們建議這種方法用於輸入與輸出(I/O)綁定操作。
在某些情況下,後端會執行耗時幾秒鐘的工作。 在其他情況下,後端會進行長時間的背景工作,持續數分鐘或長時間。 在這種情況下,你不能等工作完成後才回覆。 這種情況可能會對同步請求-回覆模式造成問題。 關於設計後端處理的指引,請參閱 背景工作。
某些架構會使用訊息代理程式來分隔要求和響應階段,以解決此問題。 許多系統透過 Queue-Based 負載平衡模式達成此分離。 這種分離讓用戶端流程與後端 API 獨立擴展。 當客戶端需要成功通知時,這也會帶來額外的複雜性,因為該步驟也必須非同步。
許多適用於用戶端應用程式的考量,也同樣適用於分散式系統中的伺服器間 REST API 呼叫,例如微服務架構中。
解決方案
此問題的其中一個解決方案是使用 HTTP 輪詢。 當回調端點無法使用或長時間連線增加複雜度時,輪詢對客戶端程式碼表現良好。 即使在可以進行回撥的情況下,所需的那些額外函式庫和服務也會增加複雜度。
以下步驟說明解決方案:
用戶端應用程式會同步呼叫 API,以觸發後端的長期執行操作。
API 會儘快同步回應。 它會回傳一個 HTTP 202(已接受)狀態碼,以確認已收到處理請求。
備註
API 應該在啟動長時間運行的過程之前驗證請求和要執行的動作。 如果請求無效,請立即回覆錯誤代碼,例如 HTTP 400(錯誤請求)。
回應包含一個位置參考,指向用戶端可輪詢以檢查長期執行操作結果的端點。
API 將處理卸載給另一個元件,例如訊息佇列。
每成功呼叫一次狀態端點,該端點會回傳 HTTP 200(OK)。 在工作進行中,狀態端點會回傳一個資源,指示該狀態。 狀態回應主體應包含足夠資訊,讓用戶端能理解操作的當前狀態。
當工作完成時,狀態端點會回傳一個資源,表示已完成或重新導向到另一個資源 URL。 例如,若非同步操作建立新資源,狀態端點會重新導向該資源的 URL。
下圖展示了典型的流向。
用戶端會傳送要求並接收 HTTP 202(已接受)回應。
用戶端會將 HTTP GET 要求傳送至狀態端點。 此呼叫回傳 HTTP 200,因為該工作仍在待處理中。
某個時候,工作完成,狀態端點會回傳 HTTP 303(參見其他)以重新導向該資源。
用戶端會在指定的 URL 擷取資源。
問題和考慮
在決定如何實施此模式時,請考慮以下幾點:
透過 HTTP 實作這種模式有多種方式,且上游服務不一定使用相同的語意。 例如,有些實作不會使用獨立的狀態端點。 取而代之的是,客戶端會直接輪詢目標資源 URL 並接收 HTTP 404(未找到),直到資源建立為止。 這個回應是因為該資源尚未出現。 然而,這種方法可能不夠明確,因為無效請求 ID 也會回傳 HTTP 404。 如本模式所述,一個專用狀態端點能回傳帶有狀態主體的 HTTP 200,可避免這種混淆。
HTTP 202 回應會顯示用戶端在哪裡輪詢以及輪詢的頻率。 它應該包含以下標頭。
Header 說明 Notes Location客戶端會輪詢以判斷回應狀態的 URL 此 URL 可以是共享存取簽章(SAS)令牌。 代客鑰匙設計模式在此地點需要存取控制時非常有效。 當回應輪詢需要轉移到另一個後端時,這種模式同樣適用。 Retry-After預估處理完成時間 此標頭有助於輪詢客戶端避免向後端發送過多請求。 設計此回應時,請考慮預期客戶的行為。 你控制的客戶端可以完全遵循這些回應值。 由他人撰寫的客戶端,包括使用無程式碼或低程式碼工具如 Azure Logic Apps 建立的客戶端,都能自行處理 HTTP 202。
請考慮在狀態端點回應中加入以下欄位。
領域 說明 Notes status操作的當前狀態,例如 待處理、 執行中、 成功、 失敗或 取消 使用一致且有文件記錄的終端與非終端值集合 createdAt手術被接受的時間 協助客戶偵測陳舊或放棄的作業 lastUpdatedAt狀態最後更新的時間 協助客戶區分停滯與進行中的作業 percentComplete可選的進度指示器 當後端能估算進度時,這點很有用 error當狀態為失敗時,是一個結構化錯誤物件 為了保持一致性,建議使用 RFC 9457 格式。 你可能需要使用處理代理來調整回應標頭或有效載荷,這取決於你使用的底層服務。
如果狀態端點完成後會重新導向,請使用 HTTP 303(參見其他)。 303 指示用戶端向重定向 URL 發出 GET 請求,不論原始請求方式為何。 此行為符合此模式的正確語意,因為用戶端是在取得獨立的結果資源,而非重新提交原始操作。 HTTP 302(已找到) 並不保證方法會被更改。 有些用戶端在重定向時會重播原始方法。 此行為可能導致意外副作用,例如重複 POST 請求。
伺服器成功處理請求後,標頭指定的資源
Location會回傳 HTTP 狀態碼,如 200、201(已建立)或 204(無內容)。若處理過程中發生錯誤,請將錯誤存於標頭指定的資源 URL
Location上,並從資源回傳與失敗相符的 4xx 狀態碼。 使用結構化錯誤格式,例如 RFC 9457(HTTP API 問題細節),讓用戶端能程式化地解析並處理失敗。狀態資源及任何儲存的結果會消耗儲存空間與運算。 制定保留政策,在合理期間後清理資料。 為了通知客戶保留期間,你可以在狀態回應中加入
Expires標頭。不同解決方案的實作方式不盡相同,有些服務還會包含額外或替代標頭。 例如,Azure Resource Manager 使用了此模式的修改版本。 欲了解更多資訊,請參見 Resource Manager 非同步操作。
舊版用戶端可能不支援此模式。 在此情況下,您可能需要在異步 API 上放置外觀,以隱藏原始用戶端的異步處理。 例如,Logic Apps 原生支援此模式,你可以將其作為非同步 API 與進行同步呼叫的用戶端之間的整合層。 欲了解更多資訊,請參閱 邏輯應用程式中的非同步請求-回應行為。
為了讓用戶端能取消長時間運行的請求,可以在狀態 API 端點資源上提供一個 DELETE 操作。 此請求應將取消指令轉發給後端處理元件。 後端處理取消後,應該會更新狀態資源以反映已取消的狀態。 此過程有助於防止未完成的工作無限消耗資源。 判斷操作是否支援部分回滾或需補償交易。
你可以要求客戶端在提交初始請求時,提供冪性金鑰,例如在請求標頭中
Idempotency-Key。 如果後端收到重複的鍵值,應該會回傳現有的狀態資源,而不是再佇列第二個工作項目。 此方法可防止網路故障導致客戶端重試伺服器已接受的 POST。 這在這個模式中尤其重要,因為客戶無法區分遺失的回應與從未收到的請求。
備註
此模式描述了 HTTP 輪詢,客戶端會定期發出新的請求以檢查狀態。 在 長輪詢中,客戶端發送請求,伺服器保持連線開通,直到有新資料可用或逾時。 長輪詢相較於週期性輪詢能減少回應延遲時間,但會帶來連線管理與超時相關的複雜性。
使用此模式的時機
當下列情況時,請使用此模式:
你處理的是客戶端程式碼,比如瀏覽器應用程式,這些限制讓回調端點難以提供,或是長時間連線會增加複雜度。
你呼叫一個只使用 HTTP 協定的服務,而回傳服務因為客戶端防火牆限制無法回調。
你需要整合不支援現代回調機制(如 WebSockets 或 webhooks)的工作負載。
在下列情況下,此模式可能不適用:
你可以使用專為非同步通知設計的服務,例如 Azure Event Grid。
回應必須即時串流至用戶端。 考慮使用伺服器推送事件(Server-Sent Events, SSE),這是一種輕量級、原生 HTTP 的單向推送通道,允許從伺服器到用戶端傳輸數據而不需要用戶端進行輪詢。
客戶端需要收集大量結果,而這些結果的延遲非常重要。 考慮改用訊息中介。
伺服器端的持久網路連線如 WebSockets 或 SignalR 可用。 你可以利用這些連線通知來電者結果。
網路架構設計支援開放埠以接收非同步回撥或網路鉤子(webhook)。
工作負載設計
架構師應評估如何在工作負載設計中使用非同步 Request-Reply 模式,以達成Azure Well-Architected框架支柱所涵蓋的目標與原則。
| 支柱 | 此模式如何支援支柱目標 |
|---|---|
| 效能效率 可透過調整、數據和程式碼的優化, 有效率地協助您的工作負載符合需求 。 | 你透過將請求階段與回應階段分離,提升不需立即回應的流程,從而提升回應速度與可擴展性。 非同步方式提升並行性,並讓伺服器能隨著容量的增加來排程工作。 - PE:05 縮放和分區 - PE:07 程式代碼和基礎結構 |
如同任何設計決策,請考量與該模式可能引入的其他支柱目標之間的取捨。
範例
以下程式碼展示了使用 Azure Functions 實作此模式的應用程式摘錄。 此解有三個功能:
- 非同步 API 端點
- 狀態端點
- 一個後端函式,負責處理佇列化的工作項目並執行它們
此範例可於 GitHub 取得。
實作使用管理身份來與 Azure Service Bus 及 Azure Blob Storage 進行認證,避免儲存連線字串或帳號金鑰。 依賴性使用Program.cs註冊在DefaultAzureCredential中,並透過主要建構子進行注入。
AsyncProcessingWorkAcceptor 函式
此 AsyncProcessingWorkAcceptor 函式實作一個端點,接受客戶端應用程式的任務並將其加入隊列以進行處理:
函式會產生要求標識碼,並將它新增為佇列訊息的元數據。
HTTP 回應包含
Location指向狀態端點的標頭,以及Retry-After建議輪詢區間的標頭。 請求 ID 會出現在 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);
}
}
AsyncProcessingBackgroundWorker 函式
函 AsyncProcessingBackgroundWorker 式會從佇列讀取操作,根據訊息有效載荷處理,並將結果寫入儲存帳號。
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);
}
}
}
AsyncOperationStatusChecker 函式
函數 AsyncOperationStatusChecker 實作狀態端點。 此函式會檢查請求的狀態:
若請求完成,函式會回傳 HTTP 303(參見其他),並將用戶端導向至輸出結果的 valet key URL。
若請求為待處理,函式會回傳 包含當前狀態的 HTTP 200 程式碼。
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
}