理解ASP.NET Core - 错误处理(Handle Errors)

注:本文隶属于《理解ASP.NET Core》系列文章,请查看置顶博客或点击此处查看全文目录

使用中间件进行错误处理

开发人员异常页

开发人员异常页用于显示未处理的请求异常的详细信息。当我们通过ASP.NET Core模板创建一个项目时,

Startup.Configure
方法中会自动生成以下代码:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
// 添加开发人员异常页中间件
app.UseDeveloperExceptionPage();
}
}

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

可以看到,当程序运行在开发环境中时,才会启用开发人员异常页,这很好理解,因为在生产环境中,我们不能将异常的详细信息暴露给用户,否则,这将会导致一系列安全问题。

现在我们在下方添加如下代码抛出一个异常:

app.Use((context, next) =>
{
throw new NotImplementedException();
});

当开发人员异常页中间件捕获了该未处理异常时,会展示类似如下的相关信息:

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

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

IDeveloperPageExceptionFilter

当你查看

DeveloperExceptionPageMiddleware
的源码时,你会在构造函数中发现一个入参,类型为
IEnumerable<IDeveloperPageExceptionFilter>
。通过这个Filter集合,组成一个错误处理器管道,按照先注册先执行的原则,顺序进行错误处理。

下面是

DeveloperExceptionPageMiddleware
的核心源码:

public class DeveloperExceptionPageMiddleware
{
public DeveloperExceptionPageMiddleware(
RequestDelegate next,
IOptions<DeveloperExceptionPageOptions> options,
ILoggerFactory loggerFactory,
IWebHostEnvironment hostingEnvironment,
DiagnosticSource diagnosticSource,
IEnumerable<IDeveloperPageExceptionFilter> filters)
{
// ...

// 将 DisplayException 放置在管道最底部
// DisplayException 就用于向响应中写入我们上面见到的异常页
_exceptionHandler = DisplayException;

foreach (var filter in filters.Reverse())
{
var nextFilter = _exceptionHandler;
_exceptionHandler = errorContext => filter.HandleExceptionAsync(errorContext, nextFilter);
}
}

public async Task Invoke(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
// 响应已经启动,则跳过处理,直接上抛
if (context.Response.HasStarted)
{
throw;
}

try
{
context.Response.Clear();
context.Response.StatusCode = 500;

// 错误处理
await _exceptionHandler(new ErrorContext(context, ex));

// ...

// 错误已成功处理
return;
}
catch (Exception ex2) { }

// 若处理过程中抛出了新的异常ex2,则重新引发原始异常ex
throw;
}
}
}

这也就说明,如果我们想要自定义开发者异常页,那我们可以通过实现

IDeveloperPageExceptionFilter
接口来达到目的。

先看一下

IDeveloperPageExceptionFilter
接口定义:

public interface IDeveloperPageExceptionFilter
{
Task HandleExceptionAsync(ErrorContext errorContext, Func<ErrorContext, Task> next);
}

public class ErrorContext
{
public ErrorContext(HttpContext httpContext, Exception exception)
{
HttpContext = httpContext ?? throw new ArgumentNullException(nameof(httpContext));
Exception = exception ?? throw new ArgumentNullException(nameof(exception));
}

public HttpContext HttpContext { get; }

public Exception Exception { get; }
}

HandleExceptionAsync
方法除了错误上下文信息外,还包含了一个
Func<ErrorContext, Task> next
,这是干嘛的呢?其实,前面我们已经提到了,
IDeveloperPageExceptionFilter
的所有实现,会组成一个管道,当错误需要在管道中的后续处理器作进一步处理时,就是通过这个
next
传递错误的,所以, 当需要传递错误时,一定要记得调用
next

不废话了,赶紧实现一个看看效果吧:

public class MyDeveloperPageExceptionFilter : IDeveloperPageExceptionFilter
{
public Task HandleExceptionAsync(ErrorContext errorContext, Func<ErrorContext, Task> next)
{
errorContext.HttpContext.Response.WriteAsync($"MyDeveloperPageExceptionFilter: {errorContext.Exception}");

// 我们不调用 next,这样就不会执行 DisplayException

return Task.CompletedTask;
}
}

public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<IDeveloperPageExceptionFilter, MyDeveloperPageExceptionFilter>();
}

当抛出一个异常,你会看到类似如下的页面:

异常处理程序

