ASP.NET Core管道詳解[3]: Pipeline = IServer + IHttpApplication

ASP.NET Core的請求處理管道由一個伺服器和一組中間件構成,但對於面向傳輸層的伺服器來說,它其實沒有中間件的概念。當伺服器接收到請求之後,會將該請求分發給一個處理器進行處理,對伺服器而言,這個處理器就是一個HTTP應用,此應用通過IHttpApplication<TContext>介面來表示。由於伺服器是通過IServer介面表示的,所以可以將ASP.NET Core框架的核心視為由IServer和IHttpApplication<TContext>對象組成的管道。[本文節選自《ASP.NET Core 3框架揭秘》第13章, 更多關於ASP.NET Core的文章請點這裡]

2

目錄
一、伺服器(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的請求處理管道,那麼完整的管道就體現為下圖所示的結構。

3

三、應用生命周期和請求日誌

很多人可能對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」)作為唯一標識。

4

除了用於唯一表示每個請求的請求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框架來監控請求處理的性能和出現的異常,可以採用這樣的方案來收集原始的診斷資訊。

5

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的事件被發出。然後採用目標地址「//localhost:5000/foobar」和「// //localhost:5000/error」對應用程式發送兩個請求,從輸出結果可以看出,應用程式針對前者的處理過程會發出RequestStart事件和RequestStop事件,針對後者的處理則會因為拋出的異常發出額外的事件UnhandledException。輸入「Ctrl+C」關閉應用後,名稱為HostStop的事件被發出。對於通過EventSource發出的5個事件,只有RequestStart事件會將請求的HTTP方法(GET)和路徑(「/foobar」和「/error」)作為負載內容,其他事件都不會攜帶任何負載內容。

6

請求處理管道[1]: 模擬管道實現
請求處理管道[2]: HttpContext本質論
請求處理管道[3]: Pipeline = IServer +  IHttpApplication<TContext
請求處理管道[4]: 中間件委託鏈
請求處理管道[5]: 應用承載[上篇
請求處理管道[6]: 應用承載[下篇]