200行程式碼,7個對象——讓你了解ASP.NET Core框架的本質[3.x版]
- 2020 年 3 月 9 日
- 筆記
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承載的最核心的上下文資訊。
我們可以將請求和響應理解為一個Web應用的輸入與輸出,既然HttpContext上下文是針對請求和響應的封裝,那麼應用程式就可以利用這個上下文對象得到當前請求所有的輸入資訊,也可以利用它完成我們所需的所有輸出工作。所以,我們為ASP.NET Core模擬框架定義了如下這個極簡版本的HttpContext類型。
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; } }
如上面的程式碼片段所示,我們可以利用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對象作為輸入。
當代表當前中間件的委託對象執行之後,如果將它自己「納入」這個管道,那麼代表新管道的RequestDelegate對象就成為該委託對象執行後的輸出結果,所以中間件自然就表示成輸入和輸出類型均為RequestDelegate的Func<RequestDelegate, RequestDelegate>對象。
中間件管道的構建
從事軟體行業10多年來,筆者對架構設計越來越具有這樣的認識:好的設計一定是「簡單」的設計。所以在設計某個開發框架時筆者的目標是再簡單點。上面介紹的請求處理管道的設計就具有「簡單」的特質:Pipeline = Server + Middlewares。但是「再簡單點」其實是可以的,我們可以將多個中間件組成一個單一的請求處理器。請求處理器可以通過RequestDelegate對象來表示,所以整個請求處理管道將具有更加簡單的表達:Pipeline = Server + RequestDelegate(見下圖12)。
表示中間件的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某個方面的抽象化描述。
如上圖所示,我們可以定義一系列特性介面來為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這兩種上下文之間搭建起一座如下圖所示的橋樑。
上圖中用來在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個中間件寫入的。