ASP.NET Core 中斷請求瞭解一下(翻譯)
本文所講方式僅適用於託管在 Kestrel Server 中的應用。如果託管在IIS和IIS Express上時,ASP.NET Core Module(ANCM)並不會告訴ASP.NET Core在客戶端斷開連線時中止請求。但可喜的是,ANCM預計在.NET Core 2.2中會完善這一機制。
1. 引言
假設有一個耗時的Action,在瀏覽器發出請求返回響應之前,如果重新整理了頁面,對於瀏覽器(客戶端)來說前一個請求就會被終止。而對於服務端來說,又是怎樣呢?前一個請求也會自動終止,還是會繼續執行呢?
下面我們通過例項尋求答案。
2. 例項演示
建立一個 SlowRequestController
,再定義一個 Get
請求,並通過 Task.Delay(10_000)
模擬耗時行為。程式碼如下:
public class SlowRequestController : Controller { private readonly ILogger _logger; public SlowRequestController(ILogger<SlowRequestController> logger) { _logger = logger; } [HttpGet("/slowtest")] public async Task<string> Get() { _logger.LogInformation("Starting to do slow work"); // slow async action, e.g. call external api await Task.Delay(10_000); var message = "Finished slow delay of 10 seconds."; _logger.LogInformation(message); return message; } }
如果我們發起請求,那麼該頁面將耗時10s才能完成顯示。

如果我們檢查執行日誌,我們發現其輸出符合預期:

如果在第一次請求返回之前,重新整理頁面,結果將是怎樣呢??
從日誌中我們可以看出: 重新整理後,第一個請求雖然在客戶端被取消了,但是服務端仍舊會持續執行。
從而可以說明MVC的預設行為: 即使使用者重新整理了瀏覽器會取消原始請求,但MVC對其一無所知,已經被取消的請求還是會在服務端繼續執行,而最終的執行結果將會被丟棄。
這樣就會造成嚴重的效能浪費。如果服務端能感知使用者中斷了請求,並終止執行耗時的任務就好了。
幸好,ASP.NET Core開發團隊體貼的考慮了這一點,允許我們通過以下兩種方式來獲取客戶端的請求是否被終止。
- 通過
HttpContex
的RequestAborted
屬性:
if (HttpContext.RequestAborted.IsCancellationRequested) { // can stop working now }
- 通過方法注入
CancellationToken
引數:
[HttpGet] public async Task<ActionResult> GetHardWork(CancellationToken cancellationToken) { // ... if (cancellationToken.IsCancellationRequested) { // stop! } // ... }
而這兩種方式其實是一樣的,因為 HttpContext.RequestAborted
和 cancellationToken
對應的是同一個物件:
if(cancellationToken == HttpContext.RequestAborted) { // this is true! }
下面我們就來以 cancellationToken
為例,看看如何感知客戶端請求終止並終止服務端服務。
3. 在Action中使用CancellationToken
CancellationToken
是由 CancellationTokenSource
建立的輕量級物件。當某個 CancellationTokenSource
被取消時,它會通知所有的消費者 CancellationToken
。這允許一箇中心位置通知您的應用程式中請求取消的所有程式碼路徑。
取消時, CancellationToken
的 IsCancellationRequested
屬性將設定為True,表示 CancellationTokenSource
已取消。
再回到前面的例項,我們有一個長期執行的操作方法(例如,通過呼叫許多其他API生成只讀報告)。由於它是一種昂貴的方法,我們希望在使用者取消請求時儘快停止執行操作。
下面的程式碼顯示了通過在action方法中注入一個 CancellationToken
,並將其傳遞給 Task.Delay
,來達到同步終止服務端請求的目的:
public class SlowRequestController : Controller { private readonly ILogger _logger; public SlowRequestController(ILogger<SlowRequestController> logger) { _logger = logger; } [HttpGet("/slowtest")] public async Task<string> Get(CancellationToken cancellationToken) { _logger.LogInformation("Starting to do slow work"); // slow async action, e.g. call external api await Task.Delay(10_000, cancellationToken); var message = "Finished slow delay of 10 seconds."; _logger.LogInformation(message); return message; } }
MVC將使用 CancellationTokenModelBinder
自動將Action中的任何 CancellationToken
引數繫結到 HttpContext.RequestAborted
。當我們在 Startup.ConfigureServices()
中呼叫 services.AddMvc()
或 services.AddMvcCore()
時, CancellationTokenModelBinder
模型繫結器就會被自動註冊。
通過這個小改動,我們再嘗試在第一個請求返回之前重新整理頁面,從日誌中我們發現,第一個請求將不會繼續完成。而是當 Task.Delay
檢測到 CancellationToken.IsCancellationRequested
屬性為true時立即停止執行時並丟擲 TaskCancelledException
。

簡而言之,使用者重新整理瀏覽器,在服務端通過丟擲 TaskCancelledException
異常終止了第一個請求,而該異常通過請求管道再傳播回來。
在這個場景中, Task.Delay()
會監視 CancellationToken
,因此無需我們手動檢查 CancellationToken
是否被取消。
4. 手動檢查CancellationToken狀態
如果你正在呼叫支援 CancellationToken
的內建方法,比如 Task.Delay()
或 HttpClient.SendAsync()
,那麼你可以直接傳入令牌,並讓內部方法負責實際取消。
在其他情況下,您可能正在進行一些同步工作,您希望能夠取消這些工作。例如,假設正在構建一份報告來計算公司員工的所有佣金。你迴圈每個員工,然後遍歷他們的每一筆銷售。
能夠在中途取消此報告生成的簡單解決方案是檢查for迴圈內的 CancellationToken
,如果使用者取消請求則跳出迴圈。
以下示例通過迴圈10次並執行某些同步(不可取消)工作來表示此類情況,該工作由對 Thread.Sleep()
來模擬。在每個迴圈開始時,我們檢查 CancellationToken
,如果取消則丟擲異常。這使得我們可以終止一個長時間執行的同步任務。
public class SlowRequestController : Controller { private readonly ILogger _logger; public SlowRequestController(ILogger<SlowRequestController> logger) { _logger = logger; } [HttpGet("/slowtest")] public async Task<string> Get(CancellationToken cancellationToken) { _logger.LogInformation("Starting to do slow work"); for(var i=0; i<10; i++) { cancellationToken.ThrowIfCancellationRequested(); // slow non-cancellable work Thread.Sleep(1000); } var message = "Finished slow delay of 10 seconds."; _logger.LogInformation(message); return message; } }
現在,如果你取消請求,則對 ThrowIfCancelletionRequested()
的呼叫將丟擲一個 OperationCanceledException
,它將再次傳播回過濾器管道和中介軟體管道。
5. 使用ExceptionFilter捕捉取消異常
ExceptionFilters是一個MVC概念,可用於處理在您的操作方法或操作過濾器中發生的異常。可以參考 官方文件 。
可以將過濾器應用到控制器級別和操作級別,也可以應用於全域性級別。為了簡單起見,我們建立一個過濾器並新增到全域性過濾器。
public class OperationCancelledExceptionFilter : ExceptionFilterAttribute { private readonly ILogger _logger; public OperationCancelledExceptionFilter(ILoggerFactory loggerFactory) { _logger = loggerFactory.CreateLogger<OperationCancelledExceptionFilter>(); } public override void OnException(ExceptionContext context) { if(context.Exception is OperationCanceledException) { _logger.LogInformation("Request was cancelled"); context.ExceptionHandled = true; context.Result = new StatusCodeResult(499); } } }
我們通過過載 OnException
方法並特殊處理 OperationCanceledException
異常即可成功捕獲取消異常。
Task.Delay()
丟擲的異常是 TaskCancelledException
型別,其為 OperationCanceledException
的基類,所以,以上過濾器也可正確捕捉。
然後註冊過濾器:
public class Startup { public void ConfigureServices(IServiceCollection services) { services.AddMvc(options => { options.Filters.Add<OperationCancelledExceptionFilter>(); }); } }
現在再測試,我們發現執行日誌將不會包含異常資訊,取而代之的是我們自定義的資訊。
6. 最後
通過本文,我們知道使用者可以通過點選瀏覽器上的停止或重新載入按鈕隨時取消Web應用的請求。而實際上僅僅是終止了客戶端的請求,服務端的請求還在繼續執行。對於簡單耗時短的請求來說,我們可以不予理睬。但是,對於耗時任務來說,我們卻不可以置若罔聞,因為其有很高的效能損耗。
而如何解決呢?其關鍵是通過 CancellationToken
來捕捉使用者請求的狀態,從而根據需要進行相應的處理。