赞
踩
作者:
Máňa - Software Engineer, .NET Natalia
Kondratyeva - Software Engineer, .NET
排版:Alan Wang
随着新的 .NET 版本的发布,发表有关网络空间中新的有趣变化的博客文章已经成为一种传统。今年,我们要介绍 HTTP 部分的变化、新增指标、新的 HttpClientFactoryAPI 等。
.NET 8 使用 .NET 6 中引入的 System.Diagnostics.Metrics API 将内置 HTTP 指标添加到 ASP.NET Core 和 HttpClient。Metrics API 和新内置指标的语义都是与 OpenTelemetry 密切合作设计的,确保新指标符合标准,并与 Prometheus 和 Grafana 等流行工具良好配合。
System.Diagnostics.MetricsAPI 引入了许多 EventCounters 所缺少的新功能。新的内置指标广泛利用了这些功能,从而通过更简单、更优雅的工具实现了更广泛的功能。举几个例子:
这些优势结合在一起带来了更好、更丰富的指标,这些指标可以通过 Prometheus 等第三方工具更有效地收集。由于 PromQL(Prometheus 查询语言)的灵活性,它允许针对从 .NET 网络堆栈收集的多维指标创建复杂的查询,用户现在可以深入了解 HttpClient 和 SocketsHttpHandler 实例的状态和运行状况,这在以前是不可能的。
不足之处在于,在 .NET 8 中,只有 System.Net.Http 和 System.Net.NameResolution 组件是使用 System.Diagnostics.Metrics 进行检测的,这意味着您仍然需要使用 EventCounters 从堆栈的较低层(例如 System.Net.Sockets)提取计数器. 虽然仍然支持以前版本中存在的所有内置 EventCounters,但 .NET 团队预计不会对 EventCounters 进行大量新投资,并且在未来的版本中会使用 System.Diagnostics.Metrics 添加新的内置检测工具。
有关使用内置 HTTP 指标的更多信息,请阅读我们有关 .NET 中的网络指标的教程。它包括有关使用 Prometheus 和 Grafana 进行收集和报告的示例,还演示了如何丰富和测试内置 HTTP 指标。有关内置工具的完整列表,请参阅 System.Net 指标的文档。如果您对服务器端更感兴趣,请阅读有关 ASP.NET Core 指标的文档。
除了新指标之外,.NET 5 中引入的现有基于 EventSource 的遥测事件还增加了有关 HTTP 连接的更多信息(dotnet/runtime#88853):
- ConnectionEstablished(byte versionMajor, byte versionMinor)
+ ConnectionEstablished(byte versionMajor, byte versionMinor, long connectionId, string scheme, string host, int port, string? remoteAddress)
- ConnectionClosed(byte versionMajor, byte versionMinor)
+ ConnectionClosed(byte versionMajor, byte versionMinor, long connectionId)
- RequestHeadersStart()
+ RequestHeadersStart(long connectionId)
现在,当建立新连接时,该事件会记录 connectionId 及其方案、端口和对等 IP 地址。这样就能通过 RequestHeadersStart 事件将请求和响应与连接关联起来(当请求与池连接关联并开始处理时发生该事件),该事件还记录关联的 ConnectionId。这在用户希望查看为其 HTTP 请求提供服务的服务器的 IP 地址的诊断场景中尤其有价值,这也是添加此功能的主要动机(dotnet/runtime#63159)。
事件可以通过多种方式使用,请参阅 .NET 中的网络遥测 – 事件。但为了在进程内增强日志记录,可以使用自定义 EventListener 将请求/响应对与连接数据相关联:
using IPLoggingListener ipLoggingListener = new(); using HttpClient client = new(); // Send requests in parallel. await Parallel.ForAsync(0, 1000, async (i, ct) => { // Initialize the async local so that it can be populated by "RequestHeadersStart" event handler. RequestInfo info = RequestInfo.Current; using var response = await client.GetAsync("https://testserver"); Console.WriteLine($"Response {response.StatusCode} handled by connection {info.ConnectionId}. Remote IP: {info.RemoteAddress}"); // Process response... }); internal sealed class RequestInfo { private static readonly AsyncLocal<RequestInfo> _asyncLocal = new(); public static RequestInfo Current => _asyncLocal.Value ??= new(); public string? RemoteAddress; public long ConnectionId; } internal sealed class IPLoggingListener : EventListener { private static readonly ConcurrentDictionary<long, string> s_connection2Endpoint = new ConcurrentDictionary<long, string>(); // EventId corresponds to [Event(eventId)] attribute argument and the payload indices correspond to the event method argument order. // See: https://github.com/dotnet/runtime/blob/a6e4834d53ac591a4b3d4a213a8928ad685f7ad8/src/libraries/System.Net.Http/src/System/Net/Http/HttpTelemetry.cs#L100-L101 private const int ConnectionEstablished_EventId = 4; private const int ConnectionEstablished_ConnectionIdIndex = 2; private const int ConnectionEstablished_RemoteAddressIndex = 6; // See: https://github.com/dotnet/runtime/blob/a6e4834d53ac591a4b3d4a213a8928ad685f7ad8/src/libraries/System.Net.Http/src/System/Net/Http/HttpTelemetry.cs#L106-L107 private const int ConnectionClosed_EventId = 5; private const int ConnectionClosed_ConnectionIdIndex = 2; // See: https://github.com/dotnet/runtime/blob/a6e4834d53ac591a4b3d4a213a8928ad685f7ad8/src/libraries/System.Net.Http/src/System/Net/Http/HttpTelemetry.cs#L118-L119 private const int RequestHeadersStart_EventId = 7; private const int RequestHeadersStart_ConnectionIdIndex = 0; protected override void OnEventSourceCreated(EventSource eventSource) { if (eventSource.Name == "System.Net.Http") { EnableEvents(eventSource, EventLevel.LogAlways); } } protected override void OnEventWritten(EventWrittenEventArgs eventData) { ReadOnlyCollection<object?>? payload = eventData.Payload; if (payload == null) return; switch (eventData.EventId) { case ConnectionEstablished_EventId: // Remember the connection data. long connectionId = (long)payload[ConnectionEstablished_ConnectionIdIndex]!; string? remoteAddress = (string?)payload[ConnectionEstablished_RemoteAddressIndex]; if (remoteAddress != null) { Console.WriteLine($"Connection {connectionId} established to {remoteAddress}"); s_connection2Endpoint.TryAdd(connectionId, remoteAddress); } break; case ConnectionClosed_EventId: connectionId = (long)payload[ConnectionClosed_ConnectionIdIndex]!; s_connection2Endpoint.TryRemove(connectionId, out _); break; case RequestHeadersStart_EventId: // Populate the async local RequestInfo with data from "ConnectionEstablished" event. connectionId = (long)payload[RequestHeadersStart_ConnectionIdIndex]!; if (s_connection2Endpoint.TryGetValue(connectionId, out remoteAddress)) { RequestInfo.Current.RemoteAddress = remoteAddress; RequestInfo.Current.ConnectionId = connectionId; } break; } } }
此外,Redirect 事件已扩展为包含重定向 URI:
-void Redirect();
+void Redirect(string redirectUri);
HttpClient 在诊断方面的问题之一是,当发生异常时,很难以编程方式区分错误的确切根本原因。区分它们的唯一方法是解析来自 HttpRequestException 的异常消息。此外,其他 HTTP 实现(如带有 ERROR_WINHTTP_* 错误码的 WinHTTP)以数字代码或枚举的形式提供了此类功能。所以 .NET 8引入了一个类似的枚举,并在 HTTP 处理抛出的异常中提供了它,它们是:
HttpRequestException 用于接收响应头之前的请求处理。
读取响应内容时抛出 HttpIOException。
在 dotnet/runtime#76644 API 提案中描述了 HttpRequestError 枚举的设计以及如何将其插入 HTTP 异常。
现在,HttpClient 方法的使用者可以更容易、更可靠地处理特定的内部错误:
using HttpClient httpClient = new(); // Handling problems with the server: try { using HttpResponseMessage response = await httpClient.GetAsync("https://testserver", HttpCompletionOption.ResponseHeadersRead); using Stream responseStream = await response.Content.ReadAsStreamAsync(); // Process responseStream ... } catch (HttpRequestException e) when (e.HttpRequestError == HttpRequestError.NameResolutionError) { Console.WriteLine($"Unknown host: {e}"); // --> Try different hostname. } catch (HttpRequestException e) when (e.HttpRequestError == HttpRequestError.ConnectionError) { Console.WriteLine($"Server unreachable: {e}"); // --> Try different server. } catch (HttpIOException e) when (e.HttpRequestError == HttpRequestError.InvalidResponse) { Console.WriteLine($"Mangled responses: {e}"); // --> Block list server. } // Handling problems with HTTP version selection: try { using HttpResponseMessage response = await httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get, "https://testserver") { Version = HttpVersion.Version20, VersionPolicy = HttpVersionPolicy.RequestVersionExact }, HttpCompletionOption.ResponseHeadersRead); using Stream responseStream = await response.Content.ReadAsStreamAsync(); // Process responseStream ... } catch (HttpRequestException e) when (e.HttpRequestError == HttpRequestError.VersionNegotiationError) { Console.WriteLine($"HTTP version is not supported: {e}"); // Try with different HTTP version. }
这个版本中实现的最受欢迎的功能之一是支持 HTTPS 代理(dotnet/runtime#31113)。现在可以使用代理处理通过 HTTPS发送的请求,这意味着与代理的连接是安全的。这并没有涉及来自代理本身的请求,它仍然可以是 HTTP 或 HTTPS。对于纯文本 HTTP 请求,与 HTTPS 代理的连接是安全的(通过 HTTPS),然后是从代理到目标的纯文本请求。如果是 HTTPS 请求(代理隧道),打开隧道的初始 CONNECT 请求将通过安全通道 (HTTPS) 发送到代理,然后是从代理通过隧道到目的地的 HTTPS 请求。
如果要利用该功能,只需在设置代理时使用 HTTPS 方案即可:
using HttpClient client = new HttpClient(new SocketsHttpHandler()
{
Proxy = new WebProxy("https://proxy.address:12345")
});
using HttpResponseMessage response = await client.GetAsync("https://httpbin.org/");
.NET 8 扩展了配置 HttpClientFactory 的方式,包括客户端默认设置、自定义日志记录和简化的 SocketsHttpHandler 配置。这些 API 在 Microsoft.Extensions.Http 包中实现,该包可在 NuGet 上获取,并包含对 .NET Standard 2.0 的支持。因此,此功能不仅适用于 .NET 8 上的客户端,而且适用于所有版本的 .NET,包括 .NET Framework(唯一的例外是 SocketsHttpHandler 相关 API,仅在 .NET 5+ 中可用)。
.NET 8 添加了设置默认配置的功能,该配置将用于 HttpClientFactory(dotnet/runtime#87914)创建的所有 HttpClient。当所有或大多数注册客户端包含相同的配置子集时,这非常有用。
考虑一个定义了两个命名客户端的示例,它们都需要在其消息处理程序链中使用 MyAuthHandler。
services.AddHttpClient("consoto", c => c.BaseAddress = new Uri("https://consoto.com/"))
.AddHttpMessageHandler<MyAuthHandler>();
services.AddHttpClient("github", c => c.BaseAddress = new Uri("https://github.com/"))
.AddHttpMessageHandler<MyAuthHandler>();
您现在可以使用以下 ConfigureHttpClientDefaults 方法提取公共部分:
services.ConfigureHttpClientDefaults(b => b.AddHttpMessageHandler<MyAuthHandler>());
// both clients will have MyAuthHandler added by default
services.AddHttpClient("consoto", c => c.BaseAddress = new Uri("https://consoto.com/"));
services.AddHttpClient("github", c => c.BaseAddress = new Uri("https://github.com/"));
所有与 AddHttpClient 一起使用的 IHttpClientBuilder 扩展方法也可以在 ConfigureHttpClientDefaults 中使用。
默认配置 (ConfigureHttpClientDefaults) 在客户端特定 (AddHttpClient) 配置之前应用于所有客户端;它们在注册中的相对位置并不重要。ConfigureHttpClientDefaults 可以注册多次,在这种情况下,配置将按照注册的顺序一一应用。配置的任何部分都可以在特定于客户端的配置中被重写或修改,例如,您可以为 HttpClient 对象或主处理程序设置额外的设置,删除以前添加的额外处理程序等。
请注意,从 8.0 开始,ConfigureHttpMessageHandlerBuilder 方法已被弃用。您应该改用 ConfigurePrimaryHttpMessageHandler(Action<httpmessagehandler,iserviceprovider< span=“”>>))) 或 ConfigureAdditionalHttpMessageHandlers 方法,需要分别修改先前配置的主处理程序或附加处理程序列表。
// by default, adds User-Agent header, uses HttpClientHandler with UseCookies=false // as a primary handler, and adds MyAuthHandler to all clients services.ConfigureHttpClientDefaults(b => b.ConfigureHttpClient(c => c.DefaultRequestHeaders.UserAgent.ParseAdd("HttpClient/8.0")) .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler() { UseCookies = false }) .AddHttpMessageHandler<MyAuthHandler>()); // HttpClient will have both User-Agent (from defaults) and BaseAddress set // + client will have UseCookies=false and MyAuthHandler from defaults services.AddHttpClient("modify-http-client", c => c.BaseAddress = new Uri("https://httpbin.org/")) // primary handler will have both UseCookies=false (from defaults) and MaxConnectionsPerServer set // + client will have User-Agent and MyAuthHandler from defaults services.AddHttpClient("modify-primary-handler") .ConfigurePrimaryHandler((h, _) => ((HttpClientHandler)h).MaxConnectionsPerServer = 1); // MyWrappingHandler will be inserted at the top of the handlers chain // + client will have User-Agent, UseCookies=false and MyAuthHandler from defaults services.AddHttpClient("insert-handler-into-chain")) .ConfigureAdditionalHttpMessageHandlers((handlers, _) => handlers.Insert(0, new MyWrappingHandler()); // MyAuthHandler (initially from defaults) will be removed from the handler chain // + client will still have User-Agent and UseCookies=false from defaults services.AddHttpClient("remove-handler-from-chain")) .ConfigureAdditionalHttpMessageHandlers((handlers, _) => handlers.Remove(handlers.Single(h => h is MyAuthHandler)));
自定义(或者干脆关闭)HttpClientFactory 日志记录是长期请求的功能之一(dotnet/runtime#77312)。
HttpClientFactory 添加的默认(“旧”)日志记录非常冗长,每个请求发出 8 条日志消息:
这可以用下面的图来说明。在下图中,* 和 […] 表示日志记录事件(在默认实现中,日志消息被写入 ILogger),–> 表示通过应用程序层和传输层的数据流。
Request --> * [Start notification] // "Start processing HTTP request ..." (1) * [Request headers] // "Request Headers: ..." (2) --> Additional Handler #1 --> --> .... --> --> Additional Handler #N --> * [Start notification] // "Sending HTTP request ..." (3) * [Request headers] // "Request Headers: ..." (4) --> Primary Handler --> --------Transport--layer-------> // Server sends response <-------Transport--layer-------- <-- Primary Handler <-- * [Stop notification] // "Received HTTP response ..." (5) * [Response headers] // "Response Headers: ..." (6) <-- Additional Handler #N <-- <-- .... <-- <-- Additional Handler #1 <-- * [Stop notification] // "End processing HTTP request ..." (7) * [Response headers] // "Response Headers: ..." (8) Response <--
默认 HttpClientFactory 日志记录的控制台输出如下所示:
var client = _httpClientFactory.CreateClient(); await client.GetAsync("https://httpbin.org/get"); info: System.Net.Http.HttpClient.test.LogicalHandler[100] Start processing HTTP request GET https://httpbin.org/get trce: System.Net.Http.HttpClient.test.LogicalHandler[102] Request Headers: .... info: System.Net.Http.HttpClient.test.ClientHandler[100] Sending HTTP request GET https://httpbin.org/get trce: System.Net.Http.HttpClient.test.ClientHandler[102] Request Headers: .... info: System.Net.Http.HttpClient.test.ClientHandler[101] Received HTTP response headers after 581.2898ms - 200 trce: System.Net.Http.HttpClient.test.ClientHandler[103] Response Headers: .... info: System.Net.Http.HttpClient.test.LogicalHandler[101] End processing HTTP request after 618.9736ms - 200 trce: System.Net.Http.HttpClient.test.LogicalHandler[103] Response Headers: ....
请注意,为了查看跟踪级别消息,您需要在全局日志记录配置文件中选择此选项或通过 SetMinimumLevel(LogLevel.Trace)进行设置 。但即使只考虑信息级别的消息,“旧”日志记录每个请求仍然有 4 条消息。
要删除默认(或之前添加的)日志记录,您可以使用新的 RemoveAllLoggers() 扩展方法。它与上面“为所有客户端设置默认值”部分中描述的 ConfigureHttpClientDefaults API 结合起来特别强大。这样,您就可以在一行中删除所有客户端的“旧”日志记录:
services.ConfigureHttpClientDefaults(b => b.RemoveAllLoggers()); // remove HttpClientFactory default logging for all clients
如果您需要恢复“旧”日志记录,例如 针对特定客户端,您可以使用 AddDefaultLogger() 来执行此操作。
除了能够删除“旧”日志记录之外,新的 HttpClientFactory API 还允许您完全自定义日志记录。您可以指定当 HttpClient 启动请求、接收响应或引发异常时记录的内容和方式。
您可以同时添加多个自定义记录器 - 例如,控制台和 ETW 记录器,或“包装”和“不包装”记录器。由于其附加性质,您可能需要事先显式删除默认的“旧”日志记录。
如果要添加自定义日志记录,您需要实现 IHttpClientLogger 接口,然后使用 AddLogger 将自定义记录器添加到客户端。请注意,日志记录实现不应引发任何异常,否则可能会中断请求执行。
注册:
services.AddSingleton<SimpleConsoleLogger>(); // register the logger in DI
services.AddHttpClient("foo") // add a client
.RemoveAllLoggers() // remove previous logging
.AddLogger<SimpleConsoleLogger>(); // add the custom logger
示例记录器实现:
// outputs one line per request to console
public class SimpleConsoleLogger : IHttpClientLogger
{
public object? LogRequestStart(HttpRequestMessage request) => null;
public void LogRequestStop(object? ctx, HttpRequestMessage request, HttpResponseMessage response, TimeSpan elapsed)
=> Console.WriteLine($"{request.Method} {request.RequestUri?.AbsoluteUri} - {(int)response.StatusCode} {response.StatusCode} in {elapsed.TotalMilliseconds}ms");
public void LogRequestFailed(object? ctx, HttpRequestMessage request, HttpResponseMessage? response, Exception e, TimeSpan elapsed)
=> Console.WriteLine($"{request.Method} {request.RequestUri?.AbsoluteUri} - Exception {e.GetType().FullName}: {e.Message}");
}
示例输出:
var client = _httpClientFactory.CreateClient("foo");
await client.GetAsync("https://httpbin.org/get");
await client.PostAsync("https://httpbin.org/post", new ByteArrayContent(new byte[] { 42 }));
await client.GetAsync("http://httpbin.org/status/500");
await client.GetAsync("http://localhost:1234");
GET https://httpbin.org/get - 200 OK in 393.2039ms
POST https://httpbin.org/post - 200 OK in 95.524ms
GET https://httpbin.org/status/500 - 500 InternalServerError in 99.5025ms
GET http://localhost:1234/ - Exception System.Net.Http.HttpRequestException: No connection could be made because the target machine actively refused it. (localhost:1234)
您可以使用上下文对象来匹配 LogRequestStart 调用和相应的 LogRequestStop 调用,从而将数据从一个调用传递到另一个调用。上下文对象由 LogRequestStart 产生,然后传递回 LogRequestStop。这可以是一个属性包或任何其他保存必要数据的对象。
如果不需要上下文对象,实现可以从 LogRequestStart 返回 null。
以下示例显示了如何使用上下文对象来传递自定义请求标识符。
public class RequestIdLogger : IHttpClientLogger { private readonly ILogger _log; public RequestIdLogger(ILogger<RequestIdLogger> log) { _log = log; } private static readonly Action<ILogger, Guid, string?, Exception?> _requestStart = LoggerMessage.Define<Guid, string?>( LogLevel.Information, EventIds.RequestStart, "Request Id={RequestId} ({Host}) started"); private static readonly Action<ILogger, Guid, double, Exception?> _requestStop = LoggerMessage.Define<Guid, double>( LogLevel.Information, EventIds.RequestStop, "Request Id={RequestId} succeeded in {elapsed}ms"); private static readonly Action<ILogger, Guid, Exception?> _requestFailed = LoggerMessage.Define<Guid>( LogLevel.Error, EventIds.RequestFailed, "Request Id={RequestId} FAILED"); public object? LogRequestStart(HttpRequestMessage request) { var ctx = new Context(Guid.NewGuid()); _requestStart(_log, ctx.RequestId, request.RequestUri?.Host, null); return ctx; } public void LogRequestStop(object? ctx, HttpRequestMessage request, HttpResponseMessage response, TimeSpan elapsed) => _requestStop(_log, ((Context)ctx!).RequestId, elapsed.TotalMilliseconds, null); public void LogRequestFailed(object? ctx, HttpRequestMessage request, HttpResponseMessage? response, Exception e, TimeSpan elapsed) => _requestFailed(_log, ((Context)ctx!).RequestId, null); public static class EventIds { public static readonly EventId RequestStart = new(1, "RequestStart"); public static readonly EventId RequestStop = new(2, "RequestStop"); public static readonly EventId RequestFailed = new(3, "RequestFailed"); } record Context(Guid RequestId); }
info: RequestIdLogger[1] Request Id=d0d63b84-cd67-4d21-ae9a-b63d26dfde50 (httpbin.org) started info: RequestIdLogger[2] Request Id=d0d63b84-cd67-4d21-ae9a-b63d26dfde50 succeeded in 530.1664ms info: RequestIdLogger[1] Request Id=09403213-dd3a-4101-88e8-db8ab19e1eeb (httpbin.org) started info: RequestIdLogger[2] Request Id=09403213-dd3a-4101-88e8-db8ab19e1eeb succeeded in 83.2484ms info: RequestIdLogger[1] Request Id=254e49bd-f640-4c56-b62f-5de678eca129 (httpbin.org) started info: RequestIdLogger[2] Request Id=254e49bd-f640-4c56-b62f-5de678eca129 succeeded in 162.7776ms info: RequestIdLogger[1] Request Id=e25ccb08-b97e-400d-b42b-b09d6c42adec (localhost) started fail: RequestIdLogger[3] Куйгуые Шв=у25сси08-и97у-400в-и42и-и09в6с42фвус АФШДУВ
如果您打算读取和记录(例如:请求和响应内容),请注意,它可能会对最终用户体验产生不利的副作用并导致错误。例如,请求内容可能在发送之前被消耗,或者巨大的响应内容可能最终被缓冲在内存中。此外,在 .NET 7 之前,访问标头不是线程安全的,可能会导致错误和意外行为。
我们预计同步 IHttpClientLogger 接口适用于绝大多数自定义日志记录用例。出于性能原因,建议不要在日志记录中使用异步。但是,如果严格要求日志记录中的异步访问,您可以实现异步版本 IHttpClientAsyncLogger。它派生自 IHttpClientLogger,因此可以使用相同的 AddLogger API 进行注册。
请注意,在这种情况下,还应该实现日志记录方法的同步对应项,特别是如果该实现是面向 .NET Standard 或 .NET 5+ 的库的一部分。同步对应项是从同步 HttpClient.Send 方法调用的;即使 .NET Standard 表面不包含它们,.NET Standard 库也可以在 .NET 5+ 应用程序中使用,因此最终用户可以访问同步 HttpClient.Send 方法。
当您添加记录器时,您可以显式设置 wrapHandlersPipeline 参数来指定记录器是否将被
Request -->
* [LogRequestStart()] // wrapHandlersPipeline=TRUE
--> Additional Handlers #1..N --> // handlers pipeline
--> Primary Handler -->
--------Transport--layer--------
<-- Primary Handler <--
<-- Additional Handlers #N..1 <-- // handlers pipeline
* [LogRequestStop()] // wrapHandlersPipeline=TRUE
Response <--
Request -->
--> Additional Handlers #1..N --> // handlers pipeline
* [LogRequestStart()] // wrapHandlersPipeline=FALSE
--> Primary Handler -->
--------Transport--layer--------
<-- Primary Handler <--
* [LogRequestStop()] // wrapHandlersPipeline=FALSE
<-- Additional Handlers #N..1 <-- // handlers pipeline
Response <--
默认情况下,记录器被添加为不包装。
在向管道添加重试处理程序的情况下(例如 Polly 或某些自定义重试实现),包装和不包装管道之间的区别最为显着。在这种情况下,包装记录器(位于顶部)将记录有关单个成功请求的消息,记录的经过时间将是从用户发起请求到收到响应的总时间。非包装记录器(位于底部)将记录每次重试迭代,最初的迭代可能记录异常或不成功的状态代码,最后一个记录成功。每种情况消耗的时间纯粹是在主处理程序中花费的时间(实际在网络上发送请求的处理程序,例如 HttpClientHandler)。
这可以用下图来说明:
Request --> * [LogRequestStart()] --> Additional Handlers #1..(N-1) --> --> Retry Handler --> --> //1 --> Primary Handler --> <-- "503 Service Unavailable" <-- --> //2 --> Primary Handler -> <-- "503 Service Unavailable" <-- --> //3 --> Primary Handler --> <-- "200 OK" <-- <-- Retry Handler <-- <-- Additional Handlers #(N-1)..1 <-- * [LogRequestStop()] Response <-- info: Example.CustomLogger.Wrapping[1] GET https://consoto.com/ info: Example.CustomLogger.Wrapping[2] 200 OK - 809.2135ms
Request --> --> Additional Handlers #1..(N-1) --> --> Retry Handler --> --> //1 * [LogRequestStart()] --> Primary Handler --> <-- "503 Service Unavailable" <-- * [LogRequestStop()] --> //2 * [LogRequestStart()] --> Primary Handler --> <-- "503 Service Unavailable" <-- * [LogRequestStop()] --> //3 * [LogRequestStart()] --> Primary Handler --> <-- "200 OK" <-- * [LogRequestStop()] <-- Retry Handler <-- <-- Additional Handlers #(N-1)..1 <-- Response <--
info: Example.CustomLogger.NotWrapping[1]
GET https://consoto.com/
info: Example.CustomLogger.NotWrapping[2]
503 Service Unavailable - 98.613ms
info: Example.CustomLogger.NotWrapping[1]
GET https://consoto.com/
info: Example.CustomLogger.NotWrapping[2]
503 Service Unavailable - 96.1932ms
info: Example.CustomLogger.NotWrapping[1]
GET https://consoto.com/
info: Example.CustomLogger.NotWrapping[2]
200 OK - 579.2133ms
.NET 8 添加了更方便、更流畅的方式来使用 SocketsHttpHandler 作为 HttpClientFactory 中的主处理程序(dotnet/runtime#84075)。
您可以使用 UseSocketsHttpHandler 方法设置和配置 SocketsHttpHandler。您可以使用 IConfiguration 从配置文件设置 SocketsHttpHandler 属性,也可以从代码中配置它,或者可以结合使用这两种方法。
请注意,将 IConfiguration 应用于 SocketsHttpHandler 时,仅解析 bool、int、Enum 或 TimeSpan 类型的 SocketsHttpHandler 属性。IConfiguration 中所有不匹配的属性都将被忽略。配置仅在注册时解析一次并且不会重新加载,因此在应用程序重新启动之前,处理程序不会反映任何配置文件更改。
// sets up properties on the handler directly services.AddHttpClient("foo") .UseSocketsHttpHandler((h, _) => h.UseCookies = false); // uses a builder to combine approaches services.AddHttpClient("bar") .UseSocketsHttpHandler(b => b.Configure(config.GetSection($"HttpClient:bar")) // loads simple properties from config .Configure((h, _) => // sets up SslOptions in code { h.SslOptions.RemoteCertificateValidationCallback = delegate { return true; }; }); ); { "HttpClient": { "bar": { "AllowAutoRedirect": true, "UseCookies": false, "ConnectTimeout": "00:00:05" } } }
当前大多数 Linux 发行版在其最新版本中都采用了 OpenSSL 3:
.NET 8 的 QUIC 支持已准备就绪(dotnet/runtime#81801)。
实现这一目标的第一步是确保 System.Net.Quic 下使用的 QUIC 实现 MsQuic 可以与 OpenSSL 3+ 一起使用。这项工作在 MsQuic 存储库 microsoft/msquic#2039 中进行。下一步是确保构建并发布的 libmsquic 包相应的依赖于特定发行版和版本的默认 OpenSSL 版本。例如 Debian 发行版:
最后一步是确保正在测试的MsQuic 和 OpenSSL版本正确,并且测试覆盖了所有 .NET 支持的发行版。
在 .NET 7 中发布 QUIC API(作为预览功能)后,我们收到了几个有关异常的问题:
dotnet/runtime#78751:
找不到主机时 QuicConnection.ConnectAsync 引发 SocketException
dotnet/runtime#78096:
QuicListener AcceptConnectionAsync 和 OperationCanceledException
dotnet/runtime#75115:QuicListener.AcceptConnectionAsync 重新抛出异常
在 .NET 8 中,System.Net.Quic 异常行为在 dotnet/runtime#82262 中进行了彻底修改,并且解决了上述问题。
修订的主要目标之一是确保 System.Net.Quic 中的异常行为在整个命名空间中尽可能一致。总的来说,当前的行为可以总结如下:
QuicException:特定于 QUIC 协议或与其处理相关的所有错误。
SocketException:针对网络问题,例如网络状况、名称解析或用户错误。
AuthenticationException:所有与 TLS 相关的问题。目标是具有与 SslStream 类似的行为。
ArgumentException:当提供 QuicConnectionOptions 或 QuicListenerOptions 无效时。
OperationCanceledException:每当 CancellationToken 被触发时取消。
ObjectDisposedException:每当在已释放的对象上调用方法时。
请注意,上述示例并不详尽。
除了改变行为之外,QuicException 也发生了改变。其中一项变化是调整 QuicError 枚举值。现在 SocketException 涵盖的项目已被删除,并为用户回调错误添加了一个新值(dotnet/runtime#87259)。新添加的 CallbackError 用于区分
QuicListenerOptions.ConnectionOptionsCallback 引发的异常与 System.Net.Quic 引发的异常(dotnet/runtime#88614)。因此,如果用户代码抛出 ArgumentException,QuicListener.AcceptConnectionAsync 会将其包装在 QuicException 中,并将 QuicError 设置为 CallbackError,并且内部异常将包含原始用户抛出的异常。它可以这样使用:
await using var listener = await QuicListener.ListenAsync(new QuicListenerOptions { // ... ConnectionOptionsCallback = (con, hello, token) => { if (blockedServers.Contains(hello.ServerName)) { throw new ArgumentException($"Connection attempt from forbidden server: '{hello.ServerName}'.", nameof(hello)); } return ValueTask.FromResult(new QuicServerConnectionOptions { // ... }); }, }); // ... try { await listener.AcceptConnectionAsync(); } catch (QuicException ex) when (ex.QuicError == QuicError.CallbackError && ex.InnerException is ArgumentException) { Console.WriteLine($"Blocked connection attempt from forbidden server: {ex.InnerException.Message}"); }
异常部分的最后一个更改是将传输错误代码添加到 QuicException 中(dotnet/runtime#88550)。传输错误代码由 RFC 9000 传输错误代码定义,并且 MsQuic 的 System.Net.Quic 已经可以使用它们,只是没有公开。因此,QuicException 中添加了一个新的可为 null 的属性:TransportErrorCode。我们要感谢社区贡献者 AlexRach,他在 dotnet/runtime#88614 中实现了这一更改。
Socket 空间中影响最大的更改是显着减少无连接(UDP) Socket 的分配(dotnet/runtime#30797)。使用 UDP Socket 时,分配的最大贡献者之一是在每次调用 Socket.ReceiveFrom 时分配一个新的 EndPoint 对象(并支持 IPAddress 等分配)。为了缓解这个问题,引入了一组使用 SocketAddress 的新 API(dotnet/runtime#87397)。SocketAddress 在内部将 IP 地址保存为平台相关形式的字节数组,以便可以将其直接传递给操作系统调用。因此,在调用本机 Socket 函数之前不需要复制 IP 地址数据。
此外,新添加的 ReceiveFrom-system-net-sockets-socketflags-system-net-socketaddress)) 和 ReceiveFromAsync-system-net-sockets-socketflags-system-net-socketaddress-system-threading-cancellationtoken)) 重载不会在每次调用时实例化新的 IPEndPoint,而是在适当的位置改变提供的 receiveAddress 参数。所有这些一起可以用来提高 UDP Socket 代码的效率:
// Same initialization code as before, no change here. Socket server = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); Socket client = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); byte[] message = Encoding.UTF8.GetBytes("Hello world!"); byte[] buffer = new byte[1024]; IPEndPoint endpoint = new IPEndPoint(IPAddress.Loopback, 12345); server.Bind(endpoint); // -------- // Original code that would allocate IPEndPoint for each ReceiveFromAsync: Task<SocketReceiveFromResult> receiveTaskOrig = server.ReceiveFromAsync(buffer, SocketFlags.None, endpoint); await client.SendToAsync(message, SocketFlags.None, endpoint); SocketReceiveFromResult resultOrig = await receiveTaskOrig; Console.WriteLine(Encoding.UTF8.GetString(buffer, 0, result.ReceivedBytes) + " from " + result.RemoteEndPoint); // Prints: // Hello world! from 127.0.0.1:59769 // -------- // New variables that can be re-used for subsequent calls: SocketAddress receivedAddress = endpoint.Serialize(); SocketAddress targetAddress = endpoint.Serialize(); // New code that will mutate provided SocketAddress for each ReceiveFromAsync: ValueTask<int> receiveTaskNew = server.ReceiveFromAsync(buffer, SocketFlags.None, receivedAddress); await client.SendToAsync(message, SocketFlags.None, targetAddress); var length = await receiveTaskNew; Console.WriteLine(Encoding.UTF8.GetString(buffer, 0, length) + " from " + receivedAddress); // Prints: // Hello world! from InterNetwork:16:{233,121,127,0,0,1,0,0,0,0,0,0,0,0}
最重要的是,在 dotnet/runtime#86872 中改进了 SocketAddress 的使用。SocketAddress 现在有几个额外的成员,使其本身更有用:
最后,删除了一些内部生成的 IP 地址数据副本,以提高性能。
添加缺失的 MIME 类型是网络空间中投票最多的问题之一(dotnet/runtime#1489)。这是一个主要由社区驱动的更改,最终形成了 dotnet/runtime#85807 API 提案。由于此添加需要经过 API 审核流程,因此有必要确保添加的类型是相关的并遵循规范(IANA 媒体类型)。对于这项准备工作,我们要感谢社区贡献者 Bilal-io 和 mmarinchenko。
.NET 8 中添加的另一个新 API 是新类型 IPNetwork(dotnet/runtime#79946)。该结构允许指定 RFC 4632 中定义的无类 IP 子网。例如:
新的 API 可以使用构造函数从 IPAddress 和前缀长度进行构造,也可以通过 TryParse 或 Parse 从字符串进行解析。最重要的是,它允许使用 Contains 方法检查 IPAddress 是否属于子网。示例用法如下:
// IPv4 with manual construction. IPNetwork ipNet = new IPNetwork(new IPAddress(new byte[] { 127, 0, 0, 0 }), 8); IPAddress ip1 = new IPAddress(new byte[] { 255, 0, 0, 1 }); IPAddress ip2 = new IPAddress(new byte[] { 127, 0, 0, 10 }); Console.WriteLine($"{ip1} {(ipNet.Contains(ip1) ? "belongs" : "doesn't belong")} to {ipNet}"); Console.WriteLine($"{ip2} {(ipNet.Contains(ip2) ? "belongs" : "doesn't belong")} to {ipNet}"); // Prints: // 255.0.0.1 doesn't belong to 127.0.0.0/8 // 127.0.0.10 belongs to 127.0.0.0/8 // IPv6 with parsing. IPNetwork ipNet = IPNetwork.Parse("2a01:110:8012::/96"); IPAddress ip1 = IPAddress.Parse("2a01:110:8012::1742:4244"); IPAddress ip2 = IPAddress.Parse("2a01:110:8012:1010:914e:2451:16ff:ffff"); Console.WriteLine($"{ip1} {(ipNet.Contains(ip1) ? "belongs" : "doesn't belong")} to {ipNet}"); Console.WriteLine($"{ip2} {(ipNet.Contains(ip2) ? "belongs" : "doesn't belong")} to {ipNet}"); // Prints: // 2a01:110:8012::1742:4244 belongs to 2a01:110:8012::/96 // 2a01:110:8012:1010:914e:2451:16ff:ffff doesn't belong to 2a01:110:8012::/96
请注意,不要将此类型与自 1.0 以来 ASP.NET Core 中存在的
Microsoft.AspNetCore.HttpOverrides.IPNetwork 类混淆。我们预计 ASP.NET API 最终将迁移到新的 System.Net.IPNetwork 类型(dotnet/aspnetcore#46157)。
本文选择的主题并不是 .NET 8 中所有更改的详尽列表,只是我们认为最有趣的内容。如果您对性能改进更感兴趣,您可以查看 Stephen 的大型性能博客文章中的网络部分。如果您有任何疑问或发现任何错误,可以在 dotnet/runtime 存储库中与我们联系。
最后,我要感谢我的合著者:
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。