7

200行代码,7个对象——让你了解ASP.NET Core框架的本质[3.x版]

 3 years ago
source link: https://www.cnblogs.com/artech/p/mini-asp-net-core-3x.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

200行代码,7个对象——让你了解ASP.NET Core框架的本质[3.x版]

2019年1月19日,微软技术(苏州)俱乐部成立,我受邀在成立大会上作了一个名为《ASP.NET Core框架揭秘》的分享。在此次分享中,我按照ASP.NET Core自身的运行原理和设计思想创建了一个 “迷你版” 的ASP.NET Core框架,并且利用这个 “极简” 的模拟框架阐述了ASP.NET Core框架最核心、最本质的东西。整个框架涉及到的核心代码不会超过200行,涉及到7个核心的对象。由于ASP.NET Core 3.X采用了不同的应用承载方式,所以我们将这个模拟框架升级到3.x版本。[本篇内容节选自即将出版的《ASP.NET Core 3框架解密》,感兴趣的朋友可以通过《“ASP.NET Core 3框架揭秘”读者群,欢迎加入》加入本书读者群,以便及时了解本书的动态。源代码从这里下载。]

目录
一、中间件委托链
     HttpContext
     中间件
     中间件管道的构建
二、服务器
     IServer
     针对服务器的适配
     HttpListenerServer
三、承载服务
     WebHostedService
     WebHostBuilder
     应用构建

一、中间件委托链

通过本篇文章,我将管道最核心的部分提取出来构建一个“迷你版”的ASP.NET Core框架。较之真正的ASP.NET Core框架,虽然重建的模拟框架要简单很多,但是它们采用完全一致的设计。为了能够在真实框架中找到对应物,在定义接口或者类型时会采用真实的名称,但是在API的定义上会做最大限度的简化。

HttpContext

一个HttpContext对象表示针对当前请求的上下文。要理解HttpContext上下文的本质,需要从请求处理管道的层面来讲。对于由一个服务器和多个中间件构成的管道来说,面向传输层的服务器负责请求的监听、接收和最终的响应,当它接收到客户端发送的请求后,需要将请求分发给后续中间件进行处理。对于某个中间件来说,完成自身的请求处理任务之后,在大部分情况下需要将请求分发给后续的中间件。请求在服务器与中间件之间,以及在中间件之间的分发是通过共享上下文的方式实现的。

如下图所示,当服务器接收到请求之后,会创建一个通过HttpContext表示的上下文对象,所有中间件都在这个上下文中完成针对请求的处理工作。那么一个HttpContext对象究竟会携带什么样的上下文信息?一个HTTP事务(Transaction)具有非常清晰的界定,如果从服务器的角度来说就是始于请求的接收,而终于响应的回复,所以请求和响应是两个基本的要素,也是HttpContext承载的最核心的上下文信息。

12-1

我们可以将请求和响应理解为一个Web应用的输入与输出,既然HttpContext上下文是针对请求和响应的封装,那么应用程序就可以利用这个上下文对象得到当前请求所有的输入信息,也可以利用它完成我们所需的所有输出工作。所以,我们为ASP.NET Core模拟框架定义了如下这个极简版本的HttpContext类型。

public class HttpContext
{
    public HttpRequest Request { get; }
    public HttpResponse Response { get; }
}

public class HttpRequest
{
    public Uri Url { get; }
    public NameValueCollection Headers { get; }
    public Stream Body { get; }
}

public class HttpResponse
{
    public int StatusCode { get; set; }
    public NameValueCollection Headers { get; }
    public Stream Body { get; }
}

如上面的代码片段所示,我们可以利用HttpRequest对象得到当前请求的地址、请求消息的报头集合和主体内容。利用HttpResponse对象,我们不仅可以设置响应的状态码,还可以添加任意的响应报头和写入任意的主体内容。

HttpContext对象承载了所有与当前请求相关的上下文信息,应用程序针对请求的响应也利用它来完成,所以可以利用一个Action<HttpContext>类型的委托对象来表示针对请求的处理,我们姑且将它称为请求处理器(Handler)。但Action<HttpContext>仅仅是请求处理器针对“同步”编程模式的表现形式,对于面向Task的异步编程模式,这个处理器应该表示成类型为Func<HttpContext,Task>的委托对象。

