ASP.NET Core应用基本编程模式[1]:管道式的请求处理

HTTP协议自身的特性决定了任何一个Web应用的工作模式都是监听、接收并处理HTTP请求,并且最终对请求予以响应。HTTP请求处理是管道式设计典型的应用场景:可以根据具体的需求构建一个管道,接收的HTTP请求像水一样流入这个管道,组成这个管道的各个环节依次对其做相应的处理。虽然ASP.NET Core的请求处理管道从设计上来讲是非常简单的,但是具体的实现则涉及很多细节,为了使读者对此有深刻的理解,需要从编程的角度先了解ASP.NET Core管道式的请求处理方式。[本文节选自《ASP.NET Core 3框架揭秘》第11章, 更多关于ASP.NET Core的文章请点这里]

目录
一、两个承载体系
二、请求处理管道
三、中间件
    RequestDelegate
     Func<RequestDelegate, RequestDelegate>
     Run方法的本质   
四、定义强类型中间件
五、按照约定定义中间件

一、两个承载体系

ASP.NET Core框架目前存在两个承载(Hosting)系统。ASP.NET Core最初提供了一个以IWebHostBuilder/IWebHost为核心的承载系统,其目的很单纯,就是通过下图所示的形式承载以服务器和中间件管道构建的Web应用。ASP.NET Core 3依然支持这样的应用承载方式,但是本系列不会涉及这种“过时”的承载方式。

1

除了承载Web应用本身,我们还有针对后台服务的承载需求,为此微软推出了以IHostBuilder/IHost为核心的承载系统,我们在《服务承载系统》中已经对该系统做了详细的介绍。实际上,Web应用本身就是一个长时间运行的后台服务,我们完全可以定义一个承载服务,从而将Web应用承载于这个系统中。如下图所示,这个用来承载ASP.NET Core应用的承载服务类型为GenericWebHostService,这是一个实现了IHostedService接口的内部类型。

2

IHostBuilder接口上定义了很多方法(其中很多是扩展方法),这些方法的目的主要包括以下两点:第一,为创建的IHost对象及承载的服务在依赖注入框架中注册相应的服务;第二,为服务承载和应用提供相应的配置。其实IWebHostBuilder接口同样定义了一系列方法,除了这里涉及的两点,支撑ASP.NET Core应用的中间件也是由IWebHostBuilder注册的。

即使采用基于IHostBuilder/IHost的承载系统,我们依然会使用IWebHostBuilder接口。虽然我们不再使用IWebHostBuilder的宿主构建功能,但是定义在IWebHostBuilder上的其他API都是可以使用的。具体来说,可以调用定义在IHostBuilder接口和IWebHostBuilder接口的方法(大部分为扩展方法)来注册依赖服务与初始化配置系统,两者最终会合并在一起。利用IWebHostBuilder接口注册的中间件会提供给GenericWebHostService,用于构建ASP.NET Core请求处理管道。

在基于IHostBuilder/IHost的承载系统中复用IWebHostBuilder的目的是通过如下所示的ConfigureWebHost扩展方法达成的,GenericWebHostService服务也是在这个方法中被注册的。ConfigureWebHostDefaults扩展方法则会在此基础上做一些默认设置(如KestrelServer),后续章节的实例演示基本上会使用这个方法。

public static class GenericHostWebHostBuilderExtensions
{
    public static IHostBuilder ConfigureWebHost(this IHostBuilder builder, Action<IWebHostBuilder> configure);
}

public static class GenericHostBuilderExtensions
{
    public static IHostBuilder ConfigureWebHostDefaults(this IHostBuilder builder, Action<IWebHostBuilder> configure);
}

对IWebHostBuilder接口的复用导致很多功能都具有两种编程方式,虽然这样可以最大限度地复用和兼容定义在IWebHostBuilder接口上众多的应用编程接口,但笔者并不喜欢这样略显混乱的编程模式,这一点在下一个版本中也许会得到改变。

二、请求处理管道

下面创建一个最简单的Hello World程序。这个程序由如下所示的几行代码组成。运行这个程序之后,一个名为KestrelServer的服务器将会启动并绑定到本机上的5000端口进行请求监听。针对所有接收到的请求,我们都会采用“Hello World”字符串作为响应的主体内容。

class Program
{
    static void Main()
    {
        Host.CreateDefaultBuilder()
            .ConfigureWebHost(builder => builder.Configure(app => app.Run(context => context.Response.WriteAsync("Hello World"))))
            .Build()
            .Run();
    }
}

