一、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 中間件管道底層剖析》