由于这个表示请求处理器的委托对象具有非常广泛的应用,所以我们为它专门定义了如下这个RequestDelegate委托类型,可以看出它就是对Func<HttpContext,Task>委托的表达。一个RequestDelegate对象表示的是请求处理器,那么中间件在模型中应如何表达?

public delegate Task RequestDelegate(HttpContext context);

作为请求处理管道核心组成部分的中间件可以表示成类型为Func<RequestDelegate, RequestDelegate>的委托对象。换句话说,中间件的输入与输出都是一个RequestDelegate对象。我们可以这样来理解:对于管道中的某个中间件(下图所示的第一个中间件)来说,后续中间件组成的管道体现为一个RequestDelegate对象,由于当前中间件在完成了自身的请求处理任务之后,往往需要将请求分发给后续中间件进行处理,所以它需要将后续中间件构成的RequestDelegate对象作为输入。

12-2

当代表当前中间件的委托对象执行之后,如果将它自己“纳入”这个管道,那么代表新管道的RequestDelegate对象就成为该委托对象执行后的输出结果,所以中间件自然就表示成输入和输出类型均为RequestDelegate的Func<RequestDelegate, RequestDelegate>对象。

中间件管道的构建

从事软件行业10多年来,笔者对架构设计越来越具有这样的认识:好的设计一定是“简单”的设计。所以在设计某个开发框架时笔者的目标是再简单点。上面介绍的请求处理管道的设计就具有“简单”的特质:Pipeline = Server + Middlewares。但是“再简单点”其实是可以的,我们可以将多个中间件组成一个单一的请求处理器。请求处理器可以通过RequestDelegate对象来表示,所以整个请求处理管道将具有更加简单的表达:Pipeline = Server + RequestDelegate(见下图)。

12-3

表示中间件的Func<RequestDelegate, RequestDelegate>对象向表示请求处理器的RequestDelegate对象之间的转换是通过IApplicationBuilder对象来完成的。从接口命名可以看出,IApplicationBuilder对象是用来构建“应用程序”(Application)的,实际上,由所有注册中间件构建的RequestDelegate对象就是对应用程序的表达,因为应用程序的意图完全是由注册的中间件达成的。

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

如上所示的代码片段是模拟框架对IApplicationBuilder接口的简化定义。它的Use方法用来注册中间件,而Build方法则将所有的中间件按照注册的顺序组装成一个RequestDelegate对象。如下所示的代码片段中ApplicationBuilder类型是对该接口的默认实现。我们给出的代码片段还体现了这样一个细节:当我们将注册的中间件转换成一个表示请求处理器的RequestDelegate对象时,会在管道的尾端添加一个处理器用来响应一个状态码为404的响应。这个细节意味着如果没有注册任何的中间件或者所有注册的中间件都将请求分发给后续管道,那么应用程序会回复一个状态码为404的响应。

public class ApplicationBuilder : IApplicationBuilder
{
    private readonly IList<Func<RequestDelegate, RequestDelegate>> _middlewares = new List<Func<RequestDelegate, RequestDelegate>>();

    public RequestDelegate Build()
    {
        RequestDelegate next = context =>
        {
            context.Response.StatusCode = 404;
            return Task.CompletedTask;
        };                    
        foreach (var middleware in _middlewares.Reverse())
        {
            next = middleware.Invoke(next);
        }
        return next;
    }

    public IApplicationBuilder Use(Func<RequestDelegate, RequestDelegate> middleware)
    {
        _middlewares.Add(middleware);
        return this;
    }
}

二、服务器

服务器在管道中的职责非常明确:负责HTTP请求的监听、接收和最终的响应。具体来说,启动后的服务器会绑定到指定的端口进行请求监听。一旦有请求抵达,服务器会根据该请求创建代表请求上下文的HttpContext对象,并将该上下文分发给注册的中间件进行处理。当中间件管道完成了针对请求的处理之后,服务器会将最终生成的响应回复给客户端。