从如上所示的代码片段可以看出,我们利用《服务承载系统》介绍的承载系统来承载一个ASP.NET Core应用。在调用Host类型的静态方法CreateDefaultBuilder创建了一个IHostBuilder对象之后,我们调用它的ConfigureWebHost方法对ASP.NET Core应用的请求处理管道进行定制。HTTP请求处理流程始于对请求的监听与接收,终于对请求的响应,这两项工作均由同一个对象来完成,我们称之为服务器(Server)。ASP.NET Core请求处理管道必须有一个服务器,它是整个管道的“龙头”。在演示程序中,我们调用IWebHostBuilder接口的UseKestrel扩展方法为后续构建的管道注册了一个名为KestrelServer的服务器。

当承载服务GenericWebHostService被启动之后,定制的请求处理管道会被构建出来,管道的服务器随后会绑定到一个预设的端口(如KestrelServer默认采用5000作为监听端口)开始监听请求。HTTP请求一旦抵达,服务器会将其标准化,并分发给管道后续的节点,我们将位于服务器之后的节点称为中间件(Middleware)。

每个中间件都具有各自独立的功能,如专门实现路由功能的中间件、专门实施用户认证和授权的中间件。所谓的管道定制主要体现在根据具体需求选择对应的中间件来构建最终的管道。在演示程序中,我们调用IWebHostBuilder接口的Configure方法注册了一个中间件,用于响应“Hello World”字符串。具体来说,这个用来注册中间件的Configure方法具有一个类型为Action<IApplicationBuilder>的参数,我们提供的中间件就注册到提供的IApplicationBuilder对象上。由服务器和中间件组成的请求处理管道如下图所示。

3

建立在ASP.NET Core之上的应用基本上是根据某个框架开发的。一般来说,开发框架本身就是通过某一个或者多个中间件构建起来的。以ASP.NET Core MVC开发框架为例,它借助“路由”中间件实现了请求与Action之间的映射,并在此基础之上实现了激活(Controller)、执行(Action)及呈现(View)等一系列功能。应用程序可以视为某个中间件的一部分,如果一定要将它独立出来,由服务器、中间件和应用组成的管道如下图所示。

4

三、中间件

ASP.NET Core的请求处理管道由一个服务器和一组中间件组成,位于“龙头”的服务器负责请求的监听、接收、分发和最终的响应,而针对该请求的处理则由后续的中间件来完成。如果读者希望对请求处理管道具有深刻的认识,就需要对中间件有一定程度的了解。

RequestDelegate

从概念上可以将请求处理管道理解为“请求消息”和“响应消息”流通的管道,服务器将接收的请求消息从一端流入管道并由相应的中间件进行处理,生成的响应消息反向流入管道,经过相应中间件处理后由服务器分发给请求者。但从实现的角度来讲,管道中流通的并不是所谓的请求消息与响应消息,而是一个针对当前请求创建的上下文。这个上下文被抽象成如下这个HttpContext类型,我们利用HttpContext不仅可以获取针对当前请求的所有信息,还可以直接完成针对当前请求的所有响应工作。

public abstract class HttpContext
{
    public abstract HttpRequest    Request { get; set; }
    public abstract HttpResponse     Response { get; }
    ...
}

既然流入管道的只有一个共享的HttpContext上下文,那么一个Func<HttpContext,Task>对象就可以表示处理HttpContext的操作,或者用于处理HTTP请求的处理器。由于这个委托对象非常重要,所以ASP.NET Core专门定义了如下这个名为RequestDelegate的委托类型。既然有这样一个专门的委托对象来表示“针对请求的处理”,那么中间件是否能够通过该委托对象来表示?

public delegate Task RequestDelegate(HttpContext context);

Func<RequestDelegate, RequestDelegate>

实际上,组成请求处理管道的中间件可以表示为一个类型为Func<RequestDelegate, RequestDelegate>的委托对象,但初学者很难理解这一点,所以下面对此进行简单的解释。由于RequestDelegate可以表示一个HTTP请求处理器,所以由一个或者多个中间件组成的管道最终也体现为一个RequestDelegate对象。对于下图所示的中间件Foo来说,后续中间件(Bar和Baz)组成的管道体现为一个RequestDelegate对象,该对象会作为中间件Foo输入,中间件Foo借助这个委托对象将当前HttpContext分发给后续管道做进一步处理。

5

表示中间件的Func<RequestDelegate, RequestDelegate>对象的输出依然是一个RequestDelegate对象,该对象表示将当前中间件与后续管道进行“对接”之后构成的新管道。对于表示中间件Foo的委托对象来说,返回的RequestDelegate对象体现的就是由Foo、Bar和Baz组成的请求处理管道。

