【.NET Core框架】异常处理

简介

NuGet包Microsoft.AspNetCore.Diagnostics中提供了几个与异常处理相关的中间件。当ASP.NET Core应用在处理请求过程中出现错误时,中间件捕获异常,并将错误信息返回给客户端。

需要注意的是,与“异常处理”有关的中间件,一定要尽早添加,这样,它可以最大限度的捕获后续中间件抛出的未处理异常。

在中间件、过滤器处理异常,有什么区别呢?

拦截范围。异常过滤器ExceptionFilter只能捕获Controller创建时、模型绑定、Action Filter和Action中抛出的未处理异常,其他地方抛出的异常捕获不到

开发人员异常页(DeveloperExceptionPageMiddleware)

开发者错误页面中间件,给开发人员看的,不对外展示。我们可以在这个页面中看到几乎所有的错误信息。如此详尽的信息无疑会极大地帮助开发人员尽快找出错误的根源。在生产环境中,我们不能将异常的详细信息暴露给用户,否则,这将会导致一系列安全问题。

            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

该异常页面展示了如下信息:

  • 异常消息
  • 异常堆栈追踪(Stack)
  • HTTP请求查询参数(Query)
  • Cookies
  • HTTP请求标头(Headers)
  • 路由(Routing),包含了终结点和路由信息

异常处理程序

上面介绍了开发环境中的异常处理,在生产环境下,可以通过调用UseExceptionHandler扩展方法注册中间件ExceptionHandlerMiddleware实现。
该异常处理程序:

  • 可以捕获后续中间件未处理的异常
  • 若无异常或HTTP响应已经启动(Response.HasStarted == true),则不做任何处理
  • 不会改变URL中的路径

异常处理程序页

如果我们有一个错误页,可以直接指定URL,表示当产生异常的时候,将定向到对应路径

app.UseExceptionHandler("/Home/Error");

需要注意的是,不要随意对Error添加[HttpGet]、[HttpPost]等限定Http请求方法的特性。一旦你加上了[HttpGet],那么该方法只能处理Get请求的异常。
不过,如果你就是打算将不同方法的Http请求分别进行处理,你可以类似如下进行处理:

