1. 程式人生 > >ASP.NET Core錯誤處理中介軟體[2]: 開發者異常頁面

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)

二、顯示編譯異常資訊

我們編寫的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#程式碼)呈現出來。毫無疑問,如此詳盡的錯誤頁面對於開發人員的糾錯是非常有價值的。

一般來說,動態編譯的過程如下:先將原始碼(類似於.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。

三、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]: 響應狀態