IServer

在模拟的ASP.NET Core框架中,我们将服务器定义成一个极度简化的IServer接口。在如下所示的代码片段中,IServer接口具有唯一的StartAsync方法来启动自身代表的服务器。服务器最终需要将接收的请求分发给注册的中间件,而注册的中间件最终会被IApplicationBuilder对象构建成一个代表请求处理器的RequestDelegate对象,StartAsync方法的参数handler代表的就是这样一个对象。

public interface IServer
{
    Task StartAsync(RequestDelegate handler);
}

针对服务器的适配

面向应用层的HttpContext对象是对请求和响应的抽象与封装,但是请求最初是由面向传输层的服务器接收的,最终的响应也会由服务器回复给客户端。所有ASP.NET Core应用使用的都是同一个HttpContext类型,但是它们可以注册不同类型的服务器,应如何解决两者之间的适配问题?计算机领域有这样一句话:“任何问题都可以通过添加一个抽象层的方式来解决,如果解决不了,那就再加一层。”同一个HttpContext类型与不同服务器类型之间的适配问题自然也可以通过添加一个抽象层来解决。我们将定义在该抽象层的对象称为特性(Feature),特性可以视为对HttpContext某个方面的抽象化描述。

12-4

如上图所示,我们可以定义一系列特性接口来为HttpContext提供某个方面的上下文信息,具体的服务器只需要实现这些Feature接口即可。对于所有用来定义特性的接口,最重要的是提供请求信息的IRequestFeature接口和完成响应的IResponseFeature接口。

下面阐述用来适配不同服务器类型的特性在代码层面的定义。如下面的代码片段所示,我们定义了一个IFeatureCollection接口来表示存放特性的集合。可以看出,这是一个以Type和Object作为Key和Value的字典,Key代表注册Feature所采用的类型,而Value代表Feature对象本身,也就是说,我们提供的特性最终是以对应类型(一般为接口类型)进行注册的。为了便于编程,我们定义了Set<T>方法和Get<T>方法来设置与获取特性对象。

public interface IFeatureCollection : IDictionary<Type, object> { }
public class FeatureCollection : Dictionary<Type, object>, IFeatureCollection { }
public static partial class Extensions
{
    public static T Get<T>(this IFeatureCollection features)  => features.TryGetValue(typeof(T), out var value) ? (T)value : default(T);
    public static IFeatureCollection Set<T>(this IFeatureCollection features, T feature)
    {
        features[typeof(T)] = feature;
        return features;
    }
}

最核心的两种特性类型就是分别用来表示请求和响应的特性,我们可以采用如下两个接口来表示。可以看出,IHttpRequestFeature接口和IHttpResponseFeature接口具有与抽象类型HttpRequest和HttpResponse完全一致的成员定义。

public interface IHttpRequestFeature
{
    Uri Url { get; }
    NameValueCollection Headers { get; }
    Stream Body { get; }
}
public interface IHttpResponseFeature
{
    int StatusCode { get; set; }
    NameValueCollection Headers { get; }
    Stream Body { get; }
}

我们在前面给出了用于描述请求上下文的HttpContext类型的成员定义,下面介绍其具体实现。如下面的代码片段所示,表示请求和响应的HttpRequest与HttpResponse分别是由对应的特性(IHttpRequestFeature对象和IHttpResponseFeature对象)创建的。HttpContext对象本身则是通过一个表示特性集合的IFeatureCollection 对象来创建的,它会在初始化过程中从这个集合中提取出对应的特性来创建HttpRequest对象和HttpResponse对象。

public class HttpContext
{
    public HttpRequest Request { get; }
    public HttpResponse Response { get; }

    public HttpContext(IFeatureCollection features)
    {
        Request = new HttpRequest(features);
        Response = new HttpResponse(features);
    }
}