上面介绍了开发环境中的异常处理,现在我们来看一下生产环境中的异常处理,通过调用

UseExceptionHandler
扩展方法注册中间件
ExceptionHandlerMiddleware

该异常处理程序:

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

默认情况下,会生成类似如下的模板:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
// 添加异常处理程序
app.UseExceptionHandler("/Home/Error");
}
}

通过lambda提供异常处理程序

我们可以通过lambda向

UseExceptionHandler
中提供一个异常处理逻辑:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseExceptionHandler(errorApp =>
{
var loggerFactory = errorApp.ApplicationServices.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("ExceptionHandlerWithLambda");

errorApp.Run(async context =>
{
// 这里可以自定义 http response 内容,以下仅是示例

var exceptionHandlerPathFeature = context.Features.Get<IExceptionHandlerPathFeature>();

logger.LogError($"Exception Handled:{exceptionHandlerPathFeature.Error}");

var statusCode = StatusCodes.Status500InternalServerError;
var message = exceptionHandlerPathFeature.Error.Message;

if (exceptionHandlerPathFeature.Error is NotImplementedException)
{
message = "俺未实现";
statusCode = StatusCodes.Status501NotImplemented;
}

context.Response.StatusCode = statusCode;
context.Response.ContentType = "application/json";
await context.Response.WriteAsJsonAsync(new
{
Message = message,
Success = false,
});

});
});
}

可以看到,当捕获到异常时,可以通过

HttpContext.Features
,并指定类型
IExceptionHandlerPathFeature
IExceptionHandlerFeature
(前者继承自后者),来获取到异常信息。

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

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

再提醒一遍,千万不要将敏感的错误信息暴露给客户端。

异常处理程序页

除了使用lambda外,我们还可以指定一个路径,指向一个备用管道进行异常处理,这个备用管道对于MVC来说,一般是Controller中的Action,例如MVC模板默认的

/Home/Error

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseExceptionHandler("/Home/Error");
}

public class HomeController : Controller
{
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
public IActionResult Error()
{
return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
}
}

当捕获到异常时,你会看到类似如下的页面:

你可以在Action

Error
中自定义错误处理逻辑,就像lambda一样。

需要注意的是,不要随意对

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
均会重新引发原始异常,而不是向外抛出备用管道的异常。

一般异常处理程序页是面向所有用户的,所以请保证它可以匿名访问。

下面一块看一下

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)提供友好的页面,如gitee404

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

UseStatusCodePages

我们可以通过

StatusCodePagesMiddleware
中间件实现该功能:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseDeveloperExceptionPage();

// 添加 StatusCodePagesMiddleware 中间件
app.UseStatusCodePages();

// ...请求处理中间件
app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();

app.UseAuthorization();

app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
});
}

注意,一定要在异常处理中间件之后,请求处理中间件之前调用

UseStatusCodePages

现在,你可以请求一个不存在的路径,例如

Home/Index2
,你会在浏览器中看到如下输出:

Status Code: 404; Not Found

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

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

浏览器输出为:

Status code is: 404

同样地,我们也可以通过向

UseStatusCodePages
传入lambda表达式进行处理:

app.UseStatusCodePages(async context =>
{
context.HttpContext.Response.ContentType = "text/plain";

await context.HttpContext.Response.WriteAsync(
$"Status code is: {context.HttpContext.Response.StatusCode}");
});

介绍了那么多,你也看到了,事实上

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

UseStatusCodePagesWithRedirects

该扩展方法,内部实际上是通过调用

UseStatusCodePages
并传入lambda进行实现的,该方法:

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

public class HomeController : Controller
{
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
public IActionResult StatusCodeError(int code)
{
return code switch
{
// 跳转到404页面
StatusCodes.Status404NotFound => View("404"),
// 跳转到统一展示页面
_ => View(code),
};
}
}

现在你可以自己试一下。

不知道你有没有注意:当我们请求一个不存在的路径时,它的确会跳转到404页面,但是,Url也变了,变成了

/Home/StatusCodeError?code=404
,而且,响应状态码也变了,变成了
200Ok
。可以通过源码看一下咋回事(我相信,大家看到302其实也都明白了):

