ASP.NET Core错误处理中间件[3]: 异常处理器
DeveloperExceptionPageMiddleware中间件错误页面可以呈现抛出的异常和当前请求上下文的详细信息,以辅助开发人员更好地进行纠错诊断工作。ExceptionHandlerMiddleware中间件则主要面向最终用户,我们可以利用它来显示一个友好的定制化错误页面。更多关于ASP.NET Core的文章请点这里]
一、ExceptionHandlerMiddleware
由于ExceptionHandlerMiddleware中间件可以使用指定的RequestDelegate对象来作为异常处理器,所以我们可以将它视为一个“万能”的异常处理方案。按照惯例,下面先介绍ExceptionHandlerMiddleware类型的定义。
public class ExceptionHandlerMiddleware { public ExceptionHandlerMiddleware(RequestDelegate next, ILoggerFactory loggerFactory, IOptions<ExceptionHandlerOptions> options, DiagnosticListener diagnosticListener); public Task Invoke(HttpContext context); } public class ExceptionHandlerOptions { public RequestDelegate ExceptionHandler { get; set; } public PathString ExceptionHandlingPath { get; set; } }
与DeveloperExceptionPageMiddleware类似,在创建一个ExceptionHandlerMiddleware对象时同样需要提供一个携带配置选项的对象,从上面的代码片段可以看出,配置选项由一个ExceptionHandlerOptions对象承载。一个ExceptionHandlerOptions对象通过其ExceptionHandler属性提供了一个作为异常处理器的RequestDelegate对象。如果希望应用在发生异常后自动重定向到某个指定的路径,该路径就可以利用ExceptionHandlingPath属性来指定。我们一般调用IApplicationBuilder接口的UseExceptionHandler扩展方法来注册ExceptionHandlerMiddleware中间件,这些重载的UseExceptionHandler扩展方法会采用如下方式完成中间件的注册工作。
public static class ExceptionHandlerExtensions { public static IApplicationBuilder UseExceptionHandler(this IApplicationBuilder app) => app.UseMiddleware<ExceptionHandlerMiddleware>(); public static IApplicationBuilder UseExceptionHandler(this IApplicationBuilder app, ExceptionHandlerOptions options) => app.UseMiddleware<ExceptionHandlerMiddleware>(Options.Create(options)); public static IApplicationBuilder UseExceptionHandler(this IApplicationBuilder app, string errorHandlingPath) =>app.UseExceptionHandler(new ExceptionHandlerOptions { ExceptionHandlingPath = new PathString(errorHandlingPath) }); public static IApplicationBuilder UseExceptionHandler(this IApplicationBuilder app, Action<IApplicationBuilder> configure) { IApplicationBuilder newBuilder = app.New(); configure(newBuilder); return app.UseExceptionHandler(new ExceptionHandlerOptions { ExceptionHandler = newBuilder.Build() }); } }
ExceptionHandlerMiddleware中间件处理请求的本质如下:在后续请求处理过程中出现异常的情况下,采用注册的异常处理器来处理当前请求,这个异常处理器就是RequestDelegate对象。该中间件采用的请求处理逻辑大体上可以通过如下所示的代码片段来体现。
public class ExceptionHandlerMiddleware { private RequestDelegate _next; private ExceptionHandlerOptions _options; public ExceptionHandlerMiddleware(RequestDelegate next, IOptions<ExceptionHandlerOptions> options,...) { _next = next; _options = options.Value; ... } public async Task Invoke(HttpContext context) { try { await _next(context); } catch { context.Response.StatusCode = 500; context.Response.Clear(); if (_options.ExceptionHandlingPath.HasValue) { context.Request.Path = _options.ExceptionHandlingPath; } var handler = _options.ExceptionHandler ?? _next; await handler(context); } } }
如上面的代码片段所示,如果后续的请求处理过程中出现异常,ExceptionHandlerMiddleware中间件会利用指定的作为异常处理器的RequestDelegate对象来完成最终的请求处理工作。如果创建ExceptionHandlerMiddleware对象时提供的ExceptionHandlerOptions对象携带了一个RequestDelegate对象,那么它将作为最终使用的异常处理器,否则作为异常处理器的实际上就是后续的中间件。换句话说,如果没有通过ExceptionHandlerOptions对象显式指定一个异常处理器,ExceptionHandlerMiddleware中间件会在后续管道处理请求抛出异常的情况下将请求再次传递给后续管道。
在ExceptionHandlerMiddleware中间件利用异常处理器来处理请求之前,它会对请求做一些前置处理工作,其中包括将响应状态码设置为500,并清空当前所有响应内容等。如果我们利用指定的ExceptionHandlerOptions对象的ExceptionHandlingPath属性设置了一个重定向路径,它会将该路径设置为当前请求的路径。除了包含前面代码片段的这些操作,ExceptionHandlerMiddleware中间件实际上还执行了一些其他的操作。
二、异常的传递与请求路径的恢复
由于ExceptionHandlerMiddleware中间件总是利用一个作为异常处理器的RequestDelegate对象来完成最终的异常处理工作,为了使后者能够得到抛出的异常,该中间件应该采用某种方式将抛出的异常传递给它。除此之外,由于ExceptionHandlerMiddleware中间件会改变当前请求的路径,当整个请求处理完成之后,它必须将请求路径恢复成原始状态,否则前置的中间件就无法获取到正确的请求路径。
请求处理过程中抛出的异常和原始请求路径的恢复是通过相应的特性完成的。具体来说,传递这两者的特性分别通过IExceptionHandlerFeature接口和IExceptionHandlerPathFeature接口来表示。如下面的代码片段所示,后者继承前者,ExceptionHandlerFeature类型同时实现了这两个接口。
public interface IExceptionHandlerFeature { Exception Error { get; } } public interface IExceptionHandlerPathFeature : IExceptionHandlerFeature { string Path { get; } } public class ExceptionHandlerFeature : IExceptionHandlerPathFeature, { public Exception Error { get; set; } public string Path { get; set; } }
在ExceptionHandlerMiddleware中间件将代表当前请求的HttpContext上下文传递给处理器之前,它会按照如下所示的方式根据抛出的异常和原始请求路径创建一个Exception
HandlerFeature对象,该对象最终被添加到HttpContext上下文的特性集合之中。当整个请求处理流程完全结束之后,ExceptionHandlerMiddleware中间件会借助这个特性得到原始的请求路径,并将其重新应用到当前HttpContext上下文中。
public class ExceptionHandlerMiddleware { ... public async Task Invoke(HttpContext context) { try { await _next(context); } catch(Exception ex) { context.Response.StatusCode = 500; var feature = new ExceptionHandlerFeature() { Error = ex, Path = context.Request.Path, }; context.Features.Set<IExceptionHandlerFeature>(feature); context.Features.Set<IExceptionHandlerPathFeature>(feature); if (_options.ExceptionHandlingPath.HasValue) { context.Request.Path = _options.ExceptionHandlingPath; } RequestDelegate handler = _options.ExceptionHandler ?? _next; try { await handler(context); } finally { context.Request.Path = originalPath; } } } }
在进行异常处理时,我们可以从当前HttpContext上下文中提取ExceptionHandlerFeature特性对象,进而获取抛出的异常和原始请求路径。如下面的代码片段所示,我们利用HandleError方法来呈现一个定制的错误页面。在这个方法中,我们正是借助ExceptionHandlerFeature特性得到抛出的异常的,并将其类型、消息及堆栈追踪信息显示出来。
public class Program { public static void Main() { Host.CreateDefaultBuilder() .ConfigureWebHostDefaults(builder => builder .ConfigureServices(svcs => svcs.AddRouting()) .Configure(app => app .UseExceptionHandler("/error") .UseRouting() .UseEndpoints(endpoints => endpoints.MapGet("error", HandleErrorAsync)) .Run(context => Task.FromException(new InvalidOperationException("Manually thrown exception"))))) .Build() .Run(); static async Task HandleErrorAsync(HttpContext context) { context.Response.ContentType = "text/html"; var ex = context.Features.Get<IExceptionHandlerPathFeature>().Error; await context.Response.WriteAsync("<html><head><title>Error</title></head><body>"); await context.Response.WriteAsync($"<h3>{ex.Message}</h3>"); await context.Response.WriteAsync($"<p>Type: {ex.GetType().FullName}"); await context.Response.WriteAsync($"<p>StackTrace: {ex.StackTrace}"); await context.Response.WriteAsync("</body></html>"); } } }
在上面这个应用中,我们注册了一个模板为“error”的路由指向HandleError方法。对于通过调用UseExceptionHandler扩展方法注册的ExceptionHandlerMiddleware中间件来说,我们将该路径设置为异常处理路径。对于任意从浏览器发出的请求,都会得到下图所示的错误页面。
三、清除缓存
对于一个用于获取资源的GET请求来说,如果请求目标是一个相对稳定的资源,我们可以利用缓存避免相同资源的频繁获取和传输。对于作为资源提供者的Web应用来说,当它在处理请求的时候,除了将目标资源作为响应的主体内容,它还需要设置用于控制缓存的相关响应报头。由于缓存在大部分情况下只适用于成功状态的响应,如果服务端在处理请求过程中出现异常,之前设置的缓存报头是不应该出现在响应报文中的。对于ExceptionHandlerMiddleware中间件来说,清除缓存报头也是它负责的一项重要工作。
我们同样可以通过一个简单的实例来演示ExceptionHandlerMiddleware中间件针对缓存响应报头的清除。在如下所示的应用中,我们将针对请求的处理实现在ProcessAsync方法中,它有50%的可能会抛出异常。不论是返回正常的响应内容还是抛出异常,这个方法都会先设置一个Cache-Control的响应报头,并将缓存时间设置为1小时(Cache-Control: max-age=3600)。
public class Program { private static readonly Random _random = new Random(); public static void Main() { Host.CreateDefaultBuilder() .ConfigureWebHostDefaults(builder => builder.Configure(app => app .UseExceptionHandler(app2 => app2.Run(HandleAsync)) .Run(ProcessAsync))) .Build() .Run(); static Task HandleAsync(HttpContext context) => context.Response.WriteAsync("Error occurred!"); static async Task ProcessAsync(HttpContext context) { context.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue { MaxAge = TimeSpan.FromHours(1) }; if (_random.Next() % 2 == 0) { throw new InvalidOperationException("Manually thrown exception..."); } await context.Response.WriteAsync("Succeed..."); } } }
通过调用UseExceptionHandler扩展方法注册的ExceptionHandlerMiddleware中间件在处理异常时会响应一个内容为“Error occurred!”的字符串。如下所示的两个响应报文分别对应正常响应和抛出异常的情况,我们会发现程序中设置的缓存报头Cache-Control: max-age=3600只会出现在状态码为“200 OK”的响应中。在状态码为“500 Internal Server Error”的响应中,则会出现3个与缓存相关的报头(Cache-Control、Pragma和Expires),它们的目的都是禁止缓存或者将缓存标识为过期。(S1612)
HTTP/1.1 200 OK Date: Sat, 21 Sep 2019 11:25:27 GMT Server: Kestrel Cache-Control: max-age=3600 Content-Length: 10 Succeed...
HTTP/1.1 500 Internal Server Error Date: Sat, 21 Sep 2019 11:26:11 GMT Server: Kestrel Cache-Control: no-cache Pragma: no-cache Expires: -1 Content-Length: 15 Error occurred!
ExceptionHandlerMiddleware中间件针对缓存响应报头的清除体现在如下所示的代码片段中。可以看出,它通过调用HttpResponse对象的OnStarting方法注册了一个回调(ClearCacheHeaders),上述这3个缓存报头是在这个回调中设置的。除此之外,这个回调方法还会清除ETag报头。既然目标资源没有得到正常的响应,表示资源“签名”的ETag报头就不应该出现在响应报文中。
public class ExceptionHandlerMiddleware { ... public async Task Invoke(HttpContext context) { try { await _next(context); } catch (Exception ex) { ... context.Response.OnStarting(ClearCacheHeaders, context.Response); var handler = _options.ExceptionHandler ?? _next; await handler(context); } } private Task ClearCacheHeaders(object state) { var response = (HttpResponse)state; response.Headers[HeaderNames.CacheControl] = "no-cache"; response.Headers[HeaderNames.Pragma] = "no-cache"; response.Headers[HeaderNames.Expires] = "-1"; response.Headers.Remove(HeaderNames.ETag); return Task.CompletedTask; } }
ASP.NET Core错误处理中间件[1]: 呈现错误信息
ASP.NET Core错误处理中间件[2]: 开发者异常页面
ASP.NET Core错误处理中间件[3]: 异常处理器
ASP.NET Core错误处理中间件[4]: 响应状态码页面