1. 程式人生 > >ASP.NET Core管道詳解[3]: Pipeline = IServer + IHttpApplication

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的文章請點這裡]

目錄
一、伺服器(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]: 應用承載[下篇]