ASP.NET Core错误处理中间件[2]: 开发者异常页面

呈现错误信息》通过几个简单的实例演示了如何呈现一个错误页面,该过程由3个对应的中间件来完成。下面先介绍用来呈现开发者异常页面的DeveloperExceptionPageMiddleware中间件,该中间件在捕捉到后续处理过程中抛出的异常之后会返回一个媒体类型为text/html的响应,后者在浏览器上会呈现一个错误页面。由于这是一个为开发者提供诊断信息的异常页面,所以可以将其称为开发者异常页面(Developer Exception Page)。该页面不仅会呈现异常的详细信息(类型、消息和跟踪堆栈等),还会出现与当前请求相关的上下文信息。如下所示的代码片段是DeveloperExceptionPageMiddleware中间件的定义。更多关于ASP.NET Core的文章请点这里]

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

    public Task Invoke(HttpContext context);
}

如上面的代码片段所示,当我们创建一个DeveloperExceptionPageMiddleware对象的时候需要以参数的形式提供一个IOptions<DeveloperExceptionPageOptions>对象,而DeveloperExceptionPageOptions对象携带着为这个中间件指定的配置选项,具体的配置选项体现在如下所示的两个属性(FileProvider和SourceCodeLineCount)上。

public class DeveloperExceptionPageOptions
{
    public IFileProvider FileProvider { get; set; }
    public int SourceCodeLineCount { get; set; }
}

一、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对象,它提供了当前的HttpContext上下文和抛出的异常。第二个参数表示的委托对象代表后续的异常操作,如果需要将抛出的异常分发给后续处理器做进一步处理,就需要显式地调用Func<ErrorContext, Task>对象。在如下所示的演示实例中,我们通过实现IDeveloperPageExceptionFilter接口定义了一个FakeExceptionFilter类型,并将其注册到依赖注入框架中。

public class Program
{
    public static void Main()
    {            
        Host.CreateDefaultBuilder()
            .ConfigureWebHostDefaults(builder => builder
                .ConfigureServices(svcs=>svcs.AddSingleton<IDeveloperPageExceptionFilter, FakeExceptionFilter>())
                .Configure(app => app
                    .UseDeveloperExceptionPage()
                    .Run(context => Task.FromException(new InvalidOperationException("Manually thrown exception...")))))
            .Build()
            .Run();
    }

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

在FakeExceptionFilter类型实现的HandleExceptionAsync方法仅在响应的主体内容中写入了一条简单的错误消息(Unhandled exception occurred!),并没有显式调用该方法的参数next代表的“后续异常处理器”,所以DeveloperExceptionPageMiddleware中间件默认提供的错误页面并不会呈现出来,取而代之的就是下图所示的由注册IDeveloperPageExceptionFilter定制的错误页面。(S1608)

16-7

二、显示编译异常信息

我们编写的ASP.NET Core应用会先编译成程序集,然后部署并启动执行,为什么运行过程中还会出现“编译异常”?从ASP.NET Core应用层面来说,如果采用预编译模式,也就是说我们部署的不是源代码而是编译好的程序集,运行过程中根本就不存在编译异常的说法。但是在一个ASP.NET Core MVC应用中,视图文件(.cshtml)是支持动态运行时编译(Runtime Compilation)的。我们可以直接部署视图源文件,应用在执行过程中是可以动态地将它们编译成程序集的。换句话说,由于视图文件支持动态编译,所以可以在部署环境下直接修改视图文件的内容。

对于DeveloperExceptionPageMiddleware中间件来说,如果抛出的是普通的运行时异常,它会将异常自身的详细信息和当前请求上下文信息以HTML文档的形式呈现出来,前面演示的实例已经很好地说明了这一点。如果应用在动态编译视图文件时出现了编译异常,最终呈现出来的错误页面将具有不同的结构和内容,可以通过一个简单的实例演示DeveloperExceptionPageMiddleware中间件针对编译异常的处理。

为了支持运行时编译,我们需要为应用添加针对NuGet包“Microsoft.AspNetCore.Mvc.Razor. RuntimeCompilation”的依赖,并通过修改项目文件(.csproj)将PreserveCompilationReferences属性设置为True,如下所示的代码片段是整个项目文件的定义。

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

我们通过如下所示的代码承载了一个ASP.NET Core MVC应用,并注册了DeveloperException
PageMiddleware中间件。为了支持针对Razor视图文件的运行时编译,在调用IServiceCollection接口的AddControllersWithViews扩展方法得到返回的IMvcBuilder对象之后,可以进一步调用该对象的AddRazorRuntimeCompilation扩展方法。

public class Program
{
    public static void Main()
    {
        Host.CreateDefaultBuilder()
            .ConfigureWebHostDefaults(builder => builder
                .ConfigureServices(svcs => svcs
                    .AddRouting()
                    .AddControllersWithViews()
                    .AddRazorRuntimeCompilation())
                .Configure(app => app
                    .UseDeveloperExceptionPage()
                    .UseRouting()
                    .UseEndpoints(endpoints => endpoints.MapControllers())))
        .Build()
        .Run();
    }
}

我们定义了如下所示的HomeController,它的Action方法Index会直接调用View方法将默认的视图呈现出来。根据约定,Action方法Index呈现出来的视图文件对应的路径应该是“~/views/home/index.cshtml”,我们为此在这个路径下创建了如下所示的视图文件。其中,Foobar是一个尚未被定义的类型。

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

~/views/home/index.cshtml:
@{ 
    var value = new Foobar();
}

当我们利用浏览器访问HomeController的Action方法Index时,应用会动态编译目标视图。由于视图文件中使用了一个未定义的类型,动态编译会失败,响应的错误信息会以下图所示的形式出现在浏览器上。可以看出,错误页面显示的内容和结构与前面演示的实例是完全不一样的,我们不仅可以从这个错误页面中得到导致编译失败的视图文件的路径“Views/Home/Index.cshtml”,还可以直接看到导致编译失败的那一行代码。不仅如此,这个错误页面还直接将参与编译的源代码(不是定义在.cshtml文件中的原始代码,而是经过转换处理生成的C#代码)呈现出来。毫无疑问,如此详尽的错误页面对于开发人员的纠错是非常有价值的。

16-8

一般来说,动态编译的过程如下:先将源代码(类似于.cshtml这样的模板文件)转换成针对某种 .NET语言(如C#)的代码,然后进一步编译成IL代码。动态编译过程中抛出的异常类型一般会实现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对象承载着一些描述编译错误的诊断信息。我们不仅可以借助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; } 
    ...
}

从图16-8可以看出,错误页面会直接将导致编译失败的相关源代码显示出来。具体来说,它不仅将直接导致失败的源代码实现出来,还显示前后相邻的源代码。至于相邻源代码应该显示多少行,实际上是通过配置选项DeveloperExceptionPageOptions的SourceCodeLineCount属性控制的。

public class Program
{
    public static void Main()
    {
        var options = new DeveloperExceptionPageOptions { SourceCodeLineCount = 3 };
        Host.CreateDefaultBuilder()
            .ConfigureWebHostDefaults(builder => builder
                .ConfigureServices(svcs => svcs
                    .AddRouting()
                    .AddControllersWithViews()
                    .AddRazorRuntimeCompilation())
                .Configure(app => app
                    .UseDeveloperExceptionPage(options)
                    .UseRouting()
                    .UseEndpoints(endpoints => endpoints.MapControllers())))
        .Build()
        .Run();
    }
}

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

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

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

16-9

三、DeveloperExceptionPageMiddleware

下面从DeveloperExceptionPageMiddleware类型的实现逻辑对该中间件针对异常页面的呈现做进一步讲解。如下所示的代码片段只保留了DeveloperExceptionPageMiddleware类型的核心代码,我们可以看到它的构造函数中注入了用来提供配置选项的IOptions<DeveloperExceptionPage
Options>对象和一组IDeveloperPageExceptionFilter对象。

public class DeveloperExceptionPageMiddleware
{
    private readonly RequestDelegate _next;
    private readonly DeveloperExceptionPageOptions _options;
    private readonly Func<ErrorContext, Task> _exceptionHandler;

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

        _next = next;
        _options = options.Value;
        _exceptionHandler = context => context.Exception is ICompilationException
          ? DisplayCompilationException()
          : DisplayRuntimeException();
        ...

        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)
        {
            context.Response.Clear();
            context.Response.StatusCode = 500;
            await _exceptionHandler(new ErrorContext(context, ex));
            throw;
        }
    }
    private Task DisplayCompilationException();
    private Task DisplayRuntimeException();
}

