ASP.NET Core 6框架揭祕範例演示[32]:錯誤頁面的N種呈現方式

2022-08-11 09:00:26

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