ASP.NET Core 6框架揭秘實例演示[32]:錯誤頁面的集中呈現方式

由於ASP.NET是一個同時處理多個請求的Web應用框架,所以在處理某個請求過程中出現異常並不會導致整個應用的中止。出於安全方面的考量,為了避免敏感資訊外泄,客戶端在默認情況下並不會得到詳細的出錯資訊,這無疑會在開發過程中增加查錯和糾錯的難度。對於生產環境來說,我們也希望最終用戶能夠根據具體的錯誤類型得到具有針對性並且友好的錯誤消息。ASP.NET提供的相應的中間件可以幫助我們將訂製化的錯誤資訊呈現出來。本文提供的示例演示已經同步到《ASP.NET Core 6框架揭秘-實例演示版》)

目錄
[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」),它並沒有提供任何有益於糾錯的輔助資訊。

image

圖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所示的形式直接出現在瀏覽器上,我們可以在這個頁面中看到幾乎所有的錯誤資訊,包括異常的類型、消息和堆棧資訊等。

image

圖2 開發者異常頁面(基本資訊)

開發者異常頁面除了顯示與拋出的異常相關的資訊,還會以圖3所示的形式顯示與當前請求上下文相關的資訊,包括當前請求URL攜帶的所有查詢字元串、所有請求報頭、Cookie的內容和路由資訊(終結點和路由參數)。如此詳盡的資訊無疑會極大地幫助開發人員儘快找出錯誤的根源。由於此頁面上往往會攜帶一些敏感的資訊,所以只有在開發環境才能註冊這個中間件。實際上Minimal API在開發環境會默認註冊這個中間件。

image

圖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所示的形式直接呈現在瀏覽器上。

image

圖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所示的錯誤頁面。

image

圖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所示錯誤頁面。

image

圖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})");
}