1. 程式人生 > >ASP.NET Core錯誤處理中介軟體[4]: 響應狀態碼錯誤頁面

ASP.NET Core錯誤處理中介軟體[4]: 響應狀態碼錯誤頁面

StatusCodePagesMiddleware中介軟體與ExceptionHandlerMiddleware中介軟體類似,它們都是在後續請求處理過程中“出錯”的情況下利用一個錯誤處理器來接收針對當前請求的處理。它們之間的差異在於對“錯誤”的認定上:ExceptionHandlerMiddleware中介軟體所謂的錯誤就是丟擲異常;StatusCodePagesMiddleware中介軟體則將400~599的響應狀態碼視為錯誤。更多關於ASP.NET Core的文章請點這裡]

目錄
一、StatusCodePagesMiddleware
二、阻止處理異常
三、UseStatusCodePages
四、UseStatusCodePagesWithRedirects
五、UseStatusCodePagesWithReExecute

一、StatusCodePagesMiddleware

如下面的程式碼片段所示,StatusCodePagesMiddleware中介軟體也採用“標準”的定義方式,針對它的配置選項通過一個對應的物件以Options模式的形式提供給它。

public class StatusCodePagesMiddleware
{
    public StatusCodePagesMiddleware(RequestDelegate next, IOptions<StatusCodePagesOptions> options);
    public Task Invoke(HttpContext context);
}

除了對錯誤的認定方式,StatusCodePagesMiddleware中介軟體和ExceptionHandlerMiddleware中介軟體對錯誤處理器的表達也不相同。ExceptionHandlerMiddleware中介軟體的處理器是一個RequestDelegate委託物件,而StatusCodePagesMiddleware中介軟體的處理器則是一個Func<StatusCodeContext, Task>委託物件。如下面的程式碼片段所示,配置選項StatusCodePagesOptions的唯一目的就是提供作為處理器的Func<StatusCodeContext, Task>物件。

public class StatusCodePagesOptions
{
    public Func<StatusCodeContext, Task> HandleAsync { get; set; }
}

一個RequestDelegate物件相當於一個Func<HttpContext, Task>型別的委託物件,而一個StatusCodeContext物件也是對一個HttpContext上下文的封裝,這兩個委託物件並沒有本質上的不同。如下面的程式碼片段所示,除了從StatusCodeContext物件中獲取當前HttpContext上下文,我們還可以通過其Next屬性得到一個RequestDelegate物件,並利用它將請求再次分發給後續中介軟體進行處理。StatusCodeContext物件的Options屬性返回建立 StatusCodePagesMiddleware中介軟體時指定的StatusCodePagesOptions物件。

public class StatusCodeContext
{
    public HttpContext HttpContext { get; }
    public RequestDelegate Next { get; }
    public StatusCodePagesOptions Options { get; }

    public StatusCodeContext(HttpContext context, StatusCodePagesOptions options, RequestDelegate next);
}

由於採用了針對響應狀態碼的錯誤處理策略,所以實現在StatusCodePagesMiddleware中介軟體的錯誤處理操作只會發生在當前響應狀態碼為400~599的情況下,如下所示的程式碼片段就體現了這一點。從下面給出的程式碼片段可以看出,StatusCodePagesMiddleware中介軟體除了會檢視當前響應狀態碼,還會檢視響應內容及媒體型別。如果響應報文已經包含響應內容或者設定了媒體型別,StatusCodePagesMiddleware中介軟體將不會執行任何操作,因為這正是後續中介軟體管道希望回覆給客戶端的響應,該中介軟體不應該再畫蛇添足。

