1. 程式人生 > >ASP.NET Core 中斷請求瞭解一下(翻譯)

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才能完成顯示。
頁面展示
如果我們檢查執行日誌,我們發現其輸出符合預期:
執行Log

如果在第一次請求返回之前,重新整理頁面,結果將是怎樣呢??

重新整理後執行日誌
從日誌中我們可以看出:重新整理後,第一個請求雖然在客戶端被取消了,但是服務端仍舊會持續執行。

從而可以說明MVC的預設行為: 即使使用者重新整理了瀏覽器會取消原始請求,但MVC對其一無所知,已經被取消的請求還是會在服務端繼續執行,而最終的執行結果將會被丟棄。

這樣就會造成嚴重的效能浪費。如果服務端能感知使用者中斷了請求,並終止執行耗時的任務就好了。

幸好,ASP.NET Core開發團隊體貼的考慮了這一點,允許我們通過以下兩種方式來獲取客戶端的請求是否被終止。

  1. 通過HttpContexRequestAborted屬性:
  2. 通過方法注入CancellationToken引數:
if (HttpContext.RequestAborted.IsCancellationRequested)
{
    // can stop working now
}
[HttpGet]
public async Task<ActionResult> GetHardWork(CancellationToken cancellationToken)
{
    // ...
 
    if (cancellationToken.IsCancellationRequested)
    {
        // stop!
    }
     
    // ...
}

而這兩種方式其實是一樣的,因為HttpContext.RequestAbortedcancellationToken對應的是同一個物件:

if(cancellationToken == HttpContext.RequestAborted)
{
    // this is true!
}

下面我們就來以cancellationToken為例,看看如何感知客戶端請求終止並終止服務端服務。

3. 在Action中使用CancellationToken

CancellationToken是由CancellationTokenSource建立的輕量級物件。當某個CancellationTokenSource被取消時,它會通知所有的消費者CancellationToken

取消時,CancellationTokenIsCancellationRequested屬性將設定為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(),那麼你可以直接傳入CancellationToken,並讓內部方法負責實際取消。
在其他情況下,您可能正在進行一些同步工作,您希望能夠取消這些工作。例如,假設正在構建一份報告來計算公司員工的所有佣金。你迴圈每個員工,然後遍歷他們的每一筆銷售。

能夠在中途取消此報告生成的簡單解決方案是檢查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來捕捉使用者請求的狀態,從而根據需要進行相應的處理。