ASP.NET Core 6框架揭秘实例演示[33]:异常处理高阶用法

NuGet包“Microsoft.AspNetCore.Diagnostics”中提供了几个与异常处理相关的中间件,我们可以利用它们将原生的或者定制的错误信息作为响应内容发送给客户端。《错误页面的N种呈现方式》演示了几个简单的实例使读者大致了解这些中间件的作用,现在我们来演示几个高阶用法。本文提供的示例演示已经同步到《ASP.NET Core 6框架揭秘-实例演示版》)

[S2108]利用IDeveloperPageExceptionFilter定制开发者异常页面 (源代码
[S2109]针对编译异常的处理(默认)(源代码
[S2110]针对编译异常的处理(定义源代码输出行数)(源代码
[S2111]利用IExceptionHandlerFeature特性提供错误信息(源代码
[S2112]清除缓存响应报头(源代码
[S2113]针对404响应的处理(源代码
[S2114]利用IStatusCodePagesFeature特性忽略异常处理(源代码

[2108]利用IDeveloperPageExceptionFilter定制开发者异常页面

DeveloperExceptionPageMiddleware中间件在默认情况下总是会呈现一个包含详细信息的错误页面,但是我们可以利用注册的IDeveloperPageExceptionFilter对象在呈现错误页面之前做一些额外的异常处理操作,甚至完全“接管”整个异常处理任务。IDeveloperPageExceptionFilter接口定义了如下所示的HandleExceptionAsync方法进行异常处理。

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

public class ErrorContext
{
    public HttpContext HttpContext { get; }
    public Exception 	Exception { get; }

    public ErrorContext(HttpContext httpContext, Exception exception) ;
}

HandleExceptionAsync方法定义了errorContext和next两个参数,前者提供的ErrorContext对象是对HttpContext上下文的封装,并利用Exception属性提供待处理的异常;后者提供的Func<ErrorContext, Task>委托代表后续的异常处理任务。如果某个IDeveloperPageExceptionFilter对象没有将异常处理任务向后分发,开发者处理页面将不会呈现出来。如下的演示实例通过实现IDeveloperPageExceptionFilter接口定义了一个FakeExceptionFilter类型,并将其注册为依赖服务。

using Microsoft.AspNetCore.Diagnostics;
var builder = WebApplication.CreateBuilder();
builder.Services.AddSingleton<IDeveloperPageExceptionFilter, FakeExceptionFilter>();
var app = builder.Build();
app.UseDeveloperExceptionPage();
app.MapGet("/", void () => throw new InvalidOperationException("Manually thrown exception..."));
app.Run();

public class FakeExceptionFilter : IDeveloperPageExceptionFilter
{
    public Task HandleExceptionAsync(ErrorContext errorContext, Func<ErrorContext, Task> next)
        => errorContext.HttpContext.Response.WriteAsync("Unhandled exception occurred!");
}

在FakeExceptionFilter类型实现的HandleExceptionAsync方法仅在响应的主体内容中写入了一条简单的错误消息(“Unhandled exception occurred!”),所以DeveloperExceptionPageMiddleware中间件默认提供的错误页面并不会呈现出来,取而代之的就是图1所示的由注册FakeExceptionFilter定制的错误页面。

image

图1 由注册IDeveloperPageExceptionFilter定制的错误页面

[2109]针对编译异常的处理(默认)

我们编写的ASP.NET应用会编译成程序集进行部署,为什么运行过程中还会出现“编译异常”呢?这是因为处理这种“预编译”模式,ASP.NET还支持运行时动态编译。以MVC应用为例,我们可以在运行时修改它的视图文件,这样的修改就会触发动态编译。如果修改的内容没法通过编译,就会抛出编译异常。DeveloperExceptionPageMiddleware中间件在处理编译异常的时候会在错误页面中呈现不同的内容。

我们接下来利用一个MVC应用来演示DeveloperExceptionPageMiddleware中间件针对编译异常的处理。为了支持运行动态编译,我们为MVC项目添加了针对 “Microsoft.AspNetCore.Mvc.Razor. RuntimeCompilation”这个NuGet包的依赖,并通过修改项目文件将PreserveCompilationReferences属性设置为True。

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net6</TargetFramework>
    <PreserveCompilationReferences>true</PreserveCompilationReferences>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="6.0.0" />
  </ItemGroup>
</Project>

如下所示演示程序注册了DeveloperExceptionPageMiddleware中间件。为了支持针对Razor视图文件的运行时编译,在调用AddControllersWithViews扩展方法得到返回的IMvcBuilder对象之后,我们进一步调用该对象的AddRazorRuntimeCompilation扩展方法。

var builder = WebApplication.CreateBuilder();
builder.Services.AddControllersWithViews().AddRazorRuntimeCompilation();
var app = builder.Build();
app.UseDeveloperExceptionPage();
app.MapControllers();
app.Run();

我们定义了如下所示的HomeController,它的Action方法Index会直接调用View方法将默认的视图呈现出来。根据约定,Action方法Index呈现出来的视图文件对应的路径应该是“~/views/home/index.cshtml”,我们先不提供这个视图文件的内容。

public class HomeController : Controller
{
    [HttpGet("/")]
    public IActionResult Index() => View();
}

我们这个MVC应用启动再将视图文件的内容定义成如下的形式,为了让动态编译失败,这里指定的Foobar类型其实根本不存在。

@{
    var value = new Foobar();
}

当我们利用浏览器请求根路径时,获得到如图2所示的错误页面。这个错误页面显示的内容和结构与前面演示的实例是完全不一样的,在这里我们不仅可以得到导致编译失败的视图文件的路径“Views/Home/Index.cshtml”,还可以看到导致编译失败的代码。这个错误页面还直接将参与编译的源代码呈现出来。

image

图2 显示在错误页面中的编译异常信息

[2110]针对编译异常的处理(定义源代码输出行数)

动态编译过程中抛出的异常类型一般会实现如下这个ICompilationException接口,该接口定义的CompilationFailures属性返回一个元素类型为CompilationFailure的集合。编译失败的相关信息被封装在一个CompilationFailure对象之中,我们可以利用它得到源文件的路径(SourceFilePath属性)和内容(SourceFileContent属性),以及源代码转换后交付编译的内容。如果在内容转换过程已经发生错误,在这种情况下的SourceFileContent属性可能返回Null。

public interface ICompilationException
{
    IEnumerable<CompilationFailure> CompilationFailures { get; }
}

public class CompilationFailure
{
    public string 				SourceFileContent {  get; }
    public string 				SourceFilePath {  get; }
    public string 				CompiledContent {  get; }
    public IEnumerable<DiagnosticMessage> 	Messages {  get; }
    ...
}

CompilationFailure类型的Messages属性返回一个元素类型为DiagnosticMessage的集合,DiagnosticMessage对象承载着一些描述编译错误的诊断信息。我们不仅可以借助该对象的相关属性得到描述编译错误的消息(Message和FormattedMessage属性),还可以得到发生编译错误所在源文件的路径(SourceFilePath)及范围,StartLine属性和StartColumn属性分别表示导致编译错误的源代码在源文件中开始的行与列。EndLine属性和EndColumn属性分别表示导致编译错误的源代码在源文件中结束的行与列(行数和列数分别从1与0开始计数)。

public class DiagnosticMessage
{
    public string 	SourceFilePath {  get; }
    public int 	StartLine {  get; }
    public int 	StartColumn {  get; }
    public int 	EndLine {  get; }
    public int 	EndColumn {  get; }

    public string 	Message {  get; }
    public string 	FormattedMessage {  get; }
    ...
}

从图21-8可以看出,错误页面会直接将导致编译失败的相关源代码显示出来。令我们更感到惊喜的是,它不仅将直接导致失败的源代码实现出来,还显示前后相邻的源代码。至于相邻源代码应该显示多少行,实际上是通过配置选项DeveloperExceptionPageOptions的SourceCodeLineCount属性控制的,而源文件的读取则是由该配置选项的FileProvider属性提供的IFileProvider对象完成的。

var builder = WebApplication.CreateBuilder();
builder.Services.AddControllersWithViews().AddRazorRuntimeCompilation();
var app = builder.Build();
app.UseDeveloperExceptionPage(new DeveloperExceptionPageOptions {  SourceCodeLineCount = 3});
app.MapControllers();
app.Run();

对于前面演示的这个实例来说,如果将前后相邻的三行代码显示在错误页面上,我们可以采用如上所示的方式为DeveloperExceptionPageMiddleware中间件指定DeveloperExceptionPageOptions配置选项,并将它的SourceCodeLineCount属性设置为3。我们可以将视图文件(index.cshtml)改写成如下所示的形式,在导致编译失败的那一行代码前后分别添加4行代码。

1:
2:
3:
4:
5:@{ var value = new Foobar();}
6:
7:
8:
9:

对于定义在视图文件中的9行代码,根据在注册DeveloperExceptionPageMiddleware中间件时指定的规则,最终显示在错误页面上的应该是第2行至第8行。如果利用浏览器访问相同的地址,这7行代码会以图3所示的形式出现在错误页面上。如果我们没有对SourceCodeLineCount属性做显式设置,它的默认值为6。

image

图3 根据设置显示相邻源代码

[2111]利用IExceptionHandlerFeature特性提供错误信息

在ExceptionHandlerMiddleware中间件将代表当前请求的HttpContext上下文传递给处理器之前,它会按照如下所示的方式创建一个ExceptionHandlerFeature特性并附着到当前HttpContext上下文中中。当整个请求处理流程完全结束之后,该中间件还会将请求路径恢复成原始值,以免对前置中间件的后续处理造成影响。

public class ExceptionHandlerMiddleware
{
    …
    public async Task Invoke(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (Exception ex)
        {
            var edi = ExceptionDispatchInfo.Capture(ex);
            var originalPath = context.Request.Path;
            try
            {
                var feature = new ExceptionHandlerFeature()
                {
                    Error = ex,
                    Path = originalPath,
                    Endpoint = context.GetEndpoint(),
                    RouteValues = context.Features.Get<IRouteValuesFeature>()?.RouteValues
                };
                context.Features.Set<IExceptionHandlerFeature>(feature);
                context.Features.Set<IExceptionHandlerPathFeature>(feature);

                context.Response.StatusCode = 500;
                context.Response.Clear();
                if (_options.ExceptionHandlingPath.HasValue)
                {
                    context.Request.Path = _options.ExceptionHandlingPath;
                }
                var handler = _options.ExceptionHandler ?? _next;
                await handler(context);

                if (context.Response.StatusCode == 404 && !_options.AllowStatusCode404Response)
                {
                    throw edi.SourceException;
                }
            }
            finally
            {
                context.Request.Path = originalPath;
            }
        }
    }
}

在进行异常处理时,我们可以从当前HttpContext上下文中提取ExceptionHandlerFeature特性对象,进而获取抛出的异常和原始请求路径。如下面的代码片段所示,我们利用HandleError方法来呈现一个定制的错误页面。在这个方法中,我们正是借助ExceptionHandlerFeature特性得到抛出的异常的,并将其类型、消息及堆栈追踪信息显示出来。

using Microsoft.AspNetCore.Diagnostics;

var app = WebApplication.Create();
app.UseExceptionHandler("/error");
app.MapGet("/error", HandleError);
app.MapGet("/", void () => throw new InvalidOperationException("Manually thrown exception"));
app.Run();

static IResult HandleError(HttpContext context)
{
    var ex = context.Features.Get<IExceptionHandlerPathFeature>()!.Error;
    var html = $@"
<html>
    <head><title>Error</title></head>
    <body>
        <h3>{ex.Message}</h3>
        <p>Type: {ex.GetType().FullName}</p>
        <p>StackTrace: {ex.StackTrace}</p>
    </body>
</html>";
    return Results.Content(html, "text/html");
}

上面演示程序为路径 “/error”注册了一个采用HandleError作为处理方法的终结点。注册的ExceptionHandlerMiddleware中间件将该“/error”作为重定向路径。那么针对根路径的请求将会得到图4所示的错误页面。

image

图4 定制的错误页面

[2112]清除缓存响应报头

由于相应缓存缓存在大部分情况下只适用于成功状态的响应,如果服务端在处理请求过程中出现异常,之前设置的缓存报头是不应该出现在响应报文中的。对于ExceptionHandlerMiddleware中间件来说,清除缓存报头也是它负责的一项重要工作。在如下所示的演示程序中,针对根路径的请求有50%的可能会抛出异常。不论是返回正常的响应内容还是抛出异常,这个方法都会先设置一个Cache-Control的响应报头,并将缓存时间设置为1小时(Cache-Control: max-age=3600)。注册的ExceptionHandlerMiddleware中间件在处理异常时会响应一个内容为“Error occurred!”的字符串。

using Microsoft.Net.Http.Headers;

var _random = new Random();
var app = WebApplication.Create();
app.UseExceptionHandler(app2 => app2.Run(httpContext => httpContext.Response.WriteAsync("Error occurred!")));
app.MapGet("/", (HttpResponse response) => {
    response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue
    {
        MaxAge = TimeSpan.FromHours(1)
    };

    if (_random.Next() % 2 == 0)
    {
        throw new InvalidOperationException("Manually thrown exception...");
    }
    return response.WriteAsync("Succeed...");
});
app.Run();

如下所示的两个响应报文分别对应正常响应和抛出异常的情况,我们会发现程序中设置的缓存报头Cache-Control: max-age=3600只会出现在状态码为“200 OK”的响应中。在状态码为“500 Internal Server Error”的响应中,则会出现三个与缓存相关的报头(Cache-Control、Pragma和Expires),它们的目的都是禁止缓存或者将缓存标识为过期(S2112)。

HTTP/1.1 200 OK
Date: Mon, 08 Nov 2021 12:47:55 GMT
Server: Kestrel
Cache-Control: max-age=3600
Content-Length: 10

Succeed...
HTTP/1.1 500 Internal Server Error
Date: Mon, 08 Nov 2021 12:48:00 GMT
Server: Kestrel
Cache-Control: no-cache,no-store
Expires: -1
Pragma: no-cache
Content-Length: 15

Error occurred!

[2113]针对404响应的处理

ExceptionHandlerOptions 配置选项的AllowStatusCode404Response属性则表示该中间件是否允许最终返回状态码为404的响应。该属性默认值为false,这意味着在默认情况下,为该中间件指定的异常处理器不能返回404响应,此时该中间件会将原始的异常抛出来。如果404响应就应该是最终的异常处理结果,我们必须将ExceptionHandlerOptions配置选项的AllowStatusCode404Response属性设置为True。

以如下的程序为例,我们为路径“/foo”和“/bar”注册了对应的终结点,针对它们的处理器最终都会抛出一个异常。我们将DeveloperExceptionPageMiddleware中间件注册到这两个路由分支上,采用的异常处理器都会将响应状态码设置为404。但是ExceptionHandlerOptions配置选项的AllowStatusCode404Response属性的是不同的,前者采用默认值False,后者显式设置为True。

var app = WebApplication.Create();
app.MapGet("/foo", BuildHandler(app, false));
app.MapGet("/bar", BuildHandler(app, true));
app.Run();

static RequestDelegate BuildHandler(IEndpointRouteBuilder endpoints, bool allowStatusCode404Response)
{
    var options = new ExceptionHandlerOptions
    {
        ExceptionHandler = httpContext =>
        {
            httpContext.Response.StatusCode = 404;
            return Task.CompletedTask;
        },
        AllowStatusCode404Response = allowStatusCode404Response
    };
    var app = endpoints.CreateApplicationBuilder();
    app
        .UseExceptionHandler(options)
        .Run(httpContext => Task.FromException(new InvalidOperationException("Manually thrown exception.")));
    return app.Build();
}

该演示程序启动之后,针对两个路由分支的路径的请求会得到不同的输出结果。如图5所示,针对路径“/foo”的请求返回依然是状态码为500的响应,异常处理器返回的404响应在针对路径“/bar”的请求中被正常返回了。image

图5 是否允许404响应

[2114]利用IStatusCodePagesFeature特性忽略异常处理

如果某些内容已经被写入响应的主体部分,或者响应的媒体类型已经被预先设置,StatusCodePagesMiddleware中间件就不会再执行任何错误处理操作。但是应用程序往往具有自身的异常处理策略,也许在某些情况下就应该回复一个状态码在400~599区间内的响应,该中间件就不应该对当前响应做任何干预的。为了解决这种情况,我们必须赋予后续中间件能够阻止StatusCodePagesMiddleware中间件进行错误处理的能力。这项能力是借助IStatusCodePagesFeature特性来实现的。如下面的代码片段所示,该接口定义了唯一的Enabled属性表示是否希望StatusCodePagesMiddleware中间件参与当前的异常处理。StatusCodePagesFeature类型是对该接口的默认实现,它的Enabled属性默认返回True。

public interface IStatusCodePagesFeature
{
    bool Enabled { get; set; }
}

public class StatusCodePagesFeature : IStatusCodePagesFeature
{
    public bool Enabled { get; set; } = true ;
}

如下面的代码片段所示,StatusCodePagesMiddleware中间件在将请求交付给后续管道处理之前,它会创建一个StatusCodePagesFeature特性并附着到当前HttpContext上下文上。后面的中间件如果希望StatusCodePagesMiddleware中间件能够“放行”,只需要将此特性的Enabled属性设置为False就可以了。

public class StatusCodePagesMiddleware
{
    ...
    public async Task Invoke(HttpContext context)
    {
        var feature = new StatusCodePagesFeature();
        context.Features.Set<IStatusCodePagesFeature>(feature);

        await _next(context);
        var response = context.Response;
        if ((response.StatusCode >= 400 && response.StatusCode <= 599) &&
            !response.ContentLength.HasValue &&  string.IsNullOrEmpty(response.ContentType) && feature.Enabled)
        {
            await _options.HandleAsync(new StatusCodeContext(context, _options, _next));
        }
    }
}

下面的演示程序将针对根路径“/”请求的处理实现在Process方法中,该方法会将响应状态码为“401 Unauthorized”。我们通过随机数让这个方法在50%的概率下将StatusCodePagesFeature特性的Enabled属性设置为False。注册的StatusCodePagesMiddleware中间件会直接将“Error occurred!”文本作为响应内容。

using Microsoft.AspNetCore.Diagnostics;

var random = new Random();
var app = WebApplication.Create();
app.UseStatusCodePages(HandleAsync);
app.MapGet("/", Process);
app.Run();

static Task HandleAsync(StatusCodeContext context) => context.HttpContext.Response.WriteAsync("Error occurred!");

void  Process(HttpContext context)
{
    context.Response.StatusCode = 401;
    if (random.Next() % 2 == 0)
    {
        context.Features.Get<IStatusCodePagesFeature>()!.Enabled = false;
    }
}

针对根路径的请求会得到如下两种不同的响应。没有主体内容的响应是通过Process方法产生的,这种情况发生在StatusCodePagesMiddleware中间件通过StatusCodePagesFeature特性被屏蔽的时候。有主体内容的响应则是Process方法和StatusCodePagesMiddleware中间件共同作用的结果(S2114)。

HTTP/1.1 401 Unauthorized
Date: Sat, 11 Sep 2021 03:07:20 GMT
Server: Kestrel
Content-Length: 15

Error occurred!
HTTP/1.1 401 Unauthorized
Date: Sat, 11 Sep 2021 03:07:34 GMT
Server: Kestrel
Content-Length: 0