public class StatusCodePagesMiddleware
{
    private RequestDelegate _next;
    private StatusCodePagesOptions _options;

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

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

StatusCodePagesMiddleware中介軟體對錯誤的處理非常簡單,它只需要從StatusCodePagesOptions物件中提取出作為錯誤處理器的Func<StatusCodeContext, Task>物件,然後建立一個StatusCodeContext物件作為輸入引數呼叫這個委託物件即可。

二、阻止處理異常

通過《呈現錯誤資訊》的內容我們知道,如果某些內容已經被寫入響應的主體部分,或者響應的媒體型別已經被預先設定,StatusCodePagesMiddleware中介軟體就不會再執行任何錯誤處理操作。由於應用程式往往具有自身的異常處理策略,它們可能會顯式地返回一個狀態碼為400~599的響應,在此情況下,StatusCodePagesMiddleware中介軟體是不應該對當前響應做任何干預的。從這個意義上來講,StatusCodePagesMiddleware中介軟體僅僅是作為一種後備的錯誤處理機制而已。

更進一步來講,如果後續的某個中介軟體返回了一個狀態碼為400~599的響應,並且這個響應只有報頭集合沒有主體(媒體型別自然也不會設定),那麼按照我們在上面給出的錯誤處理邏輯來看,StatusCodePagesMiddleware中介軟體還是會按照自己的策略來處理並響應請求。為了解決這種情況,我們必須賦予後續中介軟體能夠阻止StatusCodePagesMiddleware中介軟體進行錯誤處理的功能。

阻止StatusCodePagesMiddleware中介軟體進行錯誤處理的功能是藉助一個通過IStatusCodePagesFeature介面表示的特性來實現的。如下面的程式碼片段所示,IStatusCodePagesFeature介面定義了唯一的Enabled屬性,StatusCodePagesFeature型別是對該介面的預設實現,它的Enabled屬性預設返回True。

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

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

StatusCodePagesMiddleware中介軟體在將請求交付給後續管道之前,會建立一個StatusCodePagesFeature物件,並將其新增到當前HttpContext上下文的特性集合中。在最終決定是否執行錯誤處理操作的時候,它還會通過這個特性檢驗後續的某個中介軟體是否不希望其進行不必要的錯誤處理,如下所示的程式碼片段很好地體現了這一點。

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

下面通過一個簡單的例項來演示如何利用StatusCodePagesFeature特性來遮蔽StatusCodePagesMiddleware中介軟體。在如下所示的程式碼片段中,我們將針對請求的處理定義在ProcessAsync方法中,該方法會返回一個狀態碼為“401 Unauthorized”的響應。我們通過隨機數讓這個方法在50%的概率下利用StatusCodePagesFeature特性來阻止StatusCodePagesMiddleware中介軟體自身對錯誤的處理。我們通過呼叫UseStatusCodePages擴充套件方法註冊的StatusCodePagesMiddleware中介軟體會直接響應一個內容為“Error occurred!”的字串。

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

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

        static Task ProcessAsync(HttpContext context)
        {
            context.Response.StatusCode = 401;
            if (_random.Next() % 2 == 0)
            {
                context.Features.Get<IStatusCodePagesFeature>().Enabled = false;
            }
            return Task.CompletedTask;
        }

    }
}

對於針對該應用的請求來說,我們會得到如下兩種不同的響應。沒有主體內容的響應是通過ProcessAsync方法產生的,這種情況發生在StatusCodePagesMiddleware中介軟體通過StatusCodePagesFeature特性被遮蔽的時候。有主體內容的響應則是ProcessAsync方法和StatusCodePagesMiddleware中介軟體共同作用的結果。

HTTP/1.1 401 Unauthorized
Date: Sat, 21 Sep 2019 13:37:31 GMT
Server: Kestrel
Content-Length: 15

Error occurred!
HTTP/1.1 401 Unauthorized
Date: Sat, 21 Sep 2019 13:37:36 GMT
Server: Kestrel
Content-Length: 0

我們在大部分情況下都會呼叫IApplicationBuilder介面相應的擴充套件方法來註冊StatusCodePagesMiddleware中介軟體。對於StatusCodePagesMiddleware中介軟體的註冊來說,除了UseStatusCodePages方法,還有其他方法可供選擇。

三、UseStatusCodePages

我們可以呼叫如下所示的3個UseStatusCodePages擴充套件方法過載來註冊StatusCodePagesMiddleware中介軟體。不論呼叫哪個過載,系統最終都會根據提供的StatusCodePagesOptions物件呼叫建構函式來建立這個中介軟體,而且StatusCodePagesOptions必須具有一個作為錯誤處理器的Func<StatusCodeContext, Task>物件。

