1. 程式人生 > >ASP.NET Core錯誤處理中介軟體[3]: 異常處理器

ASP.NET Core錯誤處理中介軟體[3]: 異常處理器

DeveloperExceptionPageMiddleware中介軟體錯誤頁面可以呈現丟擲的異常和當前請求上下文的詳細資訊,以輔助開發人員更好地進行糾錯診斷工作。ExceptionHandlerMiddleware中介軟體則主要面向終端使用者,我們可以利用它來顯示一個友好的定製化錯誤頁面。更多關於ASP.NET Core的文章請點這裡]

一、ExceptionHandlerMiddleware

由於ExceptionHandlerMiddleware中介軟體可以使用指定的RequestDelegate物件來作為異常處理器,所以我們可以將它視為一個“萬能”的異常處理方案。按照慣例,下面先介紹ExceptionHandlerMiddleware型別的定義。

public class ExceptionHandlerMiddleware
{    
    public ExceptionHandlerMiddleware(RequestDelegate next, ILoggerFactory loggerFactory, IOptions<ExceptionHandlerOptions> options, DiagnosticListener diagnosticListener);  
    public Task Invoke(HttpContext context);
}

public class ExceptionHandlerOptions
{
    public RequestDelegate ExceptionHandler { get; set; }
    public PathString ExceptionHandlingPath { get; set; }
}

與DeveloperExceptionPageMiddleware類似,在建立一個ExceptionHandlerMiddleware物件時同樣需要提供一個攜帶配置選項的物件,從上面的程式碼片段可以看出,配置選項由一個ExceptionHandlerOptions物件承載。一個ExceptionHandlerOptions物件通過其ExceptionHandler屬性提供了一個作為異常處理器的RequestDelegate物件。如果希望應用在發生異常後自動重定向到某個指定的路徑,該路徑就可以利用ExceptionHandlingPath屬性來指定。我們一般呼叫IApplicationBuilder介面的UseExceptionHandler擴充套件方法來註冊ExceptionHandlerMiddleware中介軟體,這些過載的UseExceptionHandler擴充套件方法會採用如下方式完成中介軟體的註冊工作。

public static class ExceptionHandlerExtensions
{
    public static IApplicationBuilder UseExceptionHandler(this IApplicationBuilder app)
        => app.UseMiddleware<ExceptionHandlerMiddleware>();

    public static IApplicationBuilder UseExceptionHandler(this IApplicationBuilder app, ExceptionHandlerOptions options) 
        => app.UseMiddleware<ExceptionHandlerMiddleware>(Options.Create(options));

    public static IApplicationBuilder UseExceptionHandler(this IApplicationBuilder app, string errorHandlingPath)
        =>app.UseExceptionHandler(new ExceptionHandlerOptions
        {
            ExceptionHandlingPath = new PathString(errorHandlingPath)
        });

    public static IApplicationBuilder UseExceptionHandler(this IApplicationBuilder app, Action<IApplicationBuilder> configure)
    {
        IApplicationBuilder newBuilder = app.New();
        configure(newBuilder);

        return app.UseExceptionHandler(new ExceptionHandlerOptions
        {
            ExceptionHandler = newBuilder.Build()
        });
    }     
}

ExceptionHandlerMiddleware中介軟體處理請求的本質如下:在後續請求處理過程中出現異常的情況下,採用註冊的異常處理器來處理當前請求,這個異常處理器就是RequestDelegate物件。該中介軟體採用的請求處理邏輯大體上可以通過如下所示的程式碼片段來體現。

public class ExceptionHandlerMiddleware
{
    private RequestDelegate _next;
    private ExceptionHandlerOptions _options;

    public ExceptionHandlerMiddleware(RequestDelegate next, IOptions<ExceptionHandlerOptions> options,...)
    {
        _next  = next;
        _options  = options.Value;
        ...
    }

    public async Task Invoke(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch 
        {
            context.Response.StatusCode = 500;
            context.Response.Clear();
            if (_options.ExceptionHandlingPath.HasValue)
            {
                context.Request.Path = _options.ExceptionHandlingPath;
            }
            var handler = _options.ExceptionHandler ?? _next;
            await handler(context);
        }
    }
}

