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依然支持這樣的應用承載方式,但是本系列不會涉及這種“過時”的承載方式。
除了承載Web應用本身,我們還有針對后台服務的承載需求,為此微軟推出了以IHostBuilder/IHost為核心的承載系統,我們在《服務承載系統》中已經對該系統做了詳細的介紹。實際上,Web應用本身就是一個長時間運行的后台服務,我們完全可以定義一個承載服務,從而將Web應用承載於這個系統中。如下圖所示,這個用來承載ASP.NET Core應用的承載服務類型為GenericWebHostService,這是一個實現了IHostedService接口的內部類型。
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對象上。由服務器和中間件組成的請求處理管道如下圖所示。
建立在ASP.NET Core之上的應用基本上是根據某個框架開發的。一般來說,開發框架本身就是通過某一個或者多個中間件構建起來的。以ASP.NET Core MVC開發框架為例,它借助“路由”中間件實現了請求與Action之間的映射,並在此基礎之上實現了激活(Controller)、執行(Action)及呈現(View)等一系列功能。應用程序可以視為某個中間件的一部分,如果一定要將它獨立出來,由服務器、中間件和應用組成的管道如下圖所示。
三、中間件
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分發給后續管道做進一步處理。
表示中間件的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”)發送請求,那么兩個中間件寫入的字符串會以下圖所示的形式呈現出來。
雖然可以直接采用原始的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服務。
ASP.NET Core編程模式[1]:管道式的請求處理
ASP.NET Core編程模式[2]:依賴注入的運用
ASP.NET Core編程模式[3]:配置多種使用形式
ASP.NET Core編程模式[4]:基於承載環境的編程
ASP.NET Core編程模式[5]:如何放置你的初始化代碼