public static class StatusCodePagesExtensions
{   
    public static IApplicationBuilder UseStatusCodePages(this IApplicationBuilder app)
        => app.UseMiddleware<StatusCodePagesMiddleware>();

    public static IApplicationBuilder UseStatusCodePages(this IApplicationBuilder app, StatusCodePagesOptions options)
        => app.UseMiddleware<StatusCodePagesMiddleware>(Options.Create(options)); 
    
    public static IApplicationBuilder UseStatusCodePages(this IApplicationBuilder app, Func<StatusCodeContext, Task> handler)
        => app.UseStatusCodePages(new StatusCodePagesOptions
        {
            HandleAsync = handler
        });
}

由於StatusCodePagesMiddleware中介軟體最終的目的還是將定製的錯誤資訊響應給客戶端,所以可以在註冊該中介軟體時直接指定響應的內容和媒體型別,這樣的註冊方式可以通過呼叫如下所示的UseStatusCodePages方法來完成。從如下所示的程式碼片段可以看出,通過引數bodyFormat指定的實際上是一個模板,它可以包含一個表示響應狀態碼的佔位符({0})。

public static class StatusCodePagesExtensions
{   
    public static IApplicationBuilder UseStatusCodePages(this IApplicationBuilder app, string contentType, string bodyFormat)
    {
        return app.UseStatusCodePages(context =>
        {
            var body = string.Format(CultureInfo.InvariantCulture, bodyFormat, context.HttpContext.Response.StatusCode);
            context.HttpContext.Response.ContentType = contentType;
            return context.HttpContext.Response.WriteAsync(body);
        });
    }
}

四、UseStatusCodePagesWithRedirects

如果呼叫UseStatusCodePagesWithRedirects擴充套件方法,就可以使註冊的StatusCodePagesMiddleware中介軟體向指定的路徑傳送一個客戶端重定向。從如下所示的程式碼片段可以看出,引數locationFormat指定的重定向地址也是一個模板,它可以包含一個表示響應狀態碼的佔位符({0})。我們可以指定一個完整的地址,也可以指定一個相對於PathBase的相對路徑,後者需要包含表示基地址的字首“~/”。

public static class StatusCodePagesExtensions
{       
    public static IApplicationBuilder UseStatusCodePagesWithRedirects(this IApplicationBuilder app, string locationFormat)
    {
        if (locationFormat.StartsWith("~"))
        {
            locationFormat = locationFormat.Substring(1);
            return app.UseStatusCodePages(context =>
            {
                var location = string.Format(CultureInfo.InvariantCulture, locationFormat, context.HttpContext.Response.StatusCode);
                context.HttpContext.Response.Redirect(context.HttpContext.Request.PathBase + location);
                return Task.CompletedTask;
            });
        }
        else
        {
            return app.UseStatusCodePages(context =>
            {
                var location = string.Format(CultureInfo.InvariantCulture, locationFormat, context.HttpContext.Response.StatusCode);
                context.HttpContext.Response.Redirect(location);
                return Task.CompletedTask;
            });
        }
    }
}

下面通過一個簡單的應用來演示針對客戶端重定向的錯誤頁面呈現方式。我們在如下所示的應用中註冊了一個路由模板為“error/{statuscode}”的路由,路由引數statuscode代表響應的狀態碼。在作為路由處理器的HandleAsync方法中,我們會直接響應一個包含狀態碼的字串。我們呼叫UseStatusCodePagesWithRedirects方法註冊StatusCodePagesMiddleware中介軟體時將重定義路徑設定為“error/{0}”。

