ASP.NET Core 6框架揭秘實例演示[33]:異常處理高階用法

NuGet包「Microsoft.AspNetCore.Diagnostics」中提供了幾個與異常處理相關的中間件,我們可以利用它們將原生的或者訂製的錯誤資訊作為響應內容發送給客戶端。《錯誤頁面的N種呈現方式》演示了幾個簡單的實例使讀者大致了解這些中間件的作用,現在我們來演示幾個高階用法。本文提供的示例演示已經同步到《ASP.NET Core 6框架揭秘-實例演示版》)

[S2108]利用IDeveloperPageExceptionFilter訂製開發者異常頁面 (源程式碼
[S2109]針對編譯異常的處理(默認)(源程式碼
[S2110]針對編譯異常的處理(定義源程式碼輸出行數)(源程式碼
[S2111]利用IExceptionHandlerFeature特性提供錯誤資訊(源程式碼
[S2112]清除快取響應報頭(源程式碼
[S2113]針對404響應的處理(源程式碼
[S2114]利用IStatusCodePagesFeature特性忽略異常處理(源程式碼

[2108]利用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和next兩個參數,前者提供的ErrorContext對象是對HttpContext上下文的封裝,並利用Exception屬性提供待處理的異常;後者提供的Func<ErrorContext, Task>委託代表後續的異常處理任務。如果某個IDeveloperPageExceptionFilter對象沒有將異常處理任務向後分發,開發者處理頁面將不會呈現出來。如下的演示實例通過實現IDeveloperPageExceptionFilter介面定義了一個FakeExceptionFilter類型,並將其註冊為依賴服務。

using Microsoft.AspNetCore.Diagnostics;
var builder = WebApplication.CreateBuilder();
builder.Services.AddSingleton<IDeveloperPageExceptionFilter, FakeExceptionFilter>();
var app = builder.Build();
app.UseDeveloperExceptionPage();
app.MapGet("/", void () => throw new InvalidOperationException("Manually thrown exception..."));
app.Run();

public class FakeExceptionFilter : IDeveloperPageExceptionFilter
{
    public Task HandleExceptionAsync(ErrorContext errorContext, Func<ErrorContext, Task> next)
        => errorContext.HttpContext.Response.WriteAsync("Unhandled exception occurred!");
}

在FakeExceptionFilter類型實現的HandleExceptionAsync方法僅在響應的主體內容中寫入了一條簡單的錯誤消息(「Unhandled exception occurred!」),所以DeveloperExceptionPageMiddleware中間件默認提供的錯誤頁面並不會呈現出來,取而代之的就是圖1所示的由註冊FakeExceptionFilter訂製的錯誤頁面。

image

圖1 由註冊IDeveloperPageExceptionFilter訂製的錯誤頁面

[2109]針對編譯異常的處理(默認)

我們編寫的ASP.NET應用會編譯成程式集進行部署,為什麼運行過程中還會出現「編譯異常」呢?這是因為處理這種「預編譯」模式,ASP.NET還支援運行時動態編譯。以MVC應用為例,我們可以在運行時修改它的視圖文件,這樣的修改就會觸發動態編譯。如果修改的內容沒法通過編譯,就會拋出編譯異常。DeveloperExceptionPageMiddleware中間件在處理編譯異常的時候會在錯誤頁面中呈現不同的內容。

我們接下來利用一個MVC應用來演示DeveloperExceptionPageMiddleware中間件針對編譯異常的處理。為了支援運行動態編譯,我們為MVC項目添加了針對 「Microsoft.AspNetCore.Mvc.Razor. RuntimeCompilation」這個NuGet包的依賴,並通過修改項目文件將PreserveCompilationReferences屬性設置為True。

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net6</TargetFramework>
    <PreserveCompilationReferences>true</PreserveCompilationReferences>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="6.0.0" />
  </ItemGroup>
</Project>

如下所示演示程式註冊了DeveloperExceptionPageMiddleware中間件。為了支援針對Razor視圖文件的運行時編譯,在調用AddControllersWithViews擴展方法得到返回的IMvcBuilder對象之後,我們進一步調用該對象的AddRazorRuntimeCompilation擴展方法。

var builder = WebApplication.CreateBuilder();
builder.Services.AddControllersWithViews().AddRazorRuntimeCompilation();
var app = builder.Build();
app.UseDeveloperExceptionPage();
app.MapControllers();
app.Run();

我們定義了如下所示的HomeController,它的Action方法Index會直接調用View方法將默認的視圖呈現出來。根據約定,Action方法Index呈現出來的視圖文件對應的路徑應該是「~/views/home/index.cshtml」,我們先不提供這個視圖文件的內容。

public class HomeController : Controller
{
    [HttpGet("/")]
    public IActionResult Index() => View();
}

我們這個MVC應用啟動再將視圖文件的內容定義成如下的形式,為了讓動態編譯失敗,這裡指定的Foobar類型其實根本不存在。

@{
    var value = new Foobar();
}

當我們利用瀏覽器請求根路徑時,獲得到如圖2所示的錯誤頁面。這個錯誤頁面顯示的內容和結構與前面演示的實例是完全不一樣的,在這裡我們不僅可以得到導致編譯失敗的視圖文件的路徑「Views/Home/Index.cshtml」,還可以看到導致編譯失敗的程式碼。這個錯誤頁面還直接將參與編譯的源程式碼呈現出來。

image

圖2 顯示在錯誤頁面中的編譯異常資訊

[2110]針對編譯異常的處理(定義源程式碼輸出行數)

動態編譯過程中拋出的異常類型一般會實現如下這個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對象承載著一些描述編譯錯誤的診斷資訊。我們不僅可以藉助該對象的相關屬性得到描述編譯錯誤的消息(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; }
    ...
}

從圖21-8可以看出,錯誤頁面會直接將導致編譯失敗的相關源程式碼顯示出來。令我們更感到驚喜的是,它不僅將直接導致失敗的源程式碼實現出來,還顯示前後相鄰的源程式碼。至於相鄰源程式碼應該顯示多少行,實際上是通過配置選項DeveloperExceptionPageOptions的SourceCodeLineCount屬性控制的,而源文件的讀取則是由該配置選項的FileProvider屬性提供的IFileProvider對象完成的。

var builder = WebApplication.CreateBuilder();
builder.Services.AddControllersWithViews().AddRazorRuntimeCompilation();
var app = builder.Build();
app.UseDeveloperExceptionPage(new DeveloperExceptionPageOptions {  SourceCodeLineCount = 3});
app.MapControllers();
app.Run();

對於前面演示的這個實例來說,如果將前後相鄰的三行程式碼顯示在錯誤頁面上,我們可以採用如上所示的方式為DeveloperExceptionPageMiddleware中間件指定DeveloperExceptionPageOptions配置選項,並將它的SourceCodeLineCount屬性設置為3。我們可以將視圖文件(index.cshtml)改寫成如下所示的形式,在導致編譯失敗的那一行程式碼前後分別添加4行程式碼。

1:
2:
3:
4:
5:@{ var value = new Foobar();}
6:
7:
8:
9:

對於定義在視圖文件中的9行程式碼,根據在註冊DeveloperExceptionPageMiddleware中間件時指定的規則,最終顯示在錯誤頁面上的應該是第2行至第8行。如果利用瀏覽器訪問相同的地址,這7行程式碼會以圖3所示的形式出現在錯誤頁面上。如果我們沒有對SourceCodeLineCount屬性做顯式設置,它的默認值為6。

image

圖3 根據設置顯示相鄰源程式碼

[2111]利用IExceptionHandlerFeature特性提供錯誤資訊

在ExceptionHandlerMiddleware中間件將代表當前請求的HttpContext上下文傳遞給處理器之前,它會按照如下所示的方式創建一個ExceptionHandlerFeature特性並附著到當前HttpContext上下文中中。當整個請求處理流程完全結束之後,該中間件還會將請求路徑恢復成原始值,以免對前置中間件的後續處理造成影響。

public class ExceptionHandlerMiddleware
{
    …
    public async Task Invoke(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (Exception ex)
        {
            var edi = ExceptionDispatchInfo.Capture(ex);
            var originalPath = context.Request.Path;
            try
            {
                var feature = new ExceptionHandlerFeature()
                {
                    Error = ex,
                    Path = originalPath,
                    Endpoint = context.GetEndpoint(),
                    RouteValues = context.Features.Get<IRouteValuesFeature>()?.RouteValues
                };
                context.Features.Set<IExceptionHandlerFeature>(feature);
                context.Features.Set<IExceptionHandlerPathFeature>(feature);

                context.Response.StatusCode = 500;
                context.Response.Clear();
                if (_options.ExceptionHandlingPath.HasValue)
                {
                    context.Request.Path = _options.ExceptionHandlingPath;
                }
                var handler = _options.ExceptionHandler ?? _next;
                await handler(context);

                if (context.Response.StatusCode == 404 && !_options.AllowStatusCode404Response)
                {
                    throw edi.SourceException;
                }
            }
            finally
            {
                context.Request.Path = originalPath;
            }
        }
    }
}

在進行異常處理時,我們可以從當前HttpContext上下文中提取ExceptionHandlerFeature特性對象,進而獲取拋出的異常和原始請求路徑。如下面的程式碼片段所示,我們利用HandleError方法來呈現一個訂製的錯誤頁面。在這個方法中,我們正是藉助ExceptionHandlerFeature特性得到拋出的異常的,並將其類型、消息及堆棧追蹤資訊顯示出來。

using Microsoft.AspNetCore.Diagnostics;

var app = WebApplication.Create();
app.UseExceptionHandler("/error");
app.MapGet("/error", HandleError);
app.MapGet("/", void () => throw new InvalidOperationException("Manually thrown exception"));
app.Run();

static IResult HandleError(HttpContext context)
{
    var ex = context.Features.Get<IExceptionHandlerPathFeature>()!.Error;
    var html = $@"
<html>
    <head><title>Error</title></head>
    <body>
        <h3>{ex.Message}</h3>
        <p>Type: {ex.GetType().FullName}</p>
        <p>StackTrace: {ex.StackTrace}</p>
    </body>
</html>";
    return Results.Content(html, "text/html");
}

上面演示程式為路徑 「/error」註冊了一個採用HandleError作為處理方法的終結點。註冊的ExceptionHandlerMiddleware中間件將該「/error」作為重定向路徑。那麼針對根路徑的請求將會得到圖4所示的錯誤頁面。

image

圖4 訂製的錯誤頁面

[2112]清除快取響應報頭

由於相應快取快取在大部分情況下只適用於成功狀態的響應,如果服務端在處理請求過程中出現異常,之前設置的快取報頭是不應該出現在響應報文中的。對於ExceptionHandlerMiddleware中間件來說,清除快取報頭也是它負責的一項重要工作。在如下所示的演示程式中,針對根路徑的請求有50%的可能會拋出異常。不論是返回正常的響應內容還是拋出異常,這個方法都會先設置一個Cache-Control的響應報頭,並將快取時間設置為1小時(Cache-Control: max-age=3600)。註冊的ExceptionHandlerMiddleware中間件在處理異常時會響應一個內容為「Error occurred!」的字元串。

using Microsoft.Net.Http.Headers;

var _random = new Random();
var app = WebApplication.Create();
app.UseExceptionHandler(app2 => app2.Run(httpContext => httpContext.Response.WriteAsync("Error occurred!")));
app.MapGet("/", (HttpResponse response) => {
    response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue
    {
        MaxAge = TimeSpan.FromHours(1)
    };

    if (_random.Next() % 2 == 0)
    {
        throw new InvalidOperationException("Manually thrown exception...");
    }
    return response.WriteAsync("Succeed...");
});
app.Run();

如下所示的兩個響應報文分別對應正常響應和拋出異常的情況,我們會發現程式中設置的快取報頭Cache-Control: max-age=3600隻會出現在狀態碼為「200 OK」的響應中。在狀態碼為「500 Internal Server Error」的響應中,則會出現三個與快取相關的報頭(Cache-Control、Pragma和Expires),它們的目的都是禁止快取或者將快取標識為過期(S2112)。

HTTP/1.1 200 OK
Date: Mon, 08 Nov 2021 12:47:55 GMT
Server: Kestrel
Cache-Control: max-age=3600
Content-Length: 10

Succeed...
HTTP/1.1 500 Internal Server Error
Date: Mon, 08 Nov 2021 12:48:00 GMT
Server: Kestrel
Cache-Control: no-cache,no-store
Expires: -1
Pragma: no-cache
Content-Length: 15

Error occurred!

[2113]針對404響應的處理

ExceptionHandlerOptions 配置選項的AllowStatusCode404Response屬性則表示該中間件是否允許最終返回狀態碼為404的響應。該屬性默認值為false,這意味著在默認情況下,為該中間件指定的異常處理器不能返回404響應,此時該中間件會將原始的異常拋出來。如果404響應就應該是最終的異常處理結果,我們必須將ExceptionHandlerOptions配置選項的AllowStatusCode404Response屬性設置為True。

以如下的程式為例,我們為路徑「/foo」和「/bar」註冊了對應的終結點,針對它們的處理器最終都會拋出一個異常。我們將DeveloperExceptionPageMiddleware中間件註冊到這兩個路由分支上,採用的異常處理器都會將響應狀態碼設置為404。但是ExceptionHandlerOptions配置選項的AllowStatusCode404Response屬性的是不同的,前者採用默認值False,後者顯式設置為True。

var app = WebApplication.Create();
app.MapGet("/foo", BuildHandler(app, false));
app.MapGet("/bar", BuildHandler(app, true));
app.Run();

static RequestDelegate BuildHandler(IEndpointRouteBuilder endpoints, bool allowStatusCode404Response)
{
    var options = new ExceptionHandlerOptions
    {
        ExceptionHandler = httpContext =>
        {
            httpContext.Response.StatusCode = 404;
            return Task.CompletedTask;
        },
        AllowStatusCode404Response = allowStatusCode404Response
    };
    var app = endpoints.CreateApplicationBuilder();
    app
        .UseExceptionHandler(options)
        .Run(httpContext => Task.FromException(new InvalidOperationException("Manually thrown exception.")));
    return app.Build();
}

該演示程式啟動之後,針對兩個路由分支的路徑的請求會得到不同的輸出結果。如圖5所示,針對路徑「/foo」的請求返回依然是狀態碼為500的響應,異常處理器返回的404響應在針對路徑「/bar」的請求中被正常返回了。image

圖5 是否允許404響應

[2114]利用IStatusCodePagesFeature特性忽略異常處理

如果某些內容已經被寫入響應的主體部分,或者響應的媒體類型已經被預先設置,StatusCodePagesMiddleware中間件就不會再執行任何錯誤處理操作。但是應用程式往往具有自身的異常處理策略,也許在某些情況下就應該回復一個狀態碼在400~599區間內的響應,該中間件就不應該對當前響應做任何干預的。為了解決這種情況,我們必須賦予後續中間件能夠阻止StatusCodePagesMiddleware中間件進行錯誤處理的能力。這項能力是藉助IStatusCodePagesFeature特性來實現的。如下面的程式碼片段所示,該介面定義了唯一的Enabled屬性表示是否希望StatusCodePagesMiddleware中間件參與當前的異常處理。StatusCodePagesFeature類型是對該介面的默認實現,它的Enabled屬性默認返回True。

public interface IStatusCodePagesFeature
{
    bool Enabled { get; set; }
}

public class StatusCodePagesFeature : IStatusCodePagesFeature
{
    public bool Enabled { get; set; } = true ;
}

如下面的程式碼片段所示,StatusCodePagesMiddleware中間件在將請求交付給後續管道處理之前,它會創建一個StatusCodePagesFeature特性並附著到當前HttpContext上下文上。後面的中間件如果希望StatusCodePagesMiddleware中間件能夠「放行」,只需要將此特性的Enabled屬性設置為False就可以了。

public class StatusCodePagesMiddleware
{
    ...
    public async Task Invoke(HttpContext context)
    {
        var feature = new StatusCodePagesFeature();
        context.Features.Set<IStatusCodePagesFeature>(feature);

        await _next(context);
        var response = context.Response;
        if ((response.StatusCode >= 400 && response.StatusCode <= 599) &&
            !response.ContentLength.HasValue &&  string.IsNullOrEmpty(response.ContentType) && feature.Enabled)
        {
            await _options.HandleAsync(new StatusCodeContext(context, _options, _next));
        }
    }
}

下面的演示程式將針對根路徑「/」請求的處理實現在Process方法中,該方法會將響應狀態碼為「401 Unauthorized」。我們通過隨機數讓這個方法在50%的概率下將StatusCodePagesFeature特性的Enabled屬性設置為False。註冊的StatusCodePagesMiddleware中間件會直接將「Error occurred!」文本作為響應內容。

using Microsoft.AspNetCore.Diagnostics;

var random = new Random();
var app = WebApplication.Create();
app.UseStatusCodePages(HandleAsync);
app.MapGet("/", Process);
app.Run();

static Task HandleAsync(StatusCodeContext context) => context.HttpContext.Response.WriteAsync("Error occurred!");

void  Process(HttpContext context)
{
    context.Response.StatusCode = 401;
    if (random.Next() % 2 == 0)
    {
        context.Features.Get<IStatusCodePagesFeature>()!.Enabled = false;
    }
}

針對根路徑的請求會得到如下兩種不同的響應。沒有主體內容的響應是通過Process方法產生的,這種情況發生在StatusCodePagesMiddleware中間件通過StatusCodePagesFeature特性被屏蔽的時候。有主體內容的響應則是Process方法和StatusCodePagesMiddleware中間件共同作用的結果(S2114)。

HTTP/1.1 401 Unauthorized
Date: Sat, 11 Sep 2021 03:07:20 GMT
Server: Kestrel
Content-Length: 15

Error occurred!
HTTP/1.1 401 Unauthorized
Date: Sat, 11 Sep 2021 03:07:34 GMT
Server: Kestrel
Content-Length: 0