ASP.NET Core的請求處理管道由一個服務器和一組中間件構成,但對於面向傳輸層的服務器來說,它其實沒有中間件的概念。當服務器接收到請求之后,會將該請求分發給一個處理器進行處理,對服務器而言,這個處理器就是一個HTTP應用,此應用通過IHttpApplication<TContext>接口來表示。由於服務器是通過IServer接口表示的,所以可以將ASP.NET Core框架的核心視為由IServer和IHttpApplication<TContext>對象組成的管道。[本文節選自《ASP.NET Core 3框架揭秘》第13章, 更多關於ASP.NET Core的文章請點這里]
目錄
一、服務器(IServer)
二、承載應用( HostingApplication)
三、應用生命周期和請求日志
ILogger日志
DiagnosticSource診斷日志
EventSource事件日志
一、服務器(IServer)
由於服務器是整個請求處理管道的“龍頭”,所以啟動和關閉應用的最終目的是啟動和關閉服務器。ASP.NET Core框架中的服務器通過IServer接口來表示,該接口具有如下所示的3個成員,其中由服務器提供的特性就保存在其Features屬性表示的IFeatureCollection集合中。IServer接口的StartAsync<TContext>方法與StopAsync方法分別用來啟動和關閉服務器。
public interface IServer : IDisposable { IFeatureCollection Features { get; } Task StartAsync<TContext>(IHttpApplication<TContext> application, CancellationToken cancellationToken); Task StopAsync(CancellationToken cancellationToken); }
服務器在開始監聽請求之前總是綁定一個或者多個監聽地址,這個地址是應用程序從外部指定的。具體來說,應用程序指定的監聽地址會封裝成一個特性,並且在服務器啟動之前被添加到它的特性集合中。這個承載了監聽地址列表的特性通過如下所示的IServerAddressesFeature接口來表示,該接口除了有一個表示地址列表的Addresses屬性,還有一個布爾類型的PreferHostingUrls屬性,該屬性表示如果監聽地址同時設置到承載系統配置和服務器上,是否優先考慮使用前者。
public interface IServerAddressesFeature { ICollection<string> Addresses { get; } bool PreferHostingUrls { get; set; } }
正如前面所說,服務器將用來處理由它接收請求的處理器會被視為一個通過IHttpApplication<TContext>接口表示的應用,所以可以將ASP.NET Core的請求處理管道視為IServer對象和IHttpApplication<TContext>對象的組合。當調用IServer對象的StartAsync<TContext>方法啟動服務器時,我們需要提供這個用來處理請求的IHttpApplication<TContext>對象。IHttpApplication<TContext>采用基於上下文的請求處理方式,泛型參數TContext代表的就是上下文的類型。在IHttpApplication<TContext>處理請求之前,它需要先創建一個上下文對象,該上下文會在請求處理結束之后被釋放。上下文的創建、釋放和自身對請求的處理實現在該接口3個對應的方法(CreateContext、DisposeContext和ProcessRequestAsync)中。
public interface IHttpApplication<TContext> { TContext CreateContext(IFeatureCollection contextFeatures); void DisposeContext(TContext context, Exception exception); Task ProcessRequestAsync(TContext context); }
二、承載應用( HostingApplication)
ASP.NET Core框架利用如下所示的HostingApplication類型作為IHttpApplication<TContext>接口的默認實現,它使用一個內嵌的Context類型來表示處理請求的上下文。一個Context對象是對一個HttpContext對象的封裝,同時承載了一些與診斷相關的信息。
public class HostingApplication : IHttpApplication<HostingApplication.Context> { ... public struct Context { public HttpContext HttpContext { get; set; } public IDisposable Scope { get; set; } public long StartTimestamp { get; set; } public bool EventLogEnabled { get; set; } public Activity Activity { get; set; } } }
HostingApplication對象會在開始和完成請求處理,以及在請求過程中出現異常時發出一些診斷日志事件。具體來說,HostingApplication對象會采用3種不同的診斷日志形式,包括基於DiagnosticSource和EventSource的診斷日志以及基於 .NET Core日志系統的日志。Context除HttpContext外的其他屬性都與診斷日志有關。具體來說,Context的Scope是為ILogger創建的針對當前請求的日志范圍(第9章有對日志范圍的詳細介紹),此日志范圍會攜帶唯一標識每個請求的ID,如果注冊ILoggerProvider提供的ILogger支持日志范圍,它可以將這個請求ID記錄下來,那么我們就可以利用這個ID將針對同一請求的多條日志消息組織起來做針對性分析。
HostingApplication對象會在請求結束之后記錄當前請求處理的耗時,所以它在開始處理請求時就會記錄當前的時間戳,Context的StartTimestamp屬性表示開始處理請求的時間戳。它的EventLogEnabled屬性表示針對EventSource的事件日志是否開啟,而Activity屬性則與針對DiagnosticSource的診斷日志有關,Activity代表基於當前請求處理的活動。
雖然ASP.NET Core應用的請求處理完全由HostingApplication對象負責,但是該類型的實現邏輯其實是很簡單的,因為它將具體的請求處理分發給一個RequestDelegate對象,該對象表示的正是所有注冊中間件組成的委托鏈。在創建HostingApplication對象時除了需要提供RequestDelegate對象,還需要提供用於創建HttpContext上下文的IHttpContextFactory對象,以及與診斷日志有關的ILogger對象和DiagnosticListener對象,它們被用來創建上面提到過的HostingApplicationDiagnostics對象。
public class HostingApplication : IHttpApplication<HostingApplication.Context> { private readonly RequestDelegate _application; private HostingApplicationDiagnostics _diagnostics; private readonly IHttpContextFactory _httpContextFactory; public HostingApplication(RequestDelegate application, ILogger logger,DiagnosticListener diagnosticSource, IHttpContextFactory httpContextFactory) { _application = application; _diagnostics = new HostingApplicationDiagnostics(logger, diagnosticSource); _httpContextFactory = httpContextFactory; } public Context CreateContext(IFeatureCollection contextFeatures) { var context = new Context(); var httpContext = _httpContextFactory.Create(contextFeatures); _diagnostics.BeginRequest(httpContext, ref context); context.HttpContext = httpContext; return context; } public Task ProcessRequestAsync(Context context) => _application(context.HttpContext); public void DisposeContext(Context context, Exception exception) { var httpContext = context.HttpContext; _diagnostics.RequestEnd(httpContext, exception, context); _httpContextFactory.Dispose(httpContext); _diagnostics.ContextDisposed(context); } }
如上面的代碼片段所示,當CreateContext方法被調用時,HostingApplication對象會利用IHttpContextFactory工廠創建出當前HttpContext上下文,並進一步將它封裝成一個Context對象。在返回這個Context對象之前,它會調用HostingApplicationDiagnostics對象的BeginRequest方法記錄相應的診斷日志。用來真正處理當前請求的ProcessRequestAsync方法比較簡單,只需要調用代表中間件委托鏈的RequestDelegate對象即可。
對於用來釋放上下文的DisposeContext方法來說,它會利用IHttpContextFactory對象的Dispose方法來釋放創建的HttpContext對象。換句話說,HttpContext上下文的生命周期是由HostingApplication對象控制的。完成針對HttpContext上下文的釋放之后,HostingApplication對象會利用HostingApplicationDiagnostics對象記錄相應的診斷日志。Context的Scope屬性表示的日志范圍就是在調用HostingApplicationDiagnostics對象的ContextDisposed方法時釋放的。如果將HostingApplication對象引入ASP.NET Core的請求處理管道,那么完整的管道就體現為下圖所示的結構。
三、應用生命周期和請求日志
很多人可能對ASP.NET Core框架自身記錄的診斷日志並不關心,其實很多時候這些日志對糾錯排錯和性能監控提供了很有用的信息。例如,假設需要創建一個APM(Application Performance Management)來監控ASP.NET Core處理請求的性能及出現的異常,那么我們完全可以將HostingApplication對象記錄的日志作為收集的原始數據。實際上,目前很多APM(如Elastic APM和SkyWalking APM等)針對ASP.NET Core應用的客戶端都是利用這種方式收集請求調用鏈信息的。
ILogger日志
為了確定什么樣的信息會被作為診斷日志記錄下來,下面介紹一個簡單的實例,將HostingApplication對象寫入的診斷日志輸出到控制台上。前面提及,HostingApplication對象會將相同的診斷信息以3種不同的方式進行記錄,其中包含日志系統,所以我們可以通過注冊對應ILoggerProvider對象的方式將日志內容寫入對應的輸出渠道。
整個演示實例如下面的代碼片段所示:首先通過調用IWebHostBuilder接口的ConfigureLogging方法注冊一個ConsoleLoggerProvider對象,並開啟針對日志范圍的支持。我們調用IApplicationBuilder接口的Run擴展方法注冊了一個中間件,該中間件在處理請求時會利用表示當前請求上下文的HttpContext對象得到與之綁定的IServiceProvider對象,並進一步從中提取出用於發送日志事件的ILogger<Program>對象,我們利用它寫入一條Information等級的日志。如果請求路徑為“/error”,那么該中間件會拋出一個InvalidOperationException類型的異常。
public class Program { public static void Main() { Host.CreateDefaultBuilder() .ConfigureLogging(builder => builder.AddConsole(options => options.IncludeScopes = true)) .ConfigureWebHostDefaults(builder => builder .Configure(app => app.Run(context => { var logger = context.RequestServices.GetRequiredService<ILogger<Program>>(); logger.LogInformation($"Log for event Foobar"); if (context.Request.Path == new PathString("/error")) { throw new InvalidOperationException("Manually throw exception."); } return Task.CompletedTask; }))) .Build() .Run(); } }
在啟動程序之后,我們利用瀏覽器采用不同的路徑(“/foobar”和“/error”)向應用發送了兩次請求,演示程序的控制台上呈現的輸出結果如下圖所示。由於我們開啟了日志范圍的支持,所以被ConsoleLogger記錄下來的日志都會攜帶日志范圍的信息。日志范圍的唯一標識被稱為請求ID(Request ID),它由當前的連接ID和一個序列號組成。從圖13-4可以看出,兩次請求的ID分別是“0HLO4ON65ALGG:00000001”和“0HLO4ON65ALGG:00000002”。由於采用的是長連接,並且兩次請求共享同一個連接,所以它們具有相同的連接ID(“0HLO4ON65ALGG”)。同一連接的多次請求將一個自增的序列號(“00000001”和“00000002”)作為唯一標識。
除了用於唯一表示每個請求的請求ID,日志范圍承載的信息還包括請求指向的路徑,這也可以從圖13-4所示的輸出接口看出來。另外,上述請求ID實際上對應HttpContext類型的TraceIdentifier屬性。如果需要進行跨應用的調用鏈跟蹤,所有相關日志就可以通過共享TraceIdentifier屬性構建整個調用鏈。
public abstract class HttpContext { public abstract string TraceIdentifier { get; set; } ... }
對於兩次采用不同路徑的請求,控制台共捕獲了7條日志,其中類別為App.Program的日志是應用程序自行寫入的,HostingApplication寫入日志的類別為“Microsoft.AspNetCore.Hosting.Diagnostics”。對於第一次請求的3條日志消息,第一條是在HostingApplication開始處理請求時寫入的,我們利用這條日志獲知請求的HTTP版本(HTTP/1.1)、HTTP方法(GET)和請求URL。對於包含主體內容的請求,請求主體內容的媒體類型(Content-Type)和大小(Content-Length)也會一並記錄下來。當HostingApplication對象處理完請求后會寫入第三條日志,日志承載的信息包括請求處理耗時(67.877 6毫秒)和響應狀態碼(200)。如果響應具有主體內容,對應的媒體類型同樣會被記錄下來。
對於第二次請求,由於我們人為拋出了一個異常,所以異常的信息被寫入日志。但是如果足夠仔細,就會發現這條等級為Error的日志並不是由HostingApplication對象寫入的,而是作為服務器的KestrelServer寫入的,因為該日志采用的類別為“Microsoft.AspNetCore.Server.Kestrel”。換句話說,HostingApplication對象利用ILogger記錄的日志中並不包含應用的異常信息。
DiagnosticSource診斷日志
HostingApplication采用的3種日志形式還包括基於DiagnosticSource對象的診斷日志,所以我們可以通過注冊診斷監聽器來收集診斷信息。如果通過這種方式獲取診斷信息,就需要預先知道診斷日志事件的名稱和內容荷載的數據結構。通過查看HostingApplication類型的源代碼,我們會發現它針對“開始請求”、“結束請求”和“未處理異常”這3類診斷日志事件對應的名稱,具體如下。
- 開始請求:Microsoft.AspNetCore.Hosting.BeginRequest。
- 結束請求:Microsoft.AspNetCore.Hosting.EndRequest。
- 未處理異常:Microsoft.AspNetCore.Hosting.UnhandledException。
至於針對診斷日志消息的內容荷載(Payload)的結構,上述3類診斷事件具有兩個相同的成員,分別是表示當前請求上下文的HttpContext和通過一個Int64整數表示的當前時間戳,對應的數據成員的名稱分別為httpContext和timestamp。對於未處理異常診斷事件,它承載的內容荷載還包括一個額外的成員,那就是表示拋出異常的Exception對象,對應的成員名稱為exception。
既然我們已經知道事件的名稱和診斷承載數據的成員,所以可以定義如下所示的DiagnosticCollector類型作為診斷監聽器(需要針對NuGet包“Microsoft.Extensions.DiagnosticAdapter”的引用)。針對上述3類診斷事件,我們在DiagnosticCollector類型中定義了3個對應的方法,各個方法通過標注的DiagnosticNameAttribute特性設置了對應的診斷事件。我們根據診斷數據承載的結構定義了匹配的參數,所以DiagnosticSource對象寫入診斷日志提供的診斷數據將自動綁定到對應的參數上。
public class DiagnosticCollector { [DiagnosticName("Microsoft.AspNetCore.Hosting.BeginRequest")] public void OnRequestStart(HttpContext httpContext, long timestamp) { var request = httpContext.Request; Console.WriteLine($"\nRequest starting {request.Protocol} {request.Method} { request.Scheme}://{request.Host}{request.PathBase}{request.Path}"); httpContext.Items["StartTimestamp"] = timestamp; } [DiagnosticName("Microsoft.AspNetCore.Hosting.EndRequest")] public void OnRequestEnd(HttpContext httpContext, long timestamp) { var startTimestamp = long.Parse(httpContext.Items["StartTimestamp"].ToString()); var timestampToTicks = TimeSpan.TicksPerSecond / (double)Stopwatch.Frequency; var elapsed = new TimeSpan((long)(timestampToTicks * (timestamp - startTimestamp))); Console.WriteLine($"Request finished in {elapsed.TotalMilliseconds}ms { httpContext.Response.StatusCode}"); } [DiagnosticName("Microsoft.AspNetCore.Hosting.UnhandledException")] public void OnException(HttpContext httpContext, long timestamp, Exception exception) { OnRequestEnd(httpContext, timestamp); Console.WriteLine($"{exception.Message}\nType:{exception.GetType()}\nStacktrace: { exception.StackTrace}"); } }
可以在針對“開始請求”診斷事件的OnRequestStart方法中輸出當前請求的HTTP版本、HTTP方法和URL。為了能夠計算整個請求處理的耗時,我們將當前時間戳保存在HttpContext上下文的Items集合中。在針對“結束請求”診斷事件的OnRequestEnd方法中,我們將這個時間戳從HttpContext上下文中提取出來,結合當前時間戳計算出請求處理耗時,該耗時和響應的狀態碼最終會被寫入控制台。針對“未處理異常”診斷事件的OnException方法則在調用OnRequestEnd方法之后將異常的消息、類型和跟蹤堆棧輸出到控制台上。
如下面的代碼片段所示,在注冊的Startup類型中,我們在Configure方法注入DiagnosticListener服務,並調用它的SubscribeWithAdapter擴展方法將上述DiagnosticCollector對象注冊為診斷日志的訂閱者。與此同時,我們調用IApplicationBuilder接口的Run擴展方法注冊了一個中間件,該中間件會在請求路徑為“/error”的情況下拋出一個異常。
public class Program { public static void Main() { Host.CreateDefaultBuilder() .ConfigureLogging(builder => builder.ClearProviders()) .ConfigureWebHostDefaults(builder => builder.UseStartup<Startup>()) .Build() .Run(); } } public class Startup { public void Configure(IApplicationBuilder app, DiagnosticListener listener) { listener.SubscribeWithAdapter(new DiagnosticCollector()); app.Run(context => { if (context.Request.Path == new PathString("/error")) { throw new InvalidOperationException("Manually throw exception."); } return Task.CompletedTask; }); } }
待演示實例正常啟動后,可以采用不同的路徑(“/foobar”和“/error”)對應用程序發送兩個請求,服務端控制台會以圖13-5所示的形式輸出DiagnosticCollector對象收集的診斷信息。如果我們試圖創建一個針對ASP.NET Core的APM框架來監控請求處理的性能和出現的異常,可以采用這樣的方案來收集原始的診斷信息。
EventSource事件日志
除了上述兩種日志形式,HostingApplication對象針對每個請求的處理過程中還會利用EventSource對象發出相應的日志事件。除此之外,在啟動和關閉應用程序(實際上就是啟動和關閉IWebHost對象)時,同一個EventSource對象還會被使用。這個EventSource類型采用的名稱為Microsoft.AspNetCore.Hosting,上述5個日志事件對應的名稱如下。
- 啟動應用程序:HostStart。
- 開始處理請求:RequestStart。
- 請求處理結束:RequestStop。
- 未處理異常:UnhandledException。
- 關閉應用程序:HostStop。
我們可以通過如下所示的實例來演示如何利用創建的EventListener對象來監聽上述5個日志事件。如下面的代碼片段所示,我們定義了派生於抽象類EventListener的DiagnosticCollector。在啟動應用前,我們創建了這個DiagnosticCollector對象,並通過注冊其EventSourceCreated事件開啟了針對目標名稱為Microsoft.AspNetCore.Hosting的EventSource的監聽。在注冊的EventWritten事件中,我們將監聽到的事件名稱的負載內容輸出到控制台上。
public class Program { private sealed class DiagnosticCollector : EventListener {} static void Main() { var listener = new DiagnosticCollector(); listener.EventSourceCreated +=(sender, args) => { if (args.EventSource.Name == "Microsoft.AspNetCore.Hosting") { listener.EnableEvents(args.EventSource, EventLevel.LogAlways); } }; listener.EventWritten += (sender, args) => { Console.WriteLine(args.EventName); for (int index = 0; index < args.PayloadNames.Count; index++) { Console.WriteLine($"\t{args.PayloadNames[index]} = {args.Payload[index]}"); } }; Host.CreateDefaultBuilder() .ConfigureLogging(builder => builder.ClearProviders()) .ConfigureWebHostDefaults(builder => builder .Configure(app => app.Run(context => { if (context.Request.Path == new PathString("/error")) { throw new InvalidOperationException("Manually throw exception."); } return Task.CompletedTask; }))) .Build() .Run(); } }
以命令行的形式啟動這個演示程序后,從下圖所示的輸出結果可以看到名為HostStart的事件被發出。然后采用目標地址“http://localhost:5000/foobar”和“http:// http://localhost:5000/error”對應用程序發送兩個請求,從輸出結果可以看出,應用程序針對前者的處理過程會發出RequestStart事件和RequestStop事件,針對后者的處理則會因為拋出的異常發出額外的事件UnhandledException。輸入“Ctrl+C”關閉應用后,名稱為HostStop的事件被發出。對於通過EventSource發出的5個事件,只有RequestStart事件會將請求的HTTP方法(GET)和路徑(“/foobar”和“/error”)作為負載內容,其他事件都不會攜帶任何負載內容。
請求處理管道[1]: 模擬管道實現
請求處理管道[2]: HttpContext本質論
請求處理管道[3]: Pipeline = IServer + IHttpApplication<TContext
請求處理管道[4]: 中間件委托鏈
請求處理管道[5]: 應用承載[上篇
請求處理管道[6]: 應用承載[下篇]