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开发团队体贴的考虑了这一点,允许我们通过以下两种方式来获取客户端的请求是否被终止。

  1. 通过 HttpContex的 RequestAborted属性:

  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. 服务端是如何知晓客户端的中断请求的呢

这就要提到FTP的四次挥手流程了,当客户端中断请求,会发送一个FIN报文段,服务端据此来判断请求是否中断。具体可以参照KestrelHttpServerSocketConnection中的代码实现:

        private async Task DoReceive()
        {
            try
            {
                while (true)
                {
                    // Ensure we have some reasonable amount of buffer space
                    var buffer = _input.Alloc(MinAllocBufferSize);
                    try
                    {
                        var bytesReceived = await _socket.ReceiveAsync(GetArraySegment(buffer.Buffer), SocketFlags.None);
                        if (bytesReceived == 0)
                        {
                            // We receive a FIN so throw an exception so that we cancel the input
                            // with an error
                            throw new TaskCanceledException("The request was aborted");
                        }
                        buffer.Advance(bytesReceived);
                    }
                    finally
                    {
                        buffer.Commit();
                    }
                    var result = await buffer.FlushAsync();
                    if (result.IsCompleted)
                    {
                        // Pipe consumer is shut down, do we stop writing
                        _socket.Shutdown(SocketShutdown.Receive);
                        break;
                    }
                }
                _input.Complete();
            }
            catch (Exception ex)
            {
                _connectionContext.Abort(ex);
                _input.Complete(ex);
            }
        }

7. 最后

通过本文,我们知道用户可以通过点击浏览器上的停止或重新加载按钮随时取消Web应用的请求。而实际上仅仅是终止了客户端的请求,服务端的请求还在继续运行。对于简单耗时短的请求来说,我们可以不予理睬。但是,对于耗时任务来说,我们却不可以置若罔闻,因为其有很高的性能损耗。

而如何解决呢?其关键是通过 CancellationToken来捕捉用户请求的状态,从而根据需要进行相应的处理。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值