public class HttpRequest
{
    private readonly IHttpRequestFeature _feature;
    public Uri Url=> _feature.Url;
    public NameValueCollection Headers=> _feature.Headers;
    public Stream Body=> _feature.Body;
    public HttpRequest(IFeatureCollection features)=> _feature = features.Get<IHttpRequestFeature>();
}

public class HttpResponse
{
    private readonly IHttpResponseFeature _feature;

    public NameValueCollection Headers=> _feature.Headers;
    public Stream Body=> _feature.Body;
    public int StatusCode
    {
        get => _feature.StatusCode;
        set => _feature.StatusCode = value;
    }
    public HttpResponse(IFeatureCollection features)=> _feature = features.Get<IHttpResponseFeature>();
}

换句话说,我们利用HttpContext对象的Request属性提取的请求信息最初来源于IHttpRequestFeature对象,利用它的Response属性针对响应所做的任意操作最终都会作用到IHttpResponseFeature对象上。这两个对象最初是由注册的服务器提供的,这正是同一个ASP.NET Core应用可以自由地选择不同服务器类型的根源所在。

HttpListenerServer

在对服务器的职责和它与HttpContext的适配原理有了清晰的认识之后,我们可以尝试定义一个服务器。我们将接下来定义的服务器类型命名为HttpListenerServer,因为它对请求的监听、接收和响应是由一个HttpListener对象来实现的。由于服务器接收到请求之后需要借助“特性”的适配来构建统一的请求上下文(即HttpContext对象),这也是中间件的执行上下文,所以提供针对性的特性实现是自定义服务类型的关键所在。

对HttpListener有所了解的读者都知道,当它在接收到请求之后同样会创建一个HttpListenerContext对象表示请求上下文。如果使用HttpListener对象作为ASP.NET Core应用的监听器,就意味着不仅所有的请求信息会来源于这个HttpListenerContext对象,我们针对请求的响应最终也需要利用这个上下文对象来完成。HttpListenerServer对应特性所起的作用实际上就是在HttpListenerContext和HttpContext这两种上下文之间搭建起一座如下图所示的桥梁。

12-5

上图中用来在HttpListenerContext和HttpContext这两个上下文类型之间完成适配的特性类型被命名为HttpListenerFeature。如下面的代码片段所示,HttpListenerFeature类型同时实现了针对请求和响应的特性接口IHttpRequestFeature与IHttpResponseFeature。

public class HttpListenerFeature : IHttpRequestFeature, IHttpResponseFeature
{
    private readonly HttpListenerContext _context;
    public HttpListenerFeature(HttpListenerContext context) => _context = context;
    Uri IHttpRequestFeature.Url => _context.Request.Url;
    NameValueCollection IHttpRequestFeature.Headers => _context.Request.Headers;
    NameValueCollection IHttpResponseFeature.Headers => _context.Response.Headers;
    Stream IHttpRequestFeature.Body => _context.Request.InputStream;
    Stream IHttpResponseFeature.Body => _context.Response.OutputStream;
    int IHttpResponseFeature.StatusCode
    {
        get => _context.Response.StatusCode;
        set => _context.Response.StatusCode = value;
    }
}

创建HttpListenerFeature对象时需要提供一个HttpListenerContext对象,IHttpRequestFeature接口的实现成员所提供的请求信息全部来源于这个HttpListenerContext上下文,IHttpResponseFeature接口的实现成员针对响应的操作最终也转移到这个HttpListenerContext上下文上。如下所示的代码片段是针对HttpListener的服务器类型HttpListenerServer的完整定义。我们在创建HttpListenerServer对象的时候可以显式提供一组监听地址,如果没有提供,监听地址会默认设置“localhost:5000”。在实现的StartAsync方法中,我们启动了在构造函数中创建的HttpListenerServer对象,并且在一个无限循环中通过调用其GetContextAsync方法实现了针对请求的监听和接收。

public class HttpListenerServer : IServer
{
    private readonly HttpListener _httpListener;
    private readonly string[] _urls;
    public HttpListenerServer(params string[] urls)
    {
        _httpListener = new HttpListener();
        _urls = urls.Any() ? urls : new string[] { "http://localhost:5000/" };
    }