被DeveloperExceptionPageMiddleware中间件用来作为异常处理器的是一个Func<ErrorContext, Task>对象,通过字段_exceptionHandler表示。当处理器在处理异常的时候,它会先调用注入的IDeveloperPageExceptionFilter对象,最后调用DisplayRuntimeException方法或者DisplayCompilation
Exception方法来呈现“开发者异常页面”。如果某个注册的IDeveloperPageExceptionFilter阻止了后续的异常处理,整个处理过程将会就此中止。

在Invoke方法中,DeveloperExceptionPageMiddleware中间件会直接将当前请求分发给后续的管道进行处理。如果抛出异常,它会根据该异常对象和当前HttpContext上下文创建一个ErrorContext对象,并将其作为参数调用作为异常处理器的Func<ErrorContext, Task>委托对象。该中间件最终会回复一个状态码为“500 Internal Server Error”的响应。

我们一般调用IApplicationBuilder 接口的如下所示的两个UseDeveloperExceptionPage扩展方法来注册DeveloperExceptionPageMiddleware中间件。我们可以利用作为配置选项的DeveloperExceptionPageOptions对象指定一个提供源文件的IFileProvider对象,也可以利用这个配置选项来控制导致异常源代码的前后行数。

public static class DeveloperExceptionPageExtensions
{    
    public static IApplicationBuilder UseDeveloperExceptionPage(this IApplicationBuilder app)
        => app.UseMiddleware<DeveloperExceptionPageMiddleware>();

    public static IApplicationBuilder UseDeveloperExceptionPage(this IApplicationBuilder app,DeveloperExceptionPageOptions options)
        =>app.UseMiddleware<DeveloperExceptionPageMiddleware>(Options.Create(options));
}

ASP.NET Core错误处理中间件[1]: 呈现错误信息
ASP.NET Core错误处理中间件[2]: 开发者异常页面
ASP.NET Core错误处理中间件[3]: 异常处理器
ASP.NET Core错误处理中间件[4]: 响应状态码页面

posted @ 2021-01-19 10:15  Artech  阅读(1179)  评论(0编辑  收藏  举报