如上面的程式碼片段所示,如果後續的請求處理過程中出現異常,ExceptionHandlerMiddleware中介軟體會利用指定的作為異常處理器的RequestDelegate物件來完成最終的請求處理工作。如果建立ExceptionHandlerMiddleware物件時提供的ExceptionHandlerOptions物件攜帶了一個RequestDelegate物件,那麼它將作為最終使用的異常處理器,否則作為異常處理器的實際上就是後續的中介軟體。換句話說,如果沒有通過ExceptionHandlerOptions物件顯式指定一個異常處理器,ExceptionHandlerMiddleware中介軟體會在後續管道處理請求丟擲異常的情況下將請求再次傳遞給後續管道。

在ExceptionHandlerMiddleware中介軟體利用異常處理器來處理請求之前,它會對請求做一些前置處理工作,其中包括將響應狀態碼設定為500,並清空當前所有響應內容等。如果我們利用指定的ExceptionHandlerOptions物件的ExceptionHandlingPath屬性設定了一個重定向路徑,它會將該路徑設定為當前請求的路徑。除了包含前面程式碼片段的這些操作,ExceptionHandlerMiddleware中介軟體實際上還執行了一些其他的操作。

二、異常的傳遞與請求路徑的恢復

由於ExceptionHandlerMiddleware中介軟體總是利用一個作為異常處理器的RequestDelegate物件來完成最終的異常處理工作,為了使後者能夠得到丟擲的異常,該中介軟體應該採用某種方式將丟擲的異常傳遞給它。除此之外,由於ExceptionHandlerMiddleware中介軟體會改變當前請求的路徑,當整個請求處理完成之後,它必須將請求路徑恢復成原始狀態,否則前置的中介軟體就無法獲取到正確的請求路徑。

請求處理過程中丟擲的異常和原始請求路徑的恢復是通過相應的特性完成的。具體來說,傳遞這兩者的特性分別通過IExceptionHandlerFeature介面和IExceptionHandlerPathFeature介面來表示。如下面的程式碼片段所示,後者繼承前者,ExceptionHandlerFeature型別同時實現了這兩個介面。

public interface IExceptionHandlerFeature
{
    Exception Error { get; }
}

public interface IExceptionHandlerPathFeature : IExceptionHandlerFeature
{
    string Path { get; }
}

public class ExceptionHandlerFeature : IExceptionHandlerPathFeature, 
{
    public Exception Error { get; set; }
    public string Path { get; set; }
}

在ExceptionHandlerMiddleware中介軟體將代表當前請求的HttpContext上下文傳遞給處理器之前,它會按照如下所示的方式根據丟擲的異常和原始請求路徑建立一個Exception
HandlerFeature物件,該物件最終被新增到HttpContext上下文的特性集合之中。當整個請求處理流程完全結束之後,ExceptionHandlerMiddleware中介軟體會藉助這個特性得到原始的請求路徑,並將其重新應用到當前HttpContext上下文中。

public class ExceptionHandlerMiddleware
{
    ...
    public async Task Invoke(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch(Exception ex)
        {
            context.Response.StatusCode = 500;

            var feature = new ExceptionHandlerFeature()
            {
                Error = ex,
                Path = context.Request.Path,
            };
            context.Features.Set<IExceptionHandlerFeature>(feature);
            context.Features.Set<IExceptionHandlerPathFeature>(feature);

            if (_options.ExceptionHandlingPath.HasValue)
             {
                context.Request.Path = _options.ExceptionHandlingPath;
             }
            RequestDelegate handler = _options.ExceptionHandler ?? _next;

            try
            {
                await handler(context);
            }
            finally
            {
                context.Request.Path = originalPath;
            }
        }
    }
}

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

public class Program
{
    public static void Main()
    {
        Host.CreateDefaultBuilder()
            .ConfigureWebHostDefaults(builder => builder
            .ConfigureServices(svcs => svcs.AddRouting())
            .Configure(app => app
                .UseExceptionHandler("/error")
                .UseRouting()
                .UseEndpoints(endpoints => endpoints.MapGet("error", HandleErrorAsync))
                .Run(context => Task.FromException(new InvalidOperationException("Manually thrown exception")))))
            .Build()
            .Run();

        static async Task HandleErrorAsync(HttpContext context)
        {
            context.Response.ContentType = "text/html";
            var ex = context.Features.Get<IExceptionHandlerPathFeature>().Error;

            await context.Response.WriteAsync("<html><head><title>Error</title></head><body>");
            await context.Response.WriteAsync($"<h3>{ex.Message}</h3>");
            await context.Response.WriteAsync($"<p>Type: {ex.GetType().FullName}");
            await context.Response.WriteAsync($"<p>StackTrace: {ex.StackTrace}");
            await context.Response.WriteAsync("</body></html>");
        }
    }
}

