NuGet包“Microsoft.AspNetCore.Diagnostics”中提供了幾個與異常處理相關的中間件。當ASP.NET Core應用在處理請求過程中出現錯誤時,我們可以利用它們將原生的或者定制的錯誤信息作為響應內容發送給客戶端。在着重介紹這些中間件之前,下面先演示幾個簡單的實例,從而使讀者大致了解這些中間件的作用。[更多關於ASP.NET Core的文章請點這里]
一、顯示開發者異常頁面
如果ASP.NET Core應用在處理某個請求時出現異常,它一般會返回一個狀態碼為“500 Internal Server Error”的響應。為了避免一些敏感信息的外泄,詳細的錯誤信息並不會隨着響應發送給客戶端,所以客戶端只會得到一個很泛化的錯誤消息。以如下所示的程序為例,它處理每個請求時都會拋出一個InvalidOperationException類型的異常。
public class Program { public static void Main() { Host.CreateDefaultBuilder() .ConfigureWebHostDefaults(builder => builder.Configure(app => app.Run( context=> Task.FromException(new InvalidOperationException("Manually thrown exception..."))))) .Build() .Run(); } }
利用瀏覽器訪問這個應用總是會得到下圖所示的錯誤頁面。可以看出,這個頁面僅僅告訴我們目標應用當前無法正常處理本次請求,除了提供的響應狀態碼(“HTTP ERROR 500”),它並沒有提供任何有益於糾錯的輔助信息。
有人認為瀏覽器上雖然沒有顯示任何詳細的錯誤信息,但這並不意味着HTTP響應報文中也沒有攜帶任何詳細的出錯信息。實際上,針對通過瀏覽器發出的這個請求,服務端會返回如下這段HTTP響應報文。我們會發現響應報文根本沒有主體部分,有限的幾個報頭也並沒有承載任何與錯誤有關的信息。
HTTP/1.1 500 Internal Server Error Date: Wed, 18 Sep 2019 23:38:59 GMT Content-Length: 0 Server: Kestrel
由於應用並沒有中斷,瀏覽器上也並沒有顯示任何具有針對性的錯誤信息,開發人員在進行查錯和糾錯時如何准確定位到作為錯誤根源的那一行代碼?這個問題有兩種解決方案:一種是利用日志,因為ASP.NET Core應用在進行請求處理時出現的任何錯誤都會被寫入日志,所以可以通過注冊相應的ILoggerProvider對象來獲取寫入的錯誤日志,如可以注冊一個ConsoleLoggerProvider對象將日志直接輸出到宿主應用的控制台上。
另一種解決方案就是直接顯示一個錯誤頁面,由於這個頁面只是在開發環境給開發人員看的,所以可以將這個頁面稱為開發者異常頁面(Developer Exception Page)。開發者異常頁面的呈現是利用一個名為DeveloperExceptionPageMiddleware的中間件完成的,我們可以采用如下所示的方式調用IApplicationBuilder接口的UseDeveloperExceptionPage擴展方法來注冊這個中間件。
public class Program { public static void Main() { Host.CreateDefaultBuilder() .ConfigureServices(svcs => svcs.AddRouting()) .ConfigureWebHostDefaults(builder => builder.Configure(app => app .UseDeveloperExceptionPage() .UseRouting() .UseEndpoints(endpoints => endpoints.MapGet("{foo}/{bar}", HandleAsync)))) .Build() .Run(); static Task HandleAsync(HttpContext httpContext) => Task.FromException(new InvalidOperationException("Manually thrown exception...")); } }
一旦注冊了DeveloperExceptionPageMiddleware中間件,ASP.NET Core應用在處理請求過程中出現的異常信息就會以下圖所示的形式直接出現在瀏覽器上,我們可以在這個頁面中看到幾乎所有的錯誤信息,包括異常的類型、消息和堆棧信息等。
開發者異常頁面除了顯示與拋出的異常相關的信息,還會以圖16-3所示的形式顯示與當前請求上下文相關的信息,其中包括當前請求URL攜帶的所有查詢字符串、所有請求報頭、Cookie的內容和路由信息(終結點和路由參數)。如此詳盡的信息無疑會極大地幫助開發人員盡快找出錯誤的根源。
通過DeveloperExceptionPageMiddleware中間件呈現的錯誤頁面僅僅是供開發人員使用的,頁面上往往會攜帶一些敏感的信息,所以只有在開發環境才能注冊這個中間件,如下所示的代碼片段體現了Startup類型中針對DeveloperExceptionPageMiddleware中間件正確的注冊方式。
public class Startup { public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } } }
二、顯示定制異常頁面
DeveloperExceptionPageMiddleware中間件會將異常詳細信息和基於當前請求的上下文直接呈現在錯誤頁面中,這為開發人員的糾錯診斷提供了極大的便利。但是在生產環境下,我們傾向於為最終的用戶呈現一個定制的錯誤頁面,這可以通過注冊另一個名為ExceptionHandlerMiddleware的中間件來實現。顧名思義,這個中間件旨在提供一個異常處理器(ExceptionHandler)來處理拋出的異常。實際上,這個所謂的異常處理器就是一個RequestDelegate對象,ExceptionHandlerMiddleware中間件捕捉到拋出的異常后利用它來處理當前的請求。
下面以上面創建的這個總是會拋出一個 InvalidOperationException異常的應用為例進行介紹。我們按照如下形式調用IApplicationBuilder接口的UseExceptionHandler擴展方法注冊了ExceptionHandlerMiddleware中間件。這個擴展方法具有一個ExceptionHandlerOptions類型的參數,它的ExceptionHandler屬性返回的就是這個作為異常處理器的RequestDelegate對象。
public class Program { public static void Main() { var options = new ExceptionHandlerOptions { ExceptionHandler = HandleAsync }; Host.CreateDefaultBuilder() .ConfigureWebHostDefaults(builder => builder.Configure(app => app .UseExceptionHandler(options) .Run(context => Task.FromException(new InvalidOperationException("Manually thrown exception..."))))) .Build() .Run(); static Task HandleAsync(HttpContext context) => context.Response.WriteAsync("Unhandled exception occurred!"); }
如上面的代碼片段所示,這個作為異常處理器的RequestDelegate對象僅僅是將一個簡單的錯誤消息(Unhandled exception occurred!)作為響應的內容。當我們利用瀏覽器訪問該應用時,這個定制的錯誤消息會以下圖所示的形式直接呈現在瀏覽器上。
由於最終作為異常處理器的是一個RequestDelegate對象,而IApplicationBuilder對象具有根據注冊的中間件來創建這個委托對象的能力,所以我們可以根據異常處理的需求將相應的中間件注冊到某個IApplicationBuilder對象上,並最終利用它來創建作為異常處理器的RequestDelegate對象。如果異常處理需要通過一個或者多個中間件來完成,我們可以按照如下所示的形式調用另一個UseExceptionHandler方法重載。這個方法的參數類型為Action<IApplicationBuilder>,我們調用它的Run方法注冊了一個中間件來響應一個簡單的錯誤消息。
public class Program { public static void Main() { Host.CreateDefaultBuilder() .ConfigureWebHostDefaults(builder => builder.Configure(app => app .UseExceptionHandler(app2 => app2.Run(HandleAsync)) .Run(context => Task.FromException(new InvalidOperationException("Manually thrown exception..."))))) .Build() .Run(); static Task HandleAsync(HttpContext context) => context.Response.WriteAsync("Unhandled exception occurred!"); } }
上面這兩種異常處理的形式都體現在提供一個RequestDelegate的委托對象來處理拋出的異常並完成最終的響應。如果應用已經設置了一個錯誤頁面,並且這個錯誤頁面有一個固定的路徑,那么我們在進行異常處理的時候就沒有必要提供這個RequestDelegate對象,只需要重定向到錯誤頁面指向的路徑即可。這種采用服務端重定向的異常處理方式可以采用如下所示的形式調用另一個UseExceptionHandler方法重載來完成,這個方法的參數表示的就是重定向的目標路徑(“/error”),我們針對這個路徑注冊了一個路由來響應定制的錯誤消息。
public class Program { public static void Main() { Host.CreateDefaultBuilder() .ConfigureServices(svcs => svcs.AddRouting()) .ConfigureWebHostDefaults(builder => builder.Configure(app => app .UseExceptionHandler("/error") .UseRouting() .UseEndpoints(endpoints => endpoints.MapGet("error", HandleAsync)) .Run(context => Task.FromException(new InvalidOperationException("Manually thrown exception..."))))) .Build() .Run(); static Task HandleAsync(HttpContext context) => context.Response.WriteAsync("Unhandled exception occurred!"); } }
三、針對響應狀態碼定制錯誤頁面
由於Web應用采用HTTP通信協議,所以我們應該盡可能迎合HTTP標准,並將定義在協議規范中的語義應用到程序中。異常或者錯誤的語義表達在HTTP協議層面主要體現在響應報文的狀態碼上,具體來說,HTTP通信的錯誤大體分為如下兩種類型。
- 客戶端錯誤:表示因客戶端提供不正確的請求信息而導致服務器不能正常處理請求,響應狀態碼的范圍為400~499。
- 服務端錯誤:表示服務器在處理請求過程中因自身的問題而發生錯誤,響應狀態碼的范圍為500~599。
正是因為響應狀態碼是對錯誤或者異常語義最重要的表達,所以在很多情況下我們需要針對不同的響應狀態碼來定制顯示的錯誤信息。針對響應狀態碼對錯誤頁面的定制可以借助一個StatusCodePagesMiddleware類型的中間件來實現,我們可以調用IApplicationBuilder接口相應的擴展方法來注冊這個中間件。
DeveloperExceptionPageMiddleware中間件和ExceptionHandlerMiddleware中間件都是在后續請求處理過程中拋出異常的情況下才會被調用的,而StatusCodePagesMiddleware中間件被調用的前提是后續請求處理過程中產生一個錯誤的響應狀態碼(范圍為400~599)。如果僅僅希望顯示一個統一的錯誤頁面,我們可以按照如下所示的形式調用IApplicationBuilder接口的UseStatusCodePages擴展方法注冊這個中間件,傳入該方法的兩個參數分別表示響應采用的媒體類型和主體內容。
public class Program { public static void Main() { Host.CreateDefaultBuilder() .ConfigureWebHostDefaults(webBuilder => webBuilder.Configure(app => app .UseStatusCodePages("text/plain", "Error occurred ({0})") .Run(context => Task.Run(() => context.Response.StatusCode = 500)))) .Build() .Run(); } }
如上面的代碼片段所示,應用程序在處理請求時總是將響應狀態碼設置為“500”,所以最終的響應內容將由注冊的StatusCodePagesMiddleware中間件來提供。我們調用UseStatusCodePages方法時將響應的媒體類型設置為text/plain,並將一段簡單的錯誤消息作為響應的主體內容。值得注意的是,作為響應內容的字符串可以包含一個占位符({0}),StatusCodePagesMiddleware中間件最終會采用當前響應狀態碼來替換它。如果我們利用瀏覽器來訪問這個應用,得到的錯誤頁面如下圖16-5所示。
如果我們希望針對不同的錯誤狀態碼顯示不同的錯誤頁面,那么就需要將具體的請求處理邏輯實現在一個狀態碼錯誤處理器中,並最終提供給StatusCodePagesMiddleware中間件。這個所謂的狀態碼錯誤處理器體現為一個Func<StatusCodeContext, Task>類型的委托對象,作為輸入的StatusCodeContext對象是對HttpContext上下文的封裝,它同時承載着其他一些與錯誤處理相關的選項設置,我們將在本章后續部分對這個類型進行詳細介紹。
對於如下所示的應用來說,它在處理任意一個請求時總是隨機選擇400~599的一個整數來作為響應的狀態碼,所以客戶端返回的響應內容總是通過注冊的StatusCodePagesMiddleware中間件來提供。在調用另一個UseStatusCodePages方法重載時,我們為注冊的中間件指定一個Func<StatusCodeContext, Task>對象作為狀態碼錯誤處理器。
public class Program { private static readonly Random _random = new Random(); public static void Main() { Host.CreateDefaultBuilder() .ConfigureWebHostDefaults(webBuilder => webBuilder.Configure(app => app .UseStatusCodePages(HandleAsync) .Run(context => Task.Run(() => context.Response.StatusCode = _random.Next(400, 599))))) .Build() .Run(); static async Task HandleAsync(StatusCodeContext context) { var response = context.HttpContext.Response; if (response.StatusCode < 500) { await response.WriteAsync($"Client error ({response.StatusCode})"); } else { await response.WriteAsync($"Server error ({response.StatusCode})"); } } } }
我們指定的狀態碼錯誤處理器在處理請求時,根據響應狀態碼將錯誤分為客戶端錯誤和服務端錯誤兩種類型,並選擇針對性的錯誤消息作為響應內容。當我們利用瀏覽器訪問這個應用的時候,顯示的錯誤消息將以下圖所示的形式由響應狀態碼來決定。
在ASP.NET Core的世界里,針對請求的處理總是體現為一個RequestDelegate對象。如果請求的處理需要借助一個或者多個中間件來完成,就可以將它們注冊到IApplicationBuilder對象上,並利用該對象將中間件管道轉換成一個RequestDelegate對象。用於注冊StatusCodePagesMiddleware中間件的UseStatusCodePages方法還有另一個重載,它允許我們采用這種方式來創建一個RequestDelegate對象來完成錯誤請求處理工作,所以上面演示的這個應用完全可以改寫成如下形式。
public class Program { private static readonly Random _random = new Random(); public static void Main() { Host.CreateDefaultBuilder() .ConfigureWebHostDefaults(webBuilder => webBuilder.Configure(app => app .UseStatusCodePages(app2 => app2.Run(HandleAsync)) .Run(context => Task.Run(() => context.Response.StatusCode = _random.Next(400, 599))))) .Build() .Run(); static async Task HandleAsync(HttpContext context) { var response = context.Response; if (response.StatusCode < 500) { await response.WriteAsync($"Client error ({response.StatusCode})"); } else { await response.WriteAsync($"Server error ({response.StatusCode})"); } } } }
ASP.NET Core錯誤處理中間件[1]: 呈現錯誤信息
ASP.NET Core錯誤處理中間件[2]: 開發者異常頁面
ASP.NET Core錯誤處理中間件[3]: 異常處理器
ASP.NET Core錯誤處理中間件[4]: 響應狀態碼頁面