public static IApplicationBuilder UseStatusCodePagesWithRedirects(this IApplicationBuilder app, string locationFormat)
{
// 两个条件分支都差不多,我们看第二个,容易理解一些
if (locationFormat.StartsWith("~"))
{
locationFormat = locationFormat.Substring(1);
return app.UseStatusCodePages(context =>
{
var location = string.Format(CultureInfo.InvariantCulture, locationFormat, context.HttpContext.Response.StatusCode);
context.HttpContext.Response.Redirect(context.HttpContext.Request.PathBase + location);
return Task.CompletedTask;
});
}
else
{
return app.UseStatusCodePages(context =>
{
// 格式化资源定位,context.HttpContext.Response.StatusCode 作占位符
var location = string.Format(CultureInfo.InvariantCulture, locationFormat, context.HttpContext.Response.StatusCode);
// 重定向(302)到设定的资源
context.HttpContext.Response.Redirect(location);
return Task.CompletedTask;
});
}
}

如果你不想更改原始请求的Url,而且保留原始状态码,那么你应该使用接下来要介绍的

UseStatusCodePagesWithReExecute

UseStatusCodePagesWithReExecute

同样的,该扩展方法,内部也是通过调用

UseStatusCodePages
并传入lambda进行实现的,不过该方法:

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

具体例子就不再列举了,用上面的就行了。现在来看看源码:

public static IApplicationBuilder UseStatusCodePagesWithReExecute(
this IApplicationBuilder app,
string pathFormat,
string queryFormat = null)
{
return app.UseStatusCodePages(async context =>
{
// 请注意,此时Http响应还未启动

// 格式化资源路径,context.HttpContext.Response.StatusCode 作占位符
var newPath = new PathString(
string.Format(CultureInfo.InvariantCulture, pathFormat, context.HttpContext.Response.StatusCode));
// 格式化查询字符串,context.HttpContext.Response.StatusCode 作占位符
var formatedQueryString = queryFormat == null ? null :
string.Format(CultureInfo.InvariantCulture, queryFormat, context.HttpContext.Response.StatusCode);
var newQueryString = queryFormat == null ? QueryString.Empty : new QueryString(formatedQueryString);

var originalPath = context.HttpContext.Request.Path;
var originalQueryString = context.HttpContext.Request.QueryString;
// 将原始请求信息保存下来,以便后续进行还原
context.HttpContext.Features.Set<IStatusCodeReExecuteFeature>(new StatusCodeReExecuteFeature()
{
OriginalPathBase = context.HttpContext.Request.PathBase.Value,
OriginalPath = originalPath.Value,
OriginalQueryString = originalQueryString.HasValue ? originalQueryString.Value : null,
});

context.HttpContext.SetEndpoint(endpoint: null);
var routeValuesFeature = context.HttpContext.Features.Get<IRouteValuesFeature>();
routeValuesFeature?.RouteValues?.Clear();

// 构造新请求
context.HttpContext.Request.Path = newPath;
context.HttpContext.Request.QueryString = newQueryString;
try
{
// 执行备用管道,生成响应正文
await context.Next(context.HttpContext);
}
finally
{
// 还原原始请求信息
context.HttpContext.Request.QueryString = originalQueryString;
context.HttpContext.Request.Path = originalPath;
context.HttpContext.Features.Set<IStatusCodeReExecuteFeature>(null);
}
});
}

在MVC中,你可以通过给控制器或其中的Action方法添加

[SkipStatusCodePages]
特性,可以略过
StatusCodePagesMiddleware

使用过滤器进行错误处理

除了错误处理中间件外,ASP.NET Core 还提供了异常过滤器,用于错误处理。

异常过滤器:

通过实现接口
IExceptionFilter
IAsyncExceptionFilter
来自定义异常过滤器 可以捕获Controller创建时(也就是只捕获构造函数中抛出的异常)、模型绑定、Action Filter和Action中抛出的未处理异常 其他地方抛出的异常不会捕获

本节仅介绍异常过滤器,有关过滤器的详细内容,后续文章将会介绍

先来看一下这两个接口:

// 仅具有标记作用,标记其为 mvc 请求管道的过滤器
public interface IFilterMetadata { }

public interface IExceptionFilter : IFilterMetadata
{
// 当抛出异常时,该方法会捕获
void OnException(ExceptionContext context);
}

public interface IAsyncExceptionFilter : IFilterMetadata
{
// 当抛出异常时,该方法会捕获
Task OnExceptionAsync(ExceptionContext context);
}

