ASP.NET Core錯誤處理中間件[2]: 開發者異常頁面
- 2021 年 1 月 19 日
- 筆記
- .NET Core, Asp.Net Core, [01] 技術剖析, [02] 編程技巧, DeveloperExceptionPageMiddleware, ExceptionHandlerMiddleware, StatusCodePagesMiddleware
《呈現錯誤資訊》通過幾個簡單的實例演示了如何呈現一個錯誤頁面,該過程由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]: 響應狀態碼頁面