在上面這個應用中,我們註冊了一個模板為“error”的路由指向HandleError方法。對於通過呼叫UseExceptionHandler擴充套件方法註冊的ExceptionHandlerMiddleware中介軟體來說,我們將該路徑設定為異常處理路徑。對於任意從瀏覽器發出的請求,都會得到下圖所示的錯誤頁面。

三、清除快取

對於一個用於獲取資源的GET請求來說,如果請求目標是一個相對穩定的資源,我們可以利用快取避免相同資源的頻繁獲取和傳輸。對於作為資源提供者的Web應用來說,當它在處理請求的時候,除了將目標資源作為響應的主體內容,它還需要設定用於控制快取的相關響應報頭。由於快取在大部分情況下只適用於成功狀態的響應,如果服務端在處理請求過程中出現異常,之前設定的快取報頭是不應該出現在響應報文中的。對於ExceptionHandlerMiddleware中介軟體來說,清除快取報頭也是它負責的一項重要工作。

我們同樣可以通過一個簡單的例項來演示ExceptionHandlerMiddleware中介軟體針對快取響應報頭的清除。在如下所示的應用中,我們將針對請求的處理實現在ProcessAsync方法中,它有50%的可能會丟擲異常。不論是返回正常的響應內容還是丟擲異常,這個方法都會先設定一個Cache-Control的響應報頭,並將快取時間設定為1小時(Cache-Control: max-age=3600)。

public class Program
{
    private static readonly Random _random = new Random();
    public static void Main()
    {
        Host.CreateDefaultBuilder()
            .ConfigureWebHostDefaults(builder => builder.Configure(app => app
                .UseExceptionHandler(app2 => app2.Run(HandleAsync))
                .Run(ProcessAsync)))
            .Build()
            .Run();

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

        static async Task ProcessAsync(HttpContext context)
        {
            context.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue
            {
                MaxAge = TimeSpan.FromHours(1)
            };

            if (_random.Next() % 2 == 0)
            {
                throw new InvalidOperationException("Manually thrown exception...");
            }
            await context.Response.WriteAsync("Succeed...");
        }
    }
}

通過呼叫UseExceptionHandler擴充套件方法註冊的ExceptionHandlerMiddleware中介軟體在處理異常時會響應一個內容為“Error occurred!”的字串。如下所示的兩個響應報文分別對應正常響應和丟擲異常的情況,我們會發現程式中設定的快取報頭Cache-Control: max-age=3600只會出現在狀態碼為“200 OK”的響應中。在狀態碼為“500 Internal Server Error”的響應中,則會出現3個與快取相關的報頭(Cache-Control、Pragma和Expires),它們的目的都是禁止快取或者將快取標識為過期。(S1612)

HTTP/1.1 200 OK
Date: Sat, 21 Sep 2019 11:25:27 GMT
Server: Kestrel
Cache-Control: max-age=3600
Content-Length: 10

Succeed...
HTTP/1.1 500 Internal Server Error
Date: Sat, 21 Sep 2019 11:26:11 GMT
Server: Kestrel
Cache-Control: no-cache
Pragma: no-cache
Expires: -1
Content-Length: 15

Error occurred!

ExceptionHandlerMiddleware中介軟體針對快取響應報頭的清除體現在如下所示的程式碼片段中。可以看出,它通過呼叫HttpResponse物件的OnStarting方法註冊了一個回撥(ClearCacheHeaders),上述這3個快取報頭是在這個回撥中設定的。除此之外,這個回撥方法還會清除ETag報頭。既然目標資源沒有得到正常的響應,表示資源“簽名”的ETag報頭就不應該出現在響應報文中。

public class ExceptionHandlerMiddleware
{
    ...
    public async Task Invoke(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (Exception ex)
        {
            ...
            context.Response.OnStarting(ClearCacheHeaders, context.Response);
            var handler = _options.ExceptionHandler ?? _next;
            await handler(context);
        }
    }

    private Task ClearCacheHeaders(object state)
    {
        var response = (HttpResponse)state;
        response.Headers[HeaderNames.CacheControl]  = "no-cache";
        response.Headers[HeaderNames.Pragma]  = "no-cache";
        response.Headers[HeaderNames.Expires]  = "-1";
        response.Headers.Remove(HeaderNames.ETag);
        return Task.CompletedTask;
    }
}