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方法会响应下图所示的错误页面。

ASP.NET Core错误处理中间件[4]: 响应状态码错误页面

五、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)

ASP.NET Core错误处理中间件[4]: 响应状态码错误页面

之所以命名为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]: 响应状态码页面

发表评论

评论已关闭。

相关文章