既然原始的中间件是通过一个Func<RequestDelegate, RequestDelegate>对象表示的,就可以直接注册这样一个对象作为中间件。中间件的注册可以通过调用IWebHostBuilder接口的Configure扩展方法来完成,该方法的参数是一个Action<IApplicationBuilder>类型的委托对象,可以通过调用IApplicationBuilder接口的Use方法将表示中间件的Func<RequestDelegate, RequestDelegate>对象添加到当前中间件链条上。

public static class WebHostBuilderExtensions
{
    public static IWebHostBuilder Configure(this IWebHostBuilder hostBuilder, ction<IApplicationBuilder> configureApp);
}

public interface IApplicationBuilder
{
    IApplicationBuilder Use(Func<RequestDelegate, RequestDelegate> middleware);
}

在如下所示的代码片段中,我们创建了两个Func<RequestDelegate, RequestDelegate>对象,它们会在响应中写入两个字符串(“Hello”和“World!”)。在针对IWebHostBuilder接口的Configure方法的调用中,可以调用IApplicationBuilder接口的Use方法将这两个委托对象注册为中间件。

class Program
{
    static void Main()
    {
        static RequestDelegate Middleware1(RequestDelegate next) => async context =>
        {
            await context.Response.WriteAsync("Hello");
            await next(context);
        };
        static RequestDelegate Middleware2(RequestDelegate next) => async context =>
        {
            await context.Response.WriteAsync(" World!");
        };

        Host.CreateDefaultBuilder().ConfigureWebHostDefaults(builder => builder.Configure(app => app
            .Use(Middleware1)
            .Use(Middleware2)))
        .Build()
        .Run();
    }
}