public class Program
{
    private static readonly Random _random = new Random();
    public static void Main()
    {
        Host.CreateDefaultBuilder()
            .ConfigureWebHostDefaults(builder => builder
                .ConfigureServices(svcs => svcs.AddRouting())
                .Configure(app => app
                    .UseStatusCodePagesWithRedirects("~/error/{0}")
                    .UseRouting()
                    .UseEndpoints(endpoints => endpoints.MapGet("error/{statuscode}", HandleAsync))
                    .Run(ProcessAsync)))
            .Build()
            .Run();

        static async Task HandleAsync(HttpContext context)
        {
            var statusCode = context.GetRouteData().Values["statuscode"];
            await context.Response.WriteAsync($"Error occurred ({statusCode})");
        }

        static Task ProcessAsync(HttpContext context)
        {
            context.Response.StatusCode = _random.Next(400, 599);
            return Task.CompletedTask;
        }
    }
}

針對該應用的請求總是得到一個狀態碼為400~599的響應,StatusCodePagesMiddleware中介軟體在此情況下會向指定的路徑(“~/error/{statuscode}”)傳送一個客戶端重定向。由於重定向請求的路徑與註冊的路由相匹配,所以作為路由處理器的HandleError方法會響應下圖所示的錯誤頁面。

五、UseStatusCodePagesWithReExecute

除了可以採用客戶端重定向的方式來呈現錯誤頁面,還可以呼叫UseStatusCodePagesWithReExecute方法註冊StatusCodePagesMiddleware中介軟體,並讓它採用服務端重定向的方式來處理錯誤請求。如下面的程式碼片段所示,當我們呼叫這個方法的時候不僅可以指定重定向的路徑,還可以指定查詢字串。這裡作為重定向地址的引數pathFormat依舊是一個路徑模板,它可以包含一個表示響應狀態碼的佔位符({0})。

public static class StatusCodePagesExtensions
{
    public static IApplicationBuilder UseStatusCodePagesWithReExecute( this IApplicationBuilder app, string pathFormat, string queryFormat = null);
}

現在我們對前面演示的這個例項略做修改來演示採用服務端重定向呈現的錯誤頁面。如下面的程式碼片段所示,我們將針對UseStatusCodePagesWithRedirects方法的呼叫替換成針對UseStatusCodePagesWithReExecute方法的呼叫。

public class Program
{
    private static readonly Random _random = new Random();
    public static void Main()
    {
        Host.CreateDefaultBuilder()
            .ConfigureWebHostDefaults(builder => builder
                .ConfigureServices(svcs => svcs.AddRouting())
                .Configure(app => app
                    .UseStatusCodePagesWithReExecute("/error/{0}")
                    .UseRouting()
                    .UseEndpoints(endpoints => endpoints.MapGet("error/{statuscode}", HandleAsync))
                    .Run(ProcessAsync)))
            .Build()
            .Run();

        static async Task HandleAsync(HttpContext context)
        {
            var statusCode = context.GetRouteData().Values["statuscode"];
            await context.Response.WriteAsync($"Error occurred ({statusCode})");
        }

        static Task ProcessAsync(HttpContext context)
        {
            context.Response.StatusCode = _random.Next(400, 599);
            return Task.CompletedTask;
        }
    }
}

對於前面演示的例項,由於錯誤頁面是通過客戶端重定向的方式呈現的,所以瀏覽器位址列顯示的是重定向地址。我們在選擇這個例項時採用了服務端重定向,雖然顯示的頁面內容並沒有不同,但是位址列上的地址是不會發生改變的,如下圖所示。(S1615)

之所以命名為UseStatusCodePagesWithReExecute,是因為通過這個方法註冊的StatusCodePagesMiddleware中介軟體進行錯誤處理時,它僅僅將提供的重定向路徑和查詢字串應用到當前HttpContext上下文,然後分發給後續管道重新執行。UseStatusCodePagesWithReExecute方法中註冊StatusCodePagesMiddleware中介軟體的實現總體上可以由如下所示的程式碼片段來體現。