OnException
OnExceptionAsync
方法都包含一个类型为
ExceptionContext
参数,很显然,它就是与异常有关的上下文,我们的异常处理逻辑离不开它。那接着来看一下它的结构吧:

public class ExceptionContext : FilterContext
{
// 捕获到的未处理异常
public virtual Exception Exception { get; set; }

public virtual ExceptionDispatchInfo? ExceptionDispatchInfo { get; set; }

// 指示异常是否已被处理
// true:表示异常已被处理,异常不会再向上抛出
// false:表示异常未被处理,异常仍会继续向上抛出
public virtual bool ExceptionHandled { get; set; }

// 设置响应的 IActionResult
// 如果设置了结果,也表示异常已被处理,异常不会再向上抛出
public virtual IActionResult? Result { get; set; }
}

除此之外,

ExceptionContext
还继承了
FilterContext
,而
FilterContext
又继承了
ActionContext
(这也从侧面说明,过滤器是为Action服务的),也就是说我们也能够获取到一些过滤器和Action相关的信息,看看都有什么吧:

public class ActionContext
{
// Action相关的信息
public ActionDescriptor ActionDescriptor { get; set; }

// HTTP上下文
public HttpContext HttpContext { get; set; }

// 模型绑定和验证
public ModelStateDictionary ModelState { get; }

// 路由数据
public RouteData RouteData { get; set; }
}

public abstract class FilterContext : ActionContext
{
public virtual IList<IFilterMetadata> Filters { get; }

public bool IsEffectivePolicy<TMetadata>(TMetadata policy) where TMetadata : IFilterMetadata {}

public TMetadata FindEffectivePolicy<TMetadata>() where TMetadata : IFilterMetadata {}
}

更多参数细节,我会在专门讲过滤器的文章中详细介绍。

下面,我们就来实现一个自定义的异常处理器:

public class MyExceptionFilterAttribute : ExceptionFilterAttribute
{
private readonly IModelMetadataProvider _modelMetadataProvider;

public MyExceptionFilterAttribute(IModelMetadataProvider modelMetadataProvider)
{
_modelMetadataProvider = modelMetadataProvider;
}

public override void OnException(ExceptionContext context)
{
if (!context.ExceptionHandled)
{
// 此处仅为简单演示
var exception = context.Exception;
var result = new ViewResult()
{
ViewName = "Error",
ViewData = new ViewDataDictionary(_modelMetadataProvider, context.ModelState)
{
// 记得给 ErrorViewModel 加上 Message 属性
Model = new ErrorViewModel
{
Message = exception.ToString()
}
}
};

context.Result = result;

// 标记异常已处理
context.ExceptionHandled = true;
}
}
}

接着,找到

/Views/Shared/Error.cshtml
,展示一下错误消息:

@model ErrorViewModel
@{
ViewData["Title"] = "Error";
}

<p>@Model.Message</p>

最后,将服务

MyExceptionFilterAttribute
注册到DI容器:

public void ConfigureServices(IServiceCollection services)
{
services.AddScoped<MyExceptionFilterAttribute>();

services.AddControllersWithViews();
}

现在,我们将该异常处理器加在

/Home/Index
上,并抛个异常:

public class HomeController : Controller
{
[ServiceFilter(typeof(MyExceptionFilterAttribute))]
public IActionResult Index()
{
throw new Exception("Home Index Error");

return View();
}
}

当请求

/Home/Index
时,你会得到如下页面:

错误处理中间件 VS 异常过滤器

现在,我们已经介绍了两种错误处理的方法——错误处理中间件和异常过滤器。现在来比较一下它们的异同,以及我们何时应该选择哪种处理方式。

错误处理中间件:

可以捕获后续中间件的所有未处理异常 拥有
RequestDelegate
,操作更加灵活 粒度较粗,仅可针对全局进行配置

错误处理中间件适合用于处理全局异常。

异常过滤器:

仅可捕获Controller创建时(也就是构造函数中抛出的异常)、模型绑定、Action Filter和Action中抛出的未处理异常,其他地方抛出的异常捕获不到 粒度更小,可以灵活针对Controller或Action配置不同的异常过滤器

异常过滤器非常适合用于捕获并处理Action中的异常。

在我们的应用中,可以同时使用错误处理中间件和异常过滤器,只有充分发挥它们各自的优势,才能处理好程序中的错误。