由于我们注册了如上所示的两个中间件,所以它们会按照注册的顺序对分发给它们的请求进行处理。运行该程序后,如果利用浏览器对监听地址(“http://localhost:5000”)发送请求,那么两个中间件写入的字符串会以下图所示的形式呈现出来。

6

虽然可以直接采用原始的Func<RequestDelegate, RequestDelegate>对象来定义中间件,但是在大部分情况下,我们依然倾向于将自定义的中间件定义成一个具体的类型。至于中间件类型的定义,ASP.NET Core提供了如下两种不同的形式可供选择。

  • 强类型定义:自定义的中间件类型显式实现预定义的IMiddleware接口,并在实现的方法中完成针对请求的处理。
  • 基于约定的定义:不需要实现任何接口或者继承某个基类,只需要按照预定义的约定来定义中间件类型。

Run方法的本质

在演示的Hello World应用中,我们调用IApplicationBuilder接口的Run扩展方法注册了一个RequestDelegate对象来处理请求,实际上,该方法仅仅是按照如下方式注册了一个中间件。由于注册的中间件并不会将请求分发给后续的中间件,如果调用IApplicationBuilder接口的Run方法后又注册了其他的中间件,后续中间件的注册将毫无意义。

public static class RunExtensions
{
    public static void Run(this IApplicationBuilder app, RequestDelegate handler)
    => app.Use(_ => handler);
}

四、定义强类型中间件

如果采用强类型的中间件类型定义方式,只需要实现如下这个IMiddleware接口,该接口定义了唯一的InvokeAsync方法,用于实现中间件针对请求的处理。这个InvokeAsync方法定义了两个参数:第一个参数是代表当前请求上下文的HttpContext对象,第二个参数是代表后续中间件组成的管道的RequestDelegate对象,如果当前中间件最终需要将请求分发给后续中间件进行处理,只需要调用这个委托对象即可,否则应用针对请求的处理就到此为止。

public interface IMiddleware
{
    Task InvokeAsync(HttpContext context, RequestDelegate next);
}

在如下所示的代码片段中,我们定义了一个实现了IMiddleware接口的StringContentMiddleware中间件类型,在实现的InvokeAsync方法中,它将构造函数中指定的字符串作为响应的内容。由于中间件最终是采用依赖注入的方式来提供的,所以需要预先对它们进行服务注册,针对StringContentMiddleware的服务注册是通过调用IHostBuilder接口的ConfigureServices方法完成的。

class Program
{
    static void Main()
    {            
        Host.CreateDefaultBuilder()
            .ConfigureServices(svcs => svcs.AddSingleton(new StringContentMiddleware("Hello World!")))
            .ConfigureWebHost(builder => builder
            .Configure(app => app.UseMiddleware<StringContentMiddleware>()))
        .Build()
        .Run();
    }

    private sealed class StringContentMiddleware : IMiddleware
    {
        private readonly string _contents;
        public StringContentMiddleware(string contents) => _contents = contents;
        public Task InvokeAsync(HttpContext context, RequestDelegate next) => context.Response.WriteAsync(_contents);
    }
}

针对中间件自身的注册则体现在针对IWebHostBuilder接口的Configure方法的调用上,最终通过调用IApplicationBuilder接口的UseMiddleware<TMiddleware>方法来注册中间件类型。如下面的代码片段所示,在注册中间件类型时,可以以泛型参数的形式来指定中间件类型,也可以调用另一个非泛型的方法重载,直接通过Type类型的参数来指定中间件类型。值得注意的是,这两个方法均提供了一个参数params,它是为针对“基于约定的中间件”注册设计的,当我们注册一个实现了IMiddleware接口的强类型中间件的时候是不能指定该参数的。启动该程序后利用浏览器访问监听地址,依然可以得到上图所示的输出结果。

public static class UseMiddlewareExtensions
{
    public static IApplicationBuilder UseMiddleware<TMiddleware>(this IApplicationBuilder app, params object[] args);
    public static IApplicationBuilder UseMiddleware(this IApplicationBuilder app, Type middleware, params object[] args);    
}

五、按照约定定义中间件

可能我们已经习惯了通过实现某个接口或者继承某个抽象类的扩展方式,但是这种方式有时显得约束过重,不够灵活,所以可以采用另一种基于约定的中间件类型定义方式。这种定义方式比较自由,因为它并不需要实现某个预定义的接口或者继承某个基类,而只需要遵循一些约定即可。自定义中间件类型的约定主要体现在如下几个方面。

  • 中间件类型需要有一个有效的公共实例构造函数,该构造函数要求必须包含一个RequestDelegate类型的参数,当前中间件利用这个委托对象实现针对后续中间件的请求分发。构造函数不仅可以包含任意其他参数,对于RequestDelegate参数出现的位置也不做任何约束。
  • 针对请求的处理实现在返回类型为Task的InvokeAsync方法或者Invoke方法中,它们的第一个参数表示当前请求上下文的HttpContext对象。对于后续的参数,虽然约定并未对此做限制,但是由于这些参数最终由依赖注入框架提供,所以相应的服务注册必须存在。

采用这种方式定义的中间件类型同样是调用前面介绍的UseMiddleware方法和UseMiddleware<TMiddleware>方法进行注册的。由于这两个方法会利用依赖注入框架来提供指定类型的中间件对象,所以它会利用注册的服务来提供传入构造函数的参数。如果构造函数的参数没有对应的服务注册,就必须在调用这个方法的时候显式指定。

在如下所示的代码片段中,我们定义了一个名为StringContentMiddleware的中间件类型,在执行这个中间件时,它会将预先指定的字符串作为响应内容。StringContentMiddleware的构造函数具有两个额外的参数:contents表示响应内容,forewardToNext则表示是否需要将请求分发给后续中间件进行处理。在调用UseMiddleware<TMiddleware>扩展方法对这个中间件进行注册时,我们显式指定了响应的内容,至于参数forewardToNext,我们之所以没有每次都显式指定,是因为这是一个具有默认值的参数。

class Program
{
    static void Main()
    {
        Host.CreateDefaultBuilder()
            .ConfigureWebHostDefaults(builder => builder.Configure(app => app
                .UseMiddleware<StringContentMiddleware>("Hello")
                .UseMiddleware<StringContentMiddleware>(" World!", false)))
        .Build()
        .Run();
    }

    private sealed class StringContentMiddleware
    {
        private readonly RequestDelegate     _next;
        private readonly string _contents;
        private readonly bool _forewardToNext;

        public StringContentMiddleware(RequestDelegate next, string contents, bool forewardToNext = true)
        {
            _next  = next;
            _forewardToNext = forewardToNext;
            _contents = contents;
        }

        public async Task Invoke(HttpContext context)
        {
            await context.Response.WriteAsync(_contents);
            if (_forewardToNext)
            {
                await _next(context);
            }
        }
    }
}

启动该程序后,利用浏览器访问监听地址依然可以得到下图所示的输出结果。对于前面介绍的两个中间件,它们的不同之处除了体现在定义和注册方式上,还体现在自身生命周期的差异上。具体来说,强类型方式定义的中间件可以注册为任意生命周期模式的服务,但是按照约定定义的中间件则总是一个Singleton服务。

6

ASP.NET Core编程模式[1]:管道式的请求处理
ASP.NET Core编程模式[2]:依赖注入的运用
ASP.NET Core编程模式[3]:配置多种使用形式
ASP.NET Core编程模式[4]:基于承载环境的编程
ASP.NET Core编程模式[5]:如何放置你的初始化代码

posted @ 2020-11-11 08:46  Artech  阅读(2863)  评论(3编辑  收藏  举报