public static class StatusCodePagesExtensions
{    
    public static IApplicationBuilder UseStatusCodePagesWithReExecute( this IApplicationBuilder app, string pathFormat, string queryFormat = null)
    {
        return app.UseStatusCodePages(async context =>
        {
            var newPath = new PathString(string.Format(CultureInfo.InvariantCulture, pathFormat, context.HttpContext.Response.StatusCode));
            var formatedQueryString = queryFormat == null ? null : string.Format(CultureInfo.InvariantCulture, queryFormat, context.HttpContext.Response.StatusCode);
            
            context.HttpContext.Request.Path = newPath;
            context.HttpContext.Request.QueryString = newQueryString;
            await context.Next(context.HttpContext);
        });
    }
}

與ExceptionHandlerMiddleware中介軟體類似,StatusCodePagesMiddleware中介軟體在處理請求的過程中會改變當前請求上下文的狀態,具體體現在它會將指定的請求路徑和查詢字串重新應用到當前請求上下文中。為了不影響前置中介軟體對請求的正常處理,StatusCodePagesMiddleware中介軟體在完成自身處理流程之後必須將當前請求上下文恢復到原始狀態。StatusCodePagesMiddleware中介軟體依舊採用一個特性來儲存原始路徑和查詢字串。這個特性對應的介面是具有如下定義的IStatusCodeReExecuteFeature,但是該介面僅僅包含兩個針對路徑的屬性,並沒有用於攜帶原始請求上下文的屬性,但是預設實現型別StatusCodeReExecuteFeature包含了這個屬性。

public interface IStatusCodeReExecuteFeature
{
    string OriginalPath { get; set; }
    string OriginalPathBase { get; set; }
}

public class StatusCodeReExecuteFeature : IStatusCodeReExecuteFeature
{
    public string OriginalPath { get; set; }
    public string OriginalPathBase { get; set; }
    public string OriginalQueryString { get; set; }
}

在StatusCodePagesMiddleware中介軟體處理異常請求的過程中,在將指定的重定向路徑和查詢字串應用到當前請求上下文之前,它會根據原始的上下文建立一個StatusCodeReExecuteFeature特性物件,並將其新增到當前HttpContext上下文的特性集合中。當整個請求處理過程結束之後,StatusCodePagesMiddleware中介軟體還會將這個特性從當前HttpContext上下文中移除,並恢復原始的請求路徑和查詢字串。如下所示的程式碼片段體現了UseStatusCodePagesWithReExecute方法的實現邏輯。

public static class StatusCodePagesExtensions
{
    public static IApplicationBuilder UseStatusCodePagesWithReExecute( this IApplicationBuilder app,string pathFormat, string queryFormat = null)
    {    
        return app.UseStatusCodePages(async context =>
        {
            var newPath = new PathString( string.Format(CultureInfo.InvariantCulture, pathFormat, context.HttpContext.Response.StatusCode));
            var formatedQueryString = queryFormat == null ? null : string.Format(CultureInfo.InvariantCulture, queryFormat, context.HttpContext.Response.StatusCode);
            var newQueryString = queryFormat == null ? QueryString.Empty : new QueryString(formatedQueryString);

            var originalPath = context.HttpContext.Request.Path;
            var originalQueryString = context.HttpContext.Request.QueryString;

            context.HttpContext.Features.Set<IStatusCodeReExecuteFeature>(new StatusCodeReExecuteFeature()
            {
                OriginalPathBase = context.HttpContext.Request.PathBase.Value,
                OriginalPath = originalPath.Value,
                OriginalQueryString = originalQueryString.HasValue ? originalQueryString.Value : null,
            });

            context.HttpContext.Request.Path = newPath;
            context.HttpContext.Request.QueryString = newQueryString;
            try
            {
                await context.Next(context.HttpContext);
            }
            finally
            {
                context.HttpContext.Request.QueryString = originalQueryString;
                context.HttpContext.Request.Path = originalPath;
                context.HttpContext.Features.Set<IStatusCodeReExecuteFeature>(null);
            }
        });
    }
}

ASP.NET Core錯誤處理中介軟體[1]: 呈現錯誤資訊
ASP.NET Core錯誤處理中介軟體[2]: 開發者異常頁面
ASP.NET Core錯誤處理中介軟體[3]: 異常處理器
ASP.NET Core錯誤處理中介軟體[4]: 響應狀態碼頁面