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

一、服务器(IServer)

由于服务器是整个请求处理管道的“龙头”,所以启动和关闭应用的最终目的是启动和关闭服务器。ASP.NET Core框架中的服务器通过IServer接口来表示,该接口具有如下所示的3个成员,其中由服务器提供的特性就保存在其Features属性表示的IFeatureCollection集合中。IServer接口的StartAsync<TContext>方法与StopAsync方法分别用来启动和关闭服务器。

publicinterface IServer : IDisposable

{

    IFeatureCollection Features { get; }

    Task StartAsync(IHttpApplication application, CancellationToken cancellationToken);

    Task StopAsync(CancellationToken cancellationToken);

}

服务器在开始监听请求之前总是绑定一个或者多个监听地址,这个地址是应用程序从外部指定的。具体来说,应用程序指定的监听地址会封装成一个特性,并且在服务器启动之前被添加到它的特性集合中。这个承载了监听地址列表的特性通过如下所示的IServerAddressesFeature接口来表示,该接口除了有一个表示地址列表的Addresses属性,还有一个布尔类型的PreferHostingUrls属性,该属性表示如果监听地址同时设置到承载系统配置和服务器上,是否优先考虑使用前者。

publicinterface IServerAddressesFeature

{

    ICollection Addresses {get; }

    boolPreferHostingUrls {get;set; }

}

正如前面所说,服务器将用来处理由它接收请求的处理器会被视为一个通过IHttpApplication<TContext>接口表示的应用,所以可以将ASP.NET Core的请求处理管道视为IServer对象和IHttpApplication<TContext>对象的组合。当调用IServer对象的StartAsync<TContext>方法启动服务器时,我们需要提供这个用来处理请求的IHttpApplication<TContext>对象。IHttpApplication<TContext>采用基于上下文的请求处理方式,泛型参数TContext代表的就是上下文的类型。在IHttpApplication<TContext>处理请求之前,它需要先创建一个上下文对象,该上下文会在请求处理结束之后被释放。上下文的创建、释放和自身对请求的处理实现在该接口3个对应的方法(CreateContext、DisposeContext和ProcessRequestAsync)中。

publicinterfaceIHttpApplication{

    TContext CreateContext(IFeatureCollection contextFeatures);

    void DisposeContext(TContext context, Exception exception);

    Task ProcessRequestAsync(TContext context);

}

二、承载应用( HostingApplication)

ASP.NET Core框架利用如下所示的HostingApplication类型作为IHttpApplication<TContext>接口的默认实现,它使用一个内嵌的Context类型来表示处理请求的上下文。一个Context对象是对一个HttpContext对象的封装,同时承载了一些与诊断相关的信息。

publicclassHostingApplication : IHttpApplication{

    ...

    publicstruct Context

    {

        publicHttpContext HttpContext {get;set; }

        publicIDisposable Scope {get;set; }

        publiclongStartTimestamp {get;set; }

        publicboolEventLogEnabled {get;set; }

        publicActivity 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对象。

publicclassHostingApplication : IHttpApplication{

    privatereadonly RequestDelegate _application;

    private HostingApplicationDiagnostics _diagnostics;

    privatereadonly 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)

    {

        varcontext =new Context();

        varhttpContext = _httpContextFactory.Create(contextFeatures);

        _diagnostics.BeginRequest(httpContext, ref context);

        context.HttpContext = httpContext;

        return context;

    }

    publicTask ProcessRequestAsync(Context context) => _application(context.HttpContext);

    publicvoid DisposeContext(Context context, Exception exception)

    {

        varhttpContext = 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类型的异常。

publicclass Program

{

    publicstaticvoid Main()

    {

        Host.CreateDefaultBuilder()

            .ConfigureLogging(builder => builder.AddConsole(options => options.IncludeScopes =true))

            .ConfigureWebHostDefaults(builder => builder

                .Configure(app => app.Run(context =>                {

                    varlogger = context.RequestServices.GetRequiredService>();

                    logger.LogInformation($"Log for event Foobar");

                    if(context.Request.Path ==newPathString("/error"))

                    {

                        thrownewInvalidOperationException("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属性构建整个调用链。

publicabstractclass HttpContext

{

    publicabstractstringTraceIdentifier {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对象写入诊断日志提供的诊断数据将自动绑定到对应的参数上。

publicclass DiagnosticCollector

{

    [DiagnosticName("Microsoft.AspNetCore.Hosting.BeginRequest")]

    publicvoidOnRequestStart(HttpContext httpContext,long timestamp)

    {

        varrequest = 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")]

    publicvoidOnRequestEnd(HttpContext httpContext,long timestamp)

    {

        varstartTimestamp =long.Parse(httpContext.Items["StartTimestamp"].ToString());

        vartimestampToTicks = TimeSpan.TicksPerSecond / (double)Stopwatch.Frequency;

        varelapsed =newTimeSpan((long)(timestampToTicks * (timestamp - startTimestamp)));

        Console.WriteLine($"Request finished in {elapsed.TotalMilliseconds}ms { httpContext.Response.StatusCode}");

    }

    [DiagnosticName("Microsoft.AspNetCore.Hosting.UnhandledException")]

    publicvoidOnException(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”的情况下抛出一个异常。

publicclass Program

{

    publicstaticvoid Main()

    {

        Host.CreateDefaultBuilder()

            .ConfigureLogging(builder => builder.ClearProviders())

            .ConfigureWebHostDefaults(builder => builder.UseStartup())

            .Build()

            .Run();

    }

}publicclass Startup

{

    publicvoid Configure(IApplicationBuilder app, DiagnosticListener listener)

    {

        listener.SubscribeWithAdapter(new DiagnosticCollector());

        app.Run(context =>        {

            if(context.Request.Path ==newPathString("/error"))

            {

                thrownewInvalidOperationException("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事件中,我们将监听到的事件名称的负载内容输出到控制台上。

publicclass Program

{

    privatesealedclass DiagnosticCollector : EventListener {}

    staticvoid Main()

    {

        varlistener =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(intindex =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 ==newPathString("/error"))

                    {

                        thrownewInvalidOperationException("Manually throw exception.");

                    }

                    return Task.CompletedTask;

                })))

            .Build()

            .Run();

    }

}

亚马逊测评 www.yisuping.com

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,919评论 6 502
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,567评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,316评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,294评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,318评论 6 390
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,245评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,120评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,964评论 0 275
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,376评论 1 313
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,592评论 2 333
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,764评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,460评论 5 344
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,070评论 3 327
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,697评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,846评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,819评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,665评论 2 354

推荐阅读更多精彩内容