    public async Task StartAsync(RequestDelegate handler)
    {
        Array.ForEach(_urls, url => _httpListener.Prefixes.Add(url));
        _httpListener.Start();
        while (true)
        {
            var listenerContext = await _httpListener.GetContextAsync();
            var feature = new HttpListenerFeature(listenerContext);
            var features = new FeatureCollection()
                .Set<IHttpRequestFeature>(feature)
                .Set<IHttpResponseFeature>(feature);
            var httpContext = new HttpContext(features);
            await handler(httpContext);
            listenerContext.Response.Close();
        }
    }
}

当HttpListener监听到抵达的请求后,我们会得到一个HttpListenerContext对象,此时只需要利用它创建一个HttpListenerFeature对象并且分别以IHttpRequestFeature接口和IHttpResponseFeature接口的形式注册到创建的FeatureCollection集合上。我们最终利用这个FeatureCollection集合创建出代表请求上下文的HttpContext对象,当将它作为参数调用由所有注册中间件共同构建的RequestDelegate对象时,中间件管道将接管并处理该请求。

三、承载服务

到目前为止,我们已经了解构成ASP.NET Core请求处理管道的两个核心要素(服务器和中间件),现在我们的目标是利用.NET Core承载服务系统来承载这一管道。毫无疑问,还需要通过实现IHostedService接口来定义对应的承载服务,为此我们定义了一个名为WebHostedService的承载服务。(关于.NET Core承载服务系统,请参阅我的系列文章《服务承载系统》)

WebHostedService

由于服务器是整个请求处理管道的“龙头”,所以从某种意义上来说,启动一个ASP.NET Core应用就是为启动服务器,所以可以将服务的启动在WebHostedService承载服务中实现。如下面的代码片段所示,创建一个WebHostedService对象时,需要提供服务器对象和由所有注册中间件构建的RequestDelegate对象。在实现的StartAsync方法中,我们只需要调用服务器对象的StartAsync方法启动它即可。

public class WebHostedService : IHostedService
{
    private readonly IServer _server;
    private readonly RequestDelegate _handler;
    public WebHostedService(IServer server, RequestDelegate handler)
    {
        _server = server;
        _handler = handler;
    }

    public Task StartAsync(CancellationToken cancellationToken) => _server.StartAsync(_handler);
    public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

到目前为止,我们基本上已经完成了所有核心的工作,如果能够将一个WebHostedService实例注册到.NET Core的承载系统中,它就能够帮助我们启动一个ASP.NET Core应用。为了使这个过程在编程上变得更加便利和“优雅”,我们定义了一个辅助的WebHostBuilder类型。

WebHostBuilder

要创建一个WebHostedService对象,必需显式地提供一个表示服务器的IServer对象,以及由所有注册中间件构建而成的RequestDelegate对象,WebHostBuilder提供了更加便利和“优雅”的服务器与中间件注册方式。如下面的代码片段所示,WebHostBuilder是对额外两个Builder对象的封装:一个是用来构建服务宿主的IHostBuilder对象,另一个是用来注册中间件并最终帮助我们创建RequestDelegate对象的IApplicationBuilder对象。

public class WebHostBuilder
{   
    public IHostBuilder HostBuilder { get; }
    public IApplicationBuilder ApplicationBuilder { get; }
    public WebHostBuilder(IHostBuilder hostBuilder, IApplicationBuilder applicationBuilder)
    {
        HostBuilder = hostBuilder;
        ApplicationBuilder = applicationBuilder;
    }
}

我们为WebHostBuilder定义了如下两个扩展方法:UseHttpListenerServer方法完成了针对自定义的服务器类型HttpListenerServer的注册;Configure方法提供了一个Action<IApplication
Builder>类型的参数,利用该参数来注册任意中间件。

public static partial class Extensions
{
    public static WebHostBuilder UseHttpListenerServer(this WebHostBuilder builder, params string[] urls)
    {
        builder.HostBuilder.ConfigureServices(svcs => svcs.AddSingleton<IServer>(new HttpListenerServer(urls)));
        return builder;
    }

