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]: 響應狀態碼頁面

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章