一个Mini的ASP.NET Core框架的实现
一、ASP.NET Core Mini
在2019年1月的微软技术(苏州)俱乐部成立大会上,蒋金楠老师(大内老A)分享了一个名为“ASP.NET Core框架揭秘”的课程,他用不到200行的代码实现了一个ASP.NET Core Mini框架,重点讲解了7个核心对象,围绕ASP.NET Core最核心的本质—由服务器和若干中间件构成的管道来介绍。我在腾讯视频上看到了这个课程的录像,看了两遍之后结合蒋金楠老师的博客《200行代码,7个对象—让你了解ASP.NET Core框架的本质》一文进行了学习并下载了源代码进行研究,然后将其改成了基于.NET Standard的版本,通过一个.NET Framework和一个.NET Core的宿主端来启动一个ASP.NET Core的Server,并将其放到了GitHub上,欢迎Clone学习。
ASP.NET Core Mini是一个十分值得学习的小项目,它真实模拟了ASP.NET Core的核心,而且又足够简单(不到200行代码),最重要的是它可以执行(我们可以通过Debug的方式一步一步地查看)。本文基于蒋金楠老师的那篇博客,基于学习者的视角Run一遍这个ASP.NET Core Mini框架,一步一步地了解它的流程,了解中间件在ASP.NET Core中的作用。当然,最好先看看蒋金楠老师的博客和ASP.NET Core Mini的代码,本文只是我的一个学习总结,部分文字来源于蒋金楠老师的博文。
二、Run起来看流程
2.1 项目结构与整体流程一览
这个示例项目由三部分组成:
第一部分是AspNetCore.Mini.Core,这是一个ASP.NET Core框架的Mini实现,封装在了一个.NET Standard 2.0的类库中,可以供.NET Framework和.NET Core应用程序使用;
第二部分是AspNetCore.Mini.App,这是一个基于.NET Framework 4.6.1的控制台应用程序,它是一个使用了AspNetCore.Mini.Core的宿主程序,可以直接执行;
第三部分是AspNetCore.Mini.AppCore,这是一个基于.NET Core 2.1的控制台应用程序,它是一个使用了AspNetCore.Mini.Core的宿主程序,可以直接执行;
宿主程序的核心启动代码如下所示:
public class Program { public static void Main(string[] args) { CreateWebHostBuilder(args) .Build() .Run(); Console.ReadKey(); } public static IWebHostBuilder CreateWebHostBuilder(string[] args) { return new WebHostBuilder() .UseHttpListener() .Configure(app => app .Use(FooMiddleware) .Use(BarMiddleware) .Use(BazMiddleware)); } #region 自定义中间件 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"); #endregion }
整个项目的运行流程大致如下图所示:
首先,会通过一个WebHostBuilder来构造一个WebHost,这个过程会经历指定具体的Server(比如ASP.NET Core中的Kestrel或IIS等等),然后指定要注册的中间件(比如MVC中间件,文件服务中间件,内存缓存中间件等等)。构造好了WebHost之后,便会启动这个WebHost,启动这个WebHost的核心就在于启动刚刚注册的Server,让它绑定指定的端口开始监听(这部分内容涉及到Socket网络程序,不熟悉的朋友可以看看我的这一篇《自己动手模拟开发一个简单的Web服务器》)请求,当有请求到达时便会进行相应的请求处理流程。而这里的请求处理流程主要是封装请求上下文,依次调用注册的中间件进行处理,然后结束请求处理流程,这时候用户就可以在浏览器中看到响应的内容了。
上面介绍了一个大概的流程,下面我们就来具体看看每一步的具体内容。
2.2 WebHost与WebHostBuilder
WebHostBuilder,看这个名字就应该知道它采用了Builder模式,它的目的也很明确:创建作为应用宿主的WebHost。而在创建WebHost的时候,需要提供注册的服务器和由所有注册中间件构建而成的请求处理委托集,其接口IWebHostBuilder定义如下:
public interface IWebHostBuilder { IWebHostBuilder UseServer(IServer server); IWebHostBuilder Configure(Action<IApplicationBuilder> configure); IWebHost Build(); }
其中,UserServer方法用来指定要运行的Server,在ASP.NET Core中我们经常用到的是UseKestrel()方法来指定要运行的Server是Kestrel,这是一个基于libuv的跨平台ASP.NET Core web服务器。Configure方法则主要用来注册中间件,其中IApplicationBuilder是一个请求处理的核心构造器接口,它是注册和使用中间件的入口。
下面是在示例项目中实现的一个WebHostBuilder类:
public class WebHostBuilder : IWebHostBuilder { private IServer _server; private readonly List<Action<IApplicationBuilder>> _configures = new List<Action<IApplicationBuilder>>(); /// <summary> /// 配置中间件 /// </summary> /// <param name="configure">中间件</param> /// <returns>IWebHostBuilder</returns> public IWebHostBuilder Configure(Action<IApplicationBuilder> configure) { _configures.Add(configure); return this; } /// <summary> /// 指定要使用的具体Server /// </summary> /// <param name="server">具体Server</param> /// <returns>IWebHostBuilder</returns> public IWebHostBuilder UseServer(IServer server) { _server = server; return this; } /// <summary> /// 构造具体WebHost应用宿主 /// </summary> /// <returns>IWebHost</returns> public IWebHost Build() { var builder = new ApplicationBuilder(); foreach (var configure in _configures) { configure(builder); } return new WebHost(_server, builder.Build()); } }
可以看到,其核心就在于Build方法:创建一个WebHost实例,这个WebHost实例会关联到指定的Server以及注册的中间件集合。
那么,这个WebHost又长啥样呢?先来看看IWebHost接口:只定义了一个Run方法,它是启动Server的入口。
public interface IWebHost { Task Run(); }
下面是WebHost的实现,其核心就在于将中间件传递给Server并启动Server:
public class WebHost : IWebHost { private readonly IServer _server; private readonly RequestDelegate _handler; public WebHost(IServer server, RequestDelegate handler) { _server = server; _handler = handler; } /// <summary> /// 调用Server的启动方法进行启动 /// </summary> /// <returns></returns> public Task Run() => _server.RunAsync(_handler); }
2.3 基于HttpListener的Server
刚刚在WebHost中注入了Server,并启动了Server。那么,这个Server长啥样呢?我们知道,在ASP.NET Core中封装了Kestrel和IIS两个Server供我们使用,那么它们肯定有一个抽象层(这里是接口),定义了他们共有的行为,这里我们也写一个IServer:
public interface IServer { Task RunAsync(RequestDelegate handler); }
IServer接口行为很简单,就是约定一个启动的方法RunAsync,接受参数是中间件(本质就是一个请求处理的委托)。
有了IServer接口,就可以基于IServer封装基于不同平台的WebServer了,这里基于HttpListener实现了一个HttpListenerServer如下(HttpListener简化了Http协议的监听,仅需通过字符串的方法提供监听的地址和端口号以及虚拟路径,就可以开始监听请求):
public class HttpListenerServer : IServer { private readonly HttpListener _httpListener; private readonly string[] _urls; public HttpListenerServer(params string[] urls) { _httpListener = new HttpListener(); // 绑定默认监听地址(默认端口为5000) _urls = urls.Any() ? urls : new string[] { "http://localhost:5000/" }; } public async Task RunAsync(RequestDelegate handler) { Array.ForEach(_urls, url => _httpListener.Prefixes.Add(url)); if (!_httpListener.IsListening) { // 启动HttpListener _httpListener.Start(); } Console.WriteLine("[Info]: Server started and is listening on: {0}", string.Join(";", _urls)); while (true) { // 等待传入的请求,该方法将阻塞进程(这里使用了await),直到收到请求 var listenerContext = await _httpListener.GetContextAsync(); // 打印状态行: 请求方法, URL, 协议版本 Console.WriteLine("{0} {1} HTTP/{2}", listenerContext.Request.HttpMethod, listenerContext.Request.RawUrl, listenerContext.Request.ProtocolVersion); // 获取抽象封装后的HttpListenerFeature var feature = new HttpListenerFeature(listenerContext); // 获取封装后的Feature集合 var features = new FeatureCollection() .Set<IHttpRequestFeature>(feature) .Set<IHttpResponseFeature>(feature); // 创建HttpContext var httpContext = new HttpContext(features); Console.WriteLine("[Info]: Server process one HTTP request start."); // 开始依次执行中间件 await handler(httpContext); Console.WriteLine("[Info]: Server process one HTTP request end."); // 关闭响应 listenerContext.Response.Close(); } } } /// <summary> /// IWebHostBuilder扩展:使用基于HttpListener的Server /// </summary> public static partial class Extensions { public static IWebHostBuilder UseHttpListener(this IWebHostBuilder builder, params string[] urls) => builder.UseServer(new HttpListenerServer(urls)); }
有了Server,也有了中间件,我们要进行处理的上下文在哪里?熟悉ASP.NET请求处理的童鞋都知道,我们会操作一个叫做HttpContext的东西,它包裹了一个HttpRequest和一个HttpResponse,我们要进行的处理操作就是拿到HttpRequest里面的各种参数进行处理,然后将返回的结果包裹或调用HttpResponse的某些方法进行响应返回。在ASP.NET Core Mini中,也不例外,我们会创建一个HttpContext,然后将这个HttpContext传递给注册的中间件,各个中间件也可以拿到这个HttpContext去做具体的处理了。但是,不同的Server和单一的HttpContext之间需要如何适配呢?因为我们可以注册多样的Server,可以是IIS也可以是Kestrel还可以是这里的HttpListenerServer。
这时候,我们又可以提取一个抽象层了,如上图所示,底层是具体的基于不同平台技术的Server,上层是HttpContext共享上下文,中间层是一个抽象层,它是基于不同Server抽象出来的接口,本质是不同Server的适配器,下面就是这个IFeature的定义:
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; } }
这里不再解释,下面来看看HttpListener的适配的实现:
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 { return _context.Response.StatusCode; } set { _context.Response.StatusCode = value; } } }
可以看出,这是一个典型的适配器模式的应用,通过一个抽象层接口,为不同Server提供HttpRequest和HttpResponse对象的核心属性。
2.4 Middleware与ApplicationBuilder
在启动项目中,定义了三个中间件如下所示:
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");
可以看到,每个中间件的作用都很简单,就是向响应流中输出一个字符串。其中Foo和Bar两个中间件在输出之后,还会调用下一个中间件进行处理,而Baz不会调用下一个中间件进行处理,因此Baz在注册顺序上排在了最后,这也解释了我们为何在ASP.NET Core中进行中间件的注册时,注册的顺序比较讲究,因为这会影响到后面的执行顺序。
刚刚在进行WebHost的创建时,调用了WebHostBuilder的Configure方法进行中间件的注册,而这个Configure方法的输入参数是一个IApplicationBuilder的委托:
public interface IApplicationBuilder { IApplicationBuilder Use(Func<RequestDelegate, RequestDelegate> middleware); RequestDelegate Build(); }
可能直接看这个接口定义不是太明白,下面来看看ApplicationBuilder的实现:
public class ApplicationBuilder : IApplicationBuilder { private readonly List<Func<RequestDelegate, RequestDelegate>> _middlewares = new List<Func<RequestDelegate, RequestDelegate>>(); /// <summary> /// 构建请求处理管道 /// </summary> /// <returns>RequestDelegate</returns> public RequestDelegate Build() { _middlewares.Reverse(); // 倒置注册中间件集合的顺序 return httpContext => { // 注册默认中间件 => 返回404响应 RequestDelegate next = _ => { _.Response.StatusCode = 404; return Task.CompletedTask; }; // 构建中间件处理管道 foreach (var middleware in _middlewares) { next = middleware(next); } return next(httpContext); }; } /// <summary> /// 注册中间件 /// </summary> /// <param name="middleware">中间件</param> /// <returns>ApplicationBuilder</returns> public IApplicationBuilder Use(Func<RequestDelegate, RequestDelegate> middleware) { _middlewares.Add(middleware); return this; } }
其中,Use方法的作用就是接受中间件进行注册,Build方法的作用就是构建由注册中间件组成的请求处理管道,而Server加上这个由中间件组成的请求处理管道便是ASP.NET Core的核心内容。因此,我们可以说ASP.NET Core Pipeline = Server + Middlewares。此外,我们还可以将多个Middleware构建成一个单一的“HttpHandler”,那么整个ASP.NET Core框架将具有更加简单的表达:Pipeline = Server + HttpHandler
因此,这里的Build方法中做了以下几件事情:
(1)倒置注册中间件集合的顺序
_middlewares.Reverse();
为什么要倒置顺序呢?不是说执行顺序要跟注册顺序保持一致么?别急,且看后面的代码。
(2)注册默认中间件
return httpContext => { // 注册默认中间件 => 返回404响应 RequestDelegate next = _ => { _.Response.StatusCode = 404; return Task.CompletedTask; }; ...... }
这里默认中间件是返回404,在如果没有手动注册任何中间件的情况下生效。
(3)构建一个中间件处理管道 => "HttpHandler"
public RequestDelegate Build() { ...... return httpContext => { ...... // 构建中间件处理管道 foreach (var middleware in _middlewares) { next = middleware(next); } return next(httpContext); }; }
在通过Use方法注册多个中间件到middlewares集合中后,会在这里通过一个遍历组成一个单一的middleware(在这里表示为一个RequestDelegate对象),如下图所示。
对于middleware,它在这里是一个Func<RequestDeletegate, RequestDelegate>对象,它的输入和输出都是RequestDelegate。
对于管道的中的某一个middleware来说,由后续middleware组成的管道体现为一个RequestDelegate对象,由于当前middleware在完成了自身的请求处理任务之后,往往需要将请求分发给后续middleware进行处理,所以它需要将由后续中间件构成的RequestDelegate作为输入。当代表中间件的委托对象执行之后,我们希望的是将当前中间件“纳入”这个管道,那么新的管道体现的RequestDelegate自然成为了输出结果。
因此,这里也就解释了为什么要在第一步中进行middleware的顺序的倒置,否则无法以注册的顺序构成一个单一的middleware,下图是示例代码中的所有middleware构成的一个单一的RequestDelegate,经过层层包裹,以达到依次执行各个middleware的效果。需要注意的就是在BazMiddleware中,没有调用下一个中间件,因此404中间件便不会得到触发处理的机会。
最后我们再借用微软官方文档中的一张图来看看Middleware在ASP.NET Core中的处理过程:
如果再结合更多的ASP.NET Core内置Middlewares来看,整个ASP.NET Core请求处理管道就应该是如下图所示的样子:
下图是最后的执行结果:
三、小结
经过蒋金楠老师的讲解以及自己的学习,对这个Mini版的ASP.NET Core框架有了一个初步的理解,正如蒋老师所说,ASP.NET Core的核心就在于由一个服务器和若干中间件构成的管道,了解了这一点,就对ASP.NET Core的核心本质有了大概印象。当然,这个Mini版的ASP.NET Core只是模拟了ASP.NET Core的冰山一角,还有许多的特性都没有,比如基于Starup来注册中间件,依赖注入框架,配置系统,预定义中间件等等等等,但是对于广大ASP.NET Core学习者来说是个绝佳的入门,最后感谢大内老A的分享!
参考资料
蒋金楠,《200行代码,7个对象—让你了解ASP.NET Core框架的本质》
蒋金楠,《Inside ASP.NET Core Framework》
Vam,《asp.net core 中间件管道底层剖析》