由于ASP.NET是一個同時處理多個請求的Web應用框架,所以在處理某個請求過程中出現異常并不會導致整個應用的中止。出于安全方面的考量,為了避免敏感信息外泄,客戶端在默認情況下并不會得到詳細的出錯信息,這無疑會在開發過程中增加查錯和糾錯的難度。對于生產環境來說,我們也希望最終用戶能夠根據具體的錯誤類型得到具有針對性并且友好的錯誤消息。ASP.NET提供的相應的中間件可以幫助我們將定制化的錯誤信息呈現出來。[本文節選《ASP.NET Core 6框架揭秘》第21章]
目錄
[2101]開發者異常頁面的呈現(源代碼)
[2102]定制異常頁面的呈現(源代碼)
[2103]利用注冊的中間件處理異常(源代碼)
[2104]針對異常頁面的重定向(源代碼)
[2105]基于響應狀態碼錯誤頁面的呈現(設置響應內容模板)(源代碼)
[2106]基于響應狀態碼錯誤頁面的呈現(提供異常處理器)(源代碼)
[2107]基于響應狀態碼錯誤頁面的呈現(利用中間件創建異常處理器)(源代碼)
[2101]開發者異常頁面的呈現
如果ASP.NET應用在處理某個請求時出現異常,它一般會返回一個狀態碼為“500 Internal Server Error”的響應。為了避免一些敏感信息的外泄,客戶端只會得到一個很泛化的錯誤消息。以如下所示的程序為例,處理根路徑的請求時都會拋出一個InvalidOperationException類型的異常。
var?app?=?WebApplication.Create();
app.MapGet("/",
void?()?=>?throw?new?InvalidOperationException("Manually?thrown?exception"));
app.Run();
利用瀏覽器訪問這個應用總是會得到圖1所示的錯誤頁面。可以看出這個頁面僅僅告訴我們目標應用當前無法正常處理本次請求,除了提供的響應狀態碼(“HTTP ERROR 500”),它并沒有提供任何有益于糾錯的輔助信息。
圖1 默認的錯誤頁面
有人認為瀏覽器上雖然沒有顯示任何詳細的錯誤信息,但這并不意味著HTTP響應報文中也沒有攜帶任何詳細的出錯信息。如下所示的服務端會返回的HTTP響應報文,該響應沒有主體內容,有限的幾個報頭也并沒有承載任何與錯誤有關的信息。
HTTP/1.1?500?Internal?Server?Error
Content-Length:?0
Date:?Sun,?07?Nov?2021?08:34:18?GMT
Server:?Kestrel
由于應用并沒有中斷,瀏覽器上也并沒有顯示任何具有針對性的錯誤信息,我們無法知道背后究竟出現了什么錯誤。這個問題有兩種解決方案:一種是利用日志,ASP.NET在處理請求過程中出現異常時,會發出相應的日志事件,我們可以注冊相應的ILoggerProvider對象將日志輸出到指定的渠道。另一種解決方案就是利用注冊的DeveloperExceptionPageMiddleware中間件顯示一個“開發者異常頁面(Developer Exception Page)”。
如下的演示程序調用IApplicationBuilder接口的UseDeveloperExceptionPage擴展方法來注冊了這個中間件。該程序注冊了一個路由模板為“{foo}/{bar}”的終結點,后者在處理請求時直接拋出異常。
var?app?=?WebApplication.Create();
app.UseDeveloperExceptionPage();
app.MapGet("{foo}/{bar}",
void?()?=>?throw?new?InvalidOperationException("Manually?thrown?exception"));
app.Run();
一旦注冊了DeveloperExceptionPageMiddleware中間件,ASP.NET應用在處理請求過程中出現的異常信息就會以圖2所示的形式直接出現在瀏覽器上,我們可以在這個頁面中看到幾乎所有的錯誤信息,包括異常的類型、消息和堆棧信息等。
圖2 開發者異常頁面(基本信息)
開發者異常頁面除了顯示與拋出的異常相關的信息,還會以圖3所示的形式顯示與當前請求上下文相關的信息,包括當前請求URL攜帶的所有查詢字符串、所有請求報頭、Cookie的內容和路由信息(終結點和路由參數)。如此詳盡的信息無疑會極大地幫助開發人員盡快找出錯誤的根源。由于此頁面上往往會攜帶一些敏感的信息,所以只有在開發環境才能注冊這個中間件。實際上Minimal API在開發環境會默認注冊這個中間件。
圖3 開發者異常頁面(詳細信息)
[2102]定制異常頁面的呈現
由于ExceptionHandlerMiddleware中間件直接利用提供的RequestDelegate委托來處理出現異常的請求,我們可以利用它呈現一個定制化的錯誤頁面。如下的演示程序通過調用IApplicationBuilder接口的UseExceptionHandler擴展方法注冊了這個中間件,提供的的ExceptionHandlerOptions配置選項指定了一個指向HandleErrorAsync方法的RequestDelegate委托作為異常處理器。
var?options?=?new?ExceptionHandlerOptions?{?ExceptionHandler?=?HandleErrorAsync?};
var?app?=?WebApplication.Create();
app.UseExceptionHandler(options);
app.MapGet("/",
void?()?
=>?throw?new?InvalidOperationException("Manually?thrown?exception"));
app.Run();static?Task?HandleErrorAsync(HttpContext?context)?
=>?context.Response.WriteAsync("Unhandled?exception?occurred!");
如上面的代碼片段所示,HandleErrorAsync方法僅僅是將一個簡單的錯誤消息(Unhandled exception occurred!)作為響應的內容。演示程序注冊了一個針對根路徑(“/”)的并且直接拋出異常的終結點,當我們利用瀏覽器訪問該終結點時,這個定制的錯誤消息會以圖4所示的形式直接呈現在瀏覽器上。
圖4 定制的錯誤頁面
[2103]利用注冊的中間件處理異常
由于ExceptionHandlerMiddleware中間件的異常處理器的是一個RequestDelegate委托,而IApplicationBuilder對象具有利用注冊的中間件來創建這個委托對象的能力,所以用于注冊該中間件的UseExceptionHandler擴展方法提供了一個參數類型為Action<IApplicationBuilder>重載。如下的演示程序調用了這個方法,在提供的作為參數的Action<IApplicationBuilder>委托中,我們調用了IApplicationBuilder接口的Run方法注冊了一個中間件來處理異常,訪問啟動后的程序同樣會得到如圖21-4的錯誤信息(S2103)。
var?app?=?WebApplication.Create();
app.UseExceptionHandler(app2?=>?app2.Run(HandleErrorAsync))
app.MapGet("/",
void?()?
=>?throw?new?InvalidOperationException("Manually?thrown?exception"));
app.Run();static?Task?HandleErrorAsync(HttpContext?context)??
=>?context.Response.WriteAsync("Unhandled?exception?occurred!");
[2104]針對異常頁面的重定向
如果應用已經提供了一個錯誤頁面,ExceptionHandlerMiddleware中間件在進行異常處理時可以直接重定向到該頁面就可以了。如下的演示程序采用這種方式調用了另一個UseExceptionHandler擴展方法重載,作為參數的字符串(“/error”)指定的就是錯誤頁面的路徑,訪問啟動后的程序同樣會得到如圖4的錯誤信息。
var?app?=?WebApplication.Create();
app.UseExceptionHandler("/error");
app.MapGet("/",
void?()?
=>?throw?new?InvalidOperationException("Manually?thrown?exception"));
app.MapGet("/error",?HandleErrorAsync);
app.Run();static?Task?HandleErrorAsync(HttpContext?context)??
=>?context.Response.WriteAsync("Unhandled?exception?occurred!");
[2105]基于響應狀態碼錯誤頁面的呈現(設置響應內容模板)
我們知道HTTP語義中的錯誤是由響應的狀態碼來表達的,涉及的錯誤大體劃分為如下兩種類型:
客戶端錯誤:表示因客戶端提供不正確的請求信息而導致服務器不能正常處理請求,響應狀態碼的范圍為400~499。
服務端錯誤:表示服務器在處理請求過程中因自身的問題而發生錯誤,響應狀態碼的范圍為500~599。
StatusCodePagesMiddleware中間件幫助我們針對響應狀態碼對錯誤頁面進行定制。該中間件只有在后續管道產生一個錯誤響應狀態碼(范圍為400~599)才會將錯誤頁面呈現出來。如下的演示程序通過調用UseStatusCodePages擴展方法注冊了這個中間件,作為參數的兩個字符串分別是響應的媒體類型和作為主體內容的模板,占位符“{0}”將被狀態碼進行填充。
var?app?=?WebApplication.Create();
app.UseStatusCodePages("text/plain",?"Error?occurred?({0})");
app.MapGet("/",?void?(HttpResponse?response)?=>?response.StatusCode?=?500);
app.Run();
我們針對根路徑(“/”)注冊了一個終結點,后者在處理請求時直接返回狀態碼為500的響應。應用啟動后,針對該路徑請求將會得到如圖5所示的錯誤頁面。
圖5 針對錯誤響應狀態碼定制的錯誤頁面
[2106]基于響應狀態碼錯誤頁面的呈現(提供異常處理器)
StatusCodePagesMiddleware中間件的錯誤處理器體現為一個Func<StatusCodeContext, Task>委托,作為輸入的StatusCodeContext是對當前HttpContext上下文的封裝。如下的演示程序定義了一個與此委托具有一致聲明的HandleErrorAsync來呈現錯誤頁面,UseStatusCodePages擴展方法指定的Func<StatusCodeContext, Task>委托指向這個方法。
using?Microsoft.AspNetCore.Diagnostics;
var?random?=?new?Random();
var?app?=?WebApplication.Create();
app.UseStatusCodePages(HandleErrorAsync);
app.MapGet("/",?void?(HttpResponse?response)?
=>?response.StatusCode?=?random.Next(400,599));
app.Run();static??Task?HandleErrorAsync(StatusCodeContext?context)
{var?response?=?context.HttpContext.Response;return?response.StatusCode?<?500??response.WriteAsync($"Client?error?({response.StatusCode})"):?response.WriteAsync($"Server?error?({response.StatusCode})");
}
我們針對根路徑(“/”)注冊的終結點會隨機返回一個狀態碼在(400,599)區間內的響應。用來處理錯誤的HandleErrorAsync方法會根據狀態碼所在的區間(400~499, 500~599)分別顯式“Client error”和“Server error”。應用啟動后,針對根路徑的請求會得到如圖6所示錯誤頁面。
圖6 針對錯誤響應狀態碼定制的錯誤頁面
[2107]基于響應狀態碼錯誤頁面的呈現(利用中間件創建異常處理器)
在ASP.NET的世界里,針對請求的處理總是體現為一個RequestDelegate委托,而IApplicationBuilder對象具有根據注冊的中間件構建這個委托的能力,所以 UseStatusCodePages方法還具有另一個將Action<IApplicationBuilder>委托作為參數的重載。如下的演示程序調用了這個重載,我們利用提供的委托調用了IApplicationBuilder對象的Run擴展方法注冊了一個中間件來處理異常(S2107)。
var?random?=?new?Random();
var?app?=?WebApplication.Create();
app.UseStatusCodePages(app2?=>?app2.Run(HandleErrorAsync));
app.MapGet("/",?void?(HttpResponse?response)?=>?response.StatusCode?=?random.Next(400,599));
app.Run();static??Task?HandleErrorAsync(HttpContext?context)
{var?response?=?context.Response;return?response.StatusCode?<?500??response.WriteAsync($"Client?error?({response.StatusCode})"):?response.WriteAsync($"Server?error?({response.StatusCode})");
}