ASP.NET Core管道詳解[2]: HttpContext本質論
ASP.NET Core請求處理管道由一個伺服器和一組有序排列的中介軟體構成,所有中介軟體針對請求的處理都在通過HttpContext物件表示的上下文中進行。由於應用程式總是利用伺服器來完成對請求的接收和響應工作,所以原始請求上下文的描述由註冊的伺服器型別來決定。但是ASP.NET Core需要在上層提供具有一致性的程式設計模型,所以我們需要一個抽象的、不依賴具體伺服器型別的請求上下文描述,這就是本章著重介紹的HttpContext。[本文節選自《ASP.NET Core 3框架揭祕》第13章, 更多關於ASP.NET Core的文章請點這裡]
目錄
一、HttpContext
二、伺服器適配三、獲取HttpContext上下文
四、HttpContext上下文的建立與釋放
五、針對請求的DI容器-RequestServices
一、HttpContext
在《模擬管道實現》建立的模擬管道中,我們定義了一個簡易版的HttpContext類,它只包含表示請求和響應的兩個屬性,實際上,真正的HttpContext具有更加豐富的成員定義。對於一個HttpContext物件來說,除了描述請求和響應的Request屬性與Response屬性,我們還可以通過它獲取與當前請求相關的其他上下文資訊,如用來表示當前請求使用者的ClaimsPrincipal物件、描述當前HTTP連線的ConnectionInfo物件和用於控制Web Socket的WebSocketManager物件等。除此之外,我們還可以通過Session屬性獲取並控制當前會話,也可以通過TraceIdentifier屬性獲取或者設定除錯追蹤的ID。
public abstract class HttpContext { public abstract HttpRequest Request { get; } public abstract HttpResponse Response { get; } public abstract ClaimsPrincipal User { get; set; } public abstract ConnectionInfo Connection { get; } public abstract WebSocketManager WebSockets { get; } public abstract ISession Session { get; set; } public abstract string TraceIdentifier { get; set; } public abstract IDictionary<object, object> Items { get; set; } public abstract CancellationToken RequestAborted { get; set; } public abstract IServiceProvider RequestServices { get; set; } ... }
當客戶端中止請求(如請求超時)時,我們可以通過RequestAborted屬性返回的CancellationToken物件接收到通知,進而及時中止正在進行的請求處理操作。如果需要針對整個管道共享一些與當前上下文相關的資料,我們可以將它儲存在通過Items屬性表示的字典中。HttpContext的RequestServices返回的是針對當前請求的IServiceProvider物件,換句話說,該物件的生命週期與表示當前請求上下文的HttpContext物件繫結。對於一個HttpContext物件來說,表示請求和響應的Request屬性與Response屬性是它最重要的兩個成員,請求通過如下這個抽象類HttpRequest表示。
public abstract class HttpRequest { public abstract HttpContext HttpContext { get; } public abstract string Method { get; set; } public abstract string Scheme { get; set; } public abstract bool IsHttps { get; set; } public abstract HostString Host { get; set; } public abstract PathString PathBase { get; set; } public abstract PathString Path { get; set; } public abstract QueryString QueryString { get; set; } public abstract IQueryCollection Query { get; set; } public abstract string Protocol { get; set; } public abstract IHeaderDictionary Headers { get; } public abstract IRequestCookieCollection Cookies { get; set; } public abstract string ContentType { get; set; } public abstract Stream Body { get; set; } public abstract bool HasFormContentType { get; } public abstract IFormCollection Form { get; set; } public abstract Task<IFormCollection> ReadFormAsync(CancellationToken cancellationToken); }
在瞭解了表示請求的抽象類HttpRequest之後,下面介紹另一個與之相對的用於描述響應的HttpResponse型別。如下面的程式碼片段所示,HttpResponse依然是一個抽象類,我們可以通過它定義的屬性和方法來控制對請求的響應。從原則上講,我們對請求所做的任意形式的響應都可以利用它來實現。當通過表示當前上下文的HttpContext物件得到表示響應的HttpResponse物件之後,我們不僅可以將內容寫入響應訊息的主體部分,還可以設定響應狀態碼,並新增相應的報頭。
public abstract class HttpResponse { public abstract HttpContext HttpContext { get; } public abstract int StatusCode { get; set; } public abstract IHeaderDictionary Headers { get; } public abstract Stream Body { get; set; } public abstract long? ContentLength { get; set; } public abstract IResponseCookies Cookies { get; } public abstract bool HasStarted { get; } public abstract void OnStarting(Func<object, Task> callback, object state); public virtual void OnStarting(Func<Task> callback); public abstract void OnCompleted(Func<object, Task> callback, object state); public virtual void RegisterForDispose(IDisposable disposable); public virtual void OnCompleted(Func<Task> callback); public virtual void Redirect(string location); public abstract void Redirect(string location, bool permanent); }
二、伺服器適配
由於應用程式總是利用這個抽象的HttpContext上下文來獲取與當前請求有關的資訊,需要完成的所有響應操作也總是作用在這個HttpContext物件上,所以不同的伺服器與這個抽象的HttpContext需要進行“適配”。通過《模擬管道實現》針對模擬框架的介紹可知,ASP.NET Core框架會採用一種針對特性(Feature)的適配方式。
如下圖所示,ASP.NET Core框架為抽象的HttpContext定義了一系列標準的特性介面來對請求上下文的各個方面進行描述。在一系列標準的介面中,最核心的是用來描述請求的IHttpRequestFeature介面和描述響應的IHttpResponseFeature介面。我們在應用層使用的HttpContext上下文就是根據這樣一組特性集合來建立的,對於某個具體的伺服器來說,它需要提供這些特性介面的實現,並在接收到請求之後利用自行實現的特性來建立HttpContext上下文。
由於HttpContext上下文是利用伺服器提供的特性集合建立的,所以可以統一使用抽象的HttpContext獲取真實的請求資訊,也能驅動伺服器完成最終的響應工作。在ASP.NET Core框架中,由伺服器提供的特性集合通過IFeatureCollection介面表示。《模擬管道實現》建立的模擬框架為IFeatureCollection介面提供了一個極簡版的定義,實際上該介面具有更加豐富的成員定義。
public interface IFeatureCollection : IEnumerable<KeyValuePair<Type, object>> { TFeature Get<TFeature>(); void Set<TFeature>(TFeature instance); bool IsReadOnly { get; } object this[Type key] { get; set; } int Revision { get; } }
如上面的程式碼片段所示,一個IFeatureCollection物件本質上就是一個Key和Value型別分別為Type與Object的字典。通過呼叫Set方法可以將一個特性物件作為Value,以指定的型別(一般為特性介面)作為Key新增到這個字典中,並通過Get方法根據該型別獲取它。除此之外,特性的註冊和獲取也可以利用定義的索引來完成。如果IsReadOnly屬性返回True,就意味著不能註冊新的特性或者修改已經註冊的特性。整數型別的只讀屬性Revision可以視為IFeatureCollection物件的版本,不論是採用何種方式註冊新的特性還是修改現有的特性,都將改變該屬性的值。
具有如下定義的FeatureCollection型別是對IFeatureCollection介面的預設實現。它具有兩個建構函式過載:預設無參建構函式幫助我們建立一個空的特性集合,另一個建構函式則需要指定一個IFeatureCollection物件來提供預設或者後備特性物件。對於採用第二個建構函式建立的 FeatureCollection物件來說,當我們通過指定的型別試圖獲取對應的特性物件時,如果沒有註冊到當前FeatureCollection物件上,它會從這個後備的IFeatureCollection物件中查詢目標特性。
public class FeatureCollection : IFeatureCollection { //其他成員 public FeatureCollection(); public FeatureCollection(IFeatureCollection defaults); }
對於一個FeatureCollection物件來說,它的IsReadOnly屬性總是返回False,所以它永遠是可讀可寫的。對於呼叫預設無參建構函式建立的FeatureCollection物件來說,它的Revision屬性預設返回零。如果我們通過指定另一個IFeatureCollection物件為引數呼叫第二個建構函式來建立一個FeatureCollection物件,前者的Revision屬性值將成為後者同名屬性的預設值。無論採用何種形式(呼叫Set方法或者索引)新增一個新的特性或者改變一個已經註冊的特性,FeatureCollection物件的Revision屬性都將自動遞增。上述這些特性都體現在如下所示的除錯斷言中。
var defaults = new FeatureCollection(); Debug.Assert(defaults.Revision == 0); defaults.Set<IFoo>(new Foo()); Debug.Assert(defaults.Revision == 1); defaults[typeof(IBar)] = new Bar(); Debug.Assert(defaults.Revision == 2); FeatureCollection features = new FeatureCollection(defaults); Debug.Assert(features.Revision == 2); Debug.Assert(features.Get<IFoo>().GetType() == typeof(Foo)); features.Set<IBaz>(new Baz()); Debug.Assert(features.Revision == 3);
最初由伺服器提供的IFeatureCollection物件體現在HttpContext型別的Features屬性上。雖然特性最初是為了解決不同的伺服器型別與統一的HttpContext上下文之間的適配設計的,但是它的作用不限於此。由於註冊的特性是附加在代表當前請求的HttpContext上下文上,所以可以將任何基於當前請求的物件以特性的方式進行儲存,它其實與Items屬性的作用類似。
public abstract class HttpContext { public abstract IFeatureCollection Features { get; } ... }
上述這種基於特性來實現不同型別的伺服器與統一請求上下文之間的適配體現在DefaultHttpContext型別上,它是對HttpContext這個抽象型別的預設實現。DefaultHttpContext具有一個如下所示的建構函式,作為引數的IFeatureCollection物件就是由伺服器提供的特性集合。
public class DefaultHttpContext : HttpContext { public DefaultHttpContext(IFeatureCollection features); }
不論是組成管道的中介軟體還是建立在管道上的應用,在預設情況下都利用DefaultHttpContext物件來獲取當前請求的相關資訊,並利用這個物件完成針對請求的響應。但是DefaultHttpContext物件在這個過程中只是一個“代理”,針對它的呼叫(屬性或者方法)最終都需要轉發給由具體伺服器建立的那個原始上下文,在建構函式中指定的IFeatureCollection物件所代表的特性集合成為這兩個上下文物件進行溝通的唯一渠道。對於定義在DefaultHttpContext中的所有屬性,它們幾乎都具有一個對應的特性,這些特性都對應一個介面。
本章我們只介紹表示請求和響應的IHttpRequestFeature介面與IHttpResponseFeature介面。從下面給出的程式碼片段可以看出,這兩個介面具有與抽象類HttpRequest和HttpResponse一致的定義。對於DefaultHttpContext型別來說,它的Request屬性和Response屬性返回的具體型別為DefaultHttpRequest與DefaultHttpResponse,它們分別利用這兩個特性實現了定義在基類(HttpRequest和HttpResponse)的所有抽象成員。
public interface IHttpRequestFeature { Stream Body { get; set; } IHeaderDictionary Headers { get; set; } string Method { get; set; } string Path { get; set; } string PathBase { get; set; } string Protocol { get; set; } string QueryString { get; set; } string Scheme { get; set; } } public interface IHttpResponseFeature { Stream Body { get; set; } bool HasStarted { get; } IHeaderDictionary Headers { get; set; } string ReasonPhrase { get; set; } int StatusCode { get; set; } void OnCompleted(Func<object, Task> callback, object state); void OnStarting(Func<object, Task> callback, object state); }
三、獲取HttpContext上下文
如果第三方元件需要獲取表示當前請求上下文的HttpContext物件,就可以通過注入IHttpContextAccessor服務來實現。IHttpContextAccessor物件提供如下所示的HttpContext屬性返回針對當前請求的HttpContext物件,由於該屬性並不是只讀的,所以當前的HttpContext也可以通過該屬性進行設定。
public interface IHttpContextAccessor { HttpContext HttpContext { get; set; } }
ASP.NET Core框架提供的HttpContextAccessor型別可以作為IHttpContextAccessor介面的預設實現(真實實現稍有不同)。從如下所示的程式碼片段可以看出,HttpContextAccessor將提供的HttpContext物件以一個AsyncLocal<HttpContext>物件的方式儲存起來,所以在整個請求處理的非同步處理流程中都可以利用它得到同一個HttpContext物件。
public class HttpContextAccessor : IHttpContextAccessor { private static AsyncLocal<HttpContext> _httpContextCurrent = new AsyncLocal<HttpContext>(); public HttpContext HttpContext { get => _httpContextCurrent.Value; set => _httpContextCurrent.Value = value; } }
針對IHttpContextAccessor/HttpContextAccessor的服務註冊可以通過如下所示的AddHttpContextAccessor擴充套件方法來完成。由於它呼叫的是IServiceCollection介面的TryAddSingleton<TService, TImplementation>擴充套件方法,所以不用擔心多次呼叫該方法而出現服務的重複註冊問題。
public static class HttpServiceCollectionExtensions { public static IServiceCollection AddHttpContextAccessor( this IServiceCollection services) { services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>(); return services; } }
四、HttpContext上下文的建立與釋放
利用注入的IHttpContextAccessor服務的HttpContext屬性得到當前HttpContext上下文的前提是該屬性在此之前已經被賦值,在預設情況下,該屬性是通過預設註冊的IHttpContextFactory服務賦值的。管道在開始處理請求前對HttpContext上下文的建立,以及請求處理完成後對它的回收釋放都是通過IHttpContextFactory物件完成的。IHttpContextFactory介面定義瞭如下兩個方法:Create方法會根據提供的特性集合來建立HttpContext物件,Dispose方法則負責將提供的HttpContext物件釋放。
public interface IHttpContextFactory { HttpContext Create(IFeatureCollection featureCollection); void Dispose(HttpContext httpContext); }
ASP.NET Core框架提供如下所示的DefaultHttpContextFactory型別作為對IHttpContextFactory介面的預設實現,作為預設HttpContext上下文的 DefaultHttpContext物件就是由它建立的。如下面的程式碼片段所示,在IHttpContextAccessor服務被註冊的情況下,ASP.NET Core框架將呼叫第二個建構函式來建立HttpContextFactory物件。在Create方法中,它根據提供的IFeatureCollection物件建立一個DefaultHttpContext物件,在返回該物件之前,它會將該物件賦值給IHttpContextAccessor物件的HttpContext屬性。
public class DefaultHttpContextFactory : IHttpContextFactory { private readonly IHttpContextAccessor _httpContextAccessor; private readonly FormOptions _formOptions; private readonly IServiceScopeFactory _serviceScopeFactory; public DefaultHttpContextFactory(IServiceProvider serviceProvider) { _httpContextAccessor = serviceProvider.GetService<IHttpContextAccessor>(); _formOptions = serviceProvider.GetRequiredService<IOptions<FormOptions>>().Value; _serviceScopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>(); } public HttpContext Create(IFeatureCollection featureCollection) { var httpContext = CreateHttpContext(featureCollection); if (_httpContextAccessor != null) { _httpContextAccessor.HttpContext = httpContext; } httpContext.FormOptions = _formOptions; httpContext.ServiceScopeFactory = _serviceScopeFactory; return httpContext; } private static DefaultHttpContext CreateHttpContext(IFeatureCollection featureCollection) { if (featureCollection is IDefaultHttpContextContainer container) { return container.HttpContext; } return new DefaultHttpContext(featureCollection); } public void Dispose(HttpContext httpContext) { if (_httpContextAccessor != null) { _httpContextAccessor.HttpContext = null; } } }
如上面的程式碼片段所示,HttpContextFactory在創建出DefaultHttpContext物件並將它設定到IHttpContextAccessor物件的HttpContext屬性上之後,它還會設定DefaultHttpContext物件的FormOptions屬性和ServiceScopeFactory屬性,前者表示針對表單的配置選項,後者是用來建立服務範圍的工廠。當Dispose方法執行的時候,DefaultHttpContextFactory物件會將IHttpContextAccessor服務的HttpContext屬性設定為Null。
五、針對請求的DI容器-RequestServices
ASP.NET Core框架中存在兩個用於提供所需服務的依賴注入容器:一個針對應用程式,另一個針對當前請求。繫結到HttpContext上下文RequestServices屬性上針對當前請求的IServiceProvider來源於通過IServiceProvidersFeature介面表示的特性。如下面的程式碼片段所示,IServiceProvidersFeature介面定義了唯一的屬性RequestServices,可以利用它設定和獲取與請求繫結的IServiceProvider物件。
public interface IServiceProvidersFeature { IServiceProvider RequestServices { get; set; } }
如下所示的RequestServicesFeature型別是對IServiceProvidersFeature介面的預設實現。如下面的程式碼片段所示,當我們建立一個RequestServicesFeature物件時,需要提供當前的HttpContext上下文和建立服務範圍的IServiceScopeFactory工廠。RequestServicesFeature物件的RequestServices屬性提供的IServiceProvider物件來源於IServiceScopeFactory物件建立的服務範圍,在請求處理過程中提供的Scoped服務例項的生命週期被限定在此範圍之內。
public class RequestServicesFeature : IServiceProvidersFeature, IDisposable, IAsyncDisposable { private readonly IServiceScopeFactory _scopeFactory; private IServiceProvider _requestServices; private IServiceScope _scope; private bool _requestServicesSet; private readonly HttpContext _context; public RequestServicesFeature(HttpContext context, IServiceScopeFactory scopeFactory) { _context = context; _scopeFactory = scopeFactory; } public IServiceProvider RequestServices { get { if (!_requestServicesSet && _scopeFactory != null) { _context.Response.RegisterForDisposeAsync(this); _scope = _scopeFactory.CreateScope(); _requestServices = _scope.ServiceProvider; _requestServicesSet = true; } return _requestServices; } set { _requestServices = value; _requestServicesSet = true; } } public ValueTask DisposeAsync() { switch (_scope) { case IAsyncDisposable asyncDisposable: var vt = asyncDisposable.DisposeAsync(); if (!vt.IsCompletedSuccessfully) { return Awaited(this, vt); } vt.GetAwaiter().GetResult(); break; case IDisposable disposable: disposable.Dispose(); break; } _scope = null; _requestServices = null; return default; static async ValueTask Awaited(RequestServicesFeature servicesFeature, ValueTask vt) { await vt; servicesFeature._scope = null; servicesFeature._requestServices = null; } } public void Dispose() => DisposeAsync().ConfigureAwait(false).GetAwaiter().GetResult(); }
為了在完成請求處理之後釋放所有非Singleton服務例項,我們必須及時釋放建立的服務範圍。針對服務範圍的釋放實現在DisposeAsync方法中,該方法是針對IAsyncDisposable介面的實現。在服務範圍被建立時,RequestServicesFeature物件會呼叫表示當前響應的HttpResponse物件的RegisterForDisposeAsync方法將自身新增到需要釋放的物件列表中,當響應完成之後,DisposeAsync方法會自動被呼叫,進而將針對當前請求的服務範圍聯通該範圍內的服務例項釋放。
前面提及,除了建立返回的DefaultHttpContext物件,DefaultHttpContextFactory物件還會設定用於建立服務範圍的工廠(對應如下所示的ServiceScopeFactory屬性)。用來提供基於當前請求依賴注入容器的RequestServicesFeature特性正是根據IServiceScopeFactory物件建立的。
public sealed class DefaultHttpContext : HttpContext { public override IServiceProvider RequestServices {get;set} public IServiceScopeFactory ServiceScopeFactory { get; set; } }