    public static WebHostBuilder Configure(this WebHostBuilder builder, Action<IApplicationBuilder> configure)
    {
        configure?.Invoke(builder.ApplicationBuilder);
        return builder;
    }
}

代表ASP.NET Core应用的请求处理管道最终是利用承载服务WebHostedService注册到.NET Core的承载系统中的,针对WebHostedService服务的创建和注册体现在为IHostBuilder接口定义的ConfigureWebHost扩展方法上。如下面的代码片段所示,ConfigureWebHost方法定义了一个Action<WebHostBuilder>类型的参数,利用该参数可以注册服务器、中间件及其他相关服务。

public static partial class Extensions
{
    public static IHostBuilder ConfigureWebHost(this IHostBuilder builder, Action<WebHostBuilder> configure)
    {
        var webHostBuilder = new WebHostBuilder(builder, new ApplicationBuilder());
        configure?.Invoke(webHostBuilder);
        builder.ConfigureServices(svcs => svcs.AddSingleton<IHostedService>(provider => {
            var server = provider.GetRequiredService<IServer>();
            var handler = webHostBuilder.ApplicationBuilder.Build();
            return new WebHostedService(server, handler);
        }));
        return builder;
    }
}

在ConfigureWebHost方法中,我们创建了一个ApplicationBuilder对象,并利用它和当前的IHostBuilder对象创建了一个WebHostBuilder对象,然后将这个WebHostBuilder对象作为参数调用了指定的Action<WebHostBuilder>委托对象。在此之后,我们调用IHostBuilder接口的ConfigureServices方法在依赖注入框架中注册了一个用于创建WebHostedService服务的工厂。对于由该工厂创建的WebHostedService对象来说,服务器来源于注册的服务,而作为请求处理器的RequestDelegate对象则由ApplicationBuilder对象根据注册的中间件构建而成。

到目前为止,这个用来模拟ASP.NET Core请求处理管道的“迷你版”框架已经构建完成,下面尝试在它上面开发一个简单的应用。如下面的代码片段所示,我们调用静态类型Host的CreateDefaultBuilder方法创建了一个IHostBuilder对象,然后调用ConfigureWebHost方法并利用提供的Action<WebHostBuilder>对象注册了HttpListenerServer服务器和3个中间件。在调用Build方法构建出作为服务宿主的IHost对象之后,我们调用其Run方法启动所有承载的IHostedSerivce服务。

class Program
{
    static void Main()
    {
        Host.CreateDefaultBuilder()
            .ConfigureWebHost(builder => builder
                .UseHttpListenerServer()
                .Configure(app => app
                    .Use(FooMiddleware)
                    .Use(BarMiddleware)
                    .Use(BazMiddleware)))
            .Build()
            .Run();
    }

    public static RequestDelegate FooMiddleware(RequestDelegate next)
        => async context =>{
            await context.Response.WriteAsync("Foo=>");
            await next(context);
        };

    public static RequestDelegate BarMiddleware(RequestDelegate next)
        => async context =>{
            await context.Response.WriteAsync("Bar=>");
            await next(context);
        };

    public static RequestDelegate BazMiddleware(RequestDelegate next)
        => context => context.Response.WriteAsync("Baz");
}

由于中间件最终体现为一个类型为Func<RequestDelegate, RequestDelegate>的委托对象,所以可以利用与之匹配的方法来定义中间件。演示实例中定义的3个中间件(FooMiddleware、BarMiddleware和BazMiddleware)对应的正是3个静态方法,它们调用WriteAsync扩展方法在响应中写了一段文字。

public static partial class Extensions
{    
    public static Task WriteAsync(this HttpResponse response, string contents)
    {
        var buffer = Encoding.UTF8.GetBytes(contents);
        return response.Body.WriteAsync(buffer, 0, buffer.Length);
    }
}

应用启动之后,如果利用浏览器向应用程序采用的默认监听地址(“http://localhost:5000”)发送一个请求,得到的输出结果如下图所示。浏览器上呈现的文字正是注册的3个中间件写入的。

12-6

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK