当前位置 博文首页 > Artech:ASP.NET Core错误处理中间件[4]: 响应状态码错误页面

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

    作者:Artech 时间:2021-01-30 18:41

    StatusCodePagesMiddleware中间件与ExceptionHandlerMiddleware中间件类似,它们都是在后续请求处理过程中“出错”的情况下利用一个错误处理器来接收针对当前请求的处理。它们之间的差异在于对“错误”的认定上:ExceptionHandlerMiddleware中间件所谓的错误就是抛出异常;StatusCodePagesMiddleware中间件则将400~599的响应状态码视为错误。

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

    16-11

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