public class HomeController : Controller
{
    // 处理Get请求的异常
    [HttpGet("[controller]/error")]
    [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
    public IActionResult GetError()
    {
        _logger.LogInformation("Get Exception Handled");

        return View("Error", new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
    }

    // 处理Post请求的异常
    [HttpPost("[controller]/error")]
    [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
    public IActionResult PostError()
    {
        _logger.LogInformation("Post Exception Handled");

        return View("Error", new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
    }
}

如果在请求备用管道(如示例中的Error)时也报错了,无论是Http请求管道中的中间件报错,还是Error里面报错,此时ExceptionHandlerMiddleware均会重新引发原始异常,而不是向外抛出备用管道的异常。

通过lambda提供异常处理程序

通过lambda向UseExceptionHandler中提供一个异常处理逻辑:

app.UseExceptionHandler(appbuilder => appbuilder.Use(ExceptionHandlerDemo));

private async Task ExceptionHandlerDemo(HttpContext httpContext, Func<Task> next)
{
    //该信息由ExceptionHandlerMiddleware中间件提供,里面包含了ExceptionHandlerMiddleware中间件捕获到的异常信息。
    var exceptionDetails = httpContext.Features.Get<IExceptionHandlerFeature>();
    var ex = exceptionDetails?.Error;

    if (ex != null)
    {
        httpContext.Response.ContentType = "application/problem+json";

        var title = "An error occured: " + ex.Message;
        var details = ex.ToString();

        var problem = new ProblemDetails
        {
            Status = 500,
            Title = title,
            Detail = details
        };

        var stream = httpContext.Response.Body;
        await JsonSerializer.SerializeAsync(stream, problem);
    }
}

当捕获到异常时,可以通过HttpContext.Features,并指定类型IExceptionHandlerPathFeature或IExceptionHandlerFeature(前者继承自后者),来获取到异常信息。

public interface IExceptionHandlerFeature
{
    // 异常信息
    Exception Error { get; }
}

public interface IExceptionHandlerPathFeature : IExceptionHandlerFeature
{
    // 未被转义的http请求资源路径
    string Path { get; }
}

ExceptionHandlerMiddleware源码

public class ExceptionHandlerMiddleware
{
    public ExceptionHandlerMiddleware(
        RequestDelegate next,
        ILoggerFactory loggerFactory,
        IOptions<ExceptionHandlerOptions> options,
        DiagnosticListener diagnosticListener)
    {
        // 要么手动指定一个异常处理器(如通过lambda)
        // 要么提供一个资源路径,重新发送给后续中间件,进行异常处理
        if (_options.ExceptionHandler == null)
        {
            if (_options.ExceptionHandlingPath == null)
            {
                throw new InvalidOperationException(Resources.ExceptionHandlerOptions_NotConfiguredCorrectly);
            }
            else
            {
                _options.ExceptionHandler = _next;
            }
        }
    }

    public Task Invoke(HttpContext context)
    {
        ExceptionDispatchInfo edi;
        try
        {
            var task = _next(context);
            if (!task.IsCompletedSuccessfully)
            {
                return Awaited(this, context, task);
            }

            return Task.CompletedTask;
        }
        catch (Exception exception)
        {
            edi = ExceptionDispatchInfo.Capture(exception);
        }

        // 同步完成并抛出异常时,进行处理
        return HandleException(context, edi);

        static async Task Awaited(ExceptionHandlerMiddleware middleware, HttpContext context, Task task)
        {
            ExceptionDispatchInfo edi = null;
            try
            {
                await task;
            }
            catch (Exception exception)
            {
                edi = ExceptionDispatchInfo.Capture(exception);
            }

            if (edi != null)
            {
                // 异步完成并抛出异常时,进行处理
                await middleware.HandleException(context, edi);
            }
        }
    }

    private async Task HandleException(HttpContext context, ExceptionDispatchInfo edi)
    {
        // 响应已经启动,则跳过处理,直接上抛
        if (context.Response.HasStarted)
        {
            edi.Throw();
        }

        PathString originalPath = context.Request.Path;
        if (_options.ExceptionHandlingPath.HasValue)
        {
            context.Request.Path = _options.ExceptionHandlingPath;
        }
        try
        {
            ClearHttpContext(context);

            // 将 exceptionHandlerFeature 存入 context.Features
            var exceptionHandlerFeature = new ExceptionHandlerFeature()
            {
                Error = edi.SourceException,
                Path = originalPath.Value,
            };
            context.Features.Set<IExceptionHandlerFeature>(exceptionHandlerFeature);
            context.Features.Set<IExceptionHandlerPathFeature>(exceptionHandlerFeature);
            context.Response.StatusCode = StatusCodes.Status500InternalServerError;
            context.Response.OnStarting(_clearCacheHeadersDelegate, context.Response);

            // 处理异常
            await _options.ExceptionHandler(context);

            if (context.Response.StatusCode != StatusCodes.Status404NotFound || _options.AllowStatusCode404Response)
            {
                return;
            }
        }
        catch (Exception ex2) { }
        finally
        {
            // 还原请求路径,保证浏览器中的Url不变
            context.Request.Path = originalPath;
        }

        // 如果异常未被处理,则重新引发原始异常
        edi.Throw();
    }
}

Http错误状态码处理

默认情况下,当ASP.NET Core遇到没有正文的400-599Http错误状态码时,不会为其提供页面,而是返回状态码和空响应正文。可是,为了良好的用户体验,一般我们会对常见的错误状态码(404)提供友好的页面
通过UseStatusCodePages注册中间件StatusCodePagesMiddleware

请注意,本节所涉及到的中间件与上两节所讲解的错误异常处理中间件不冲突,可以同时使用。确切的说,本节并不是处理异常,只是为了提升用户体验。
一定要在异常处理中间件之后,请求处理中间件之前调用UseStatusCodePages。

UseStatusCodePages

app.UseStatusCodePages();

现在,你可以请求一个不存在的路径,例如Home/Index2,你会在浏览器中看到如下输出:

Status Code: 404; Not Found 

UseStatusCodePages也提供了重载,允许我们自定义响应内容类型和正文内容,如:

// 使用占位符 {0} 来填充Http状态码
app.UseStatusCodePages("text/plain", "Status code is: {0}");

也可以通过向UseStatusCodePages传入lambda表达式进行处理:

public class Program
{
    private static readonly Random _random = new Random();
    public static void Main()
    {
        Host.CreateDefaultBuilder()
            .ConfigureWebHostDefaults(webBuilder => webBuilder.Configure(app => app
                .UseStatusCodePages(HandleAsync)
                .Run(context => Task.Run(() => context.Response.StatusCode = _random.Next(400, 599)))))
            .Build()
            .Run();

        static async Task HandleAsync(StatusCodeContext context)
        {
            var response = context.HttpContext.Response;
            if (response.StatusCode < 500)
            {
                await response.WriteAsync($"Client error ({response.StatusCode})");
            }
            else
            {
                await response.WriteAsync($"Server error ({response.StatusCode})");
            }
        }
    }
}

事实上UseStatusCodePages效果并不好,所以我们在生产环境一般是不会用这玩意的,那用啥呢?请随我继续往下看。

UseStatusCodePagesWithRedirects

该扩展方法,内部实际上是通过调用UseStatusCodePages并传入lambda进行实现的,该方法:

  • 接收一个Http资源定位字符串。同样的,会有一个占位符{0},用于填充Http状态码
  • 向客户端发送Http状态码302
  • 然后将客户端重定向到指定的终结点,在该终结点中,可以针对不同错误状态码分别进行处理
app.UseStatusCodePagesWithRedirects("/Home/StatusCodeError?code={0}");

当我们请求一个不存在的路径时,它的确会跳转到/Home/StatusCodeError?code=404,而且,响应状态码也变了,变成了200Ok。
如果你不想更改原始请求的Url,而且保留原始状态码,那么你应该使用接下来要介绍的UseStatusCodePagesWithReExecute

UseStatusCodePagesWithReExecute

同样的,该扩展方法,内部也是通过调用UseStatusCodePages并传入lambda进行实现的,不过该方法:

  • 接收1个路径字符串和和1个查询字符串。同样的,会有一个占位符{0},用于填充Http状态码
  • Url保持不变,并向客户端返回原始Http状态码
  • 执行备用管道,用于生成响应正文
app.UseStatusCodePagesWithReExecute("/Home/StatusCodeError", "?code={0}");

参考:
https://www.cnblogs.com/artech/p/error-handling-middleware-01.html
https://www.jianshu.com/p/cab597211136
https://www.jb51.net/article/153926.htm
https://www.cnblogs.com/xiaoxiaotank/p/15586706.html

posted @ 2021-01-27 23:29  .Neterr  阅读(810)  评论(0编辑  收藏  举报