作者:
Máňa - Software Engineer, .NET Natalia
Kondratyeva - Software Engineer, .NET
排版:Alan Wang
随着新的 .NET 版本的发布,发表有关网络空间中新的有趣变化的博客文章已经成为一种传统。今年,我们要介绍 HTTP 部分的变化、新增指标、新的 HttpClientFactoryAPI 等。
HTTP
指标
.NET 8 使用 .NET 6 中引入的 System.Diagnostics.Metrics API 将内置 HTTP 指标添加到 ASP.NET Core 和 HttpClient。Metrics API 和新内置指标的语义都是与 OpenTelemetry 密切合作设计的,确保新指标符合标准,并与 Prometheus 和 Grafana 等流行工具良好配合。
System.Diagnostics.MetricsAPI 引入了许多 EventCounters 所缺少的新功能。新的内置指标广泛利用了这些功能,从而通过更简单、更优雅的工具实现了更广泛的功能。举几个例子:
- Histograms 允许我们能够报告持续时间,例如请求持续时间( http.client.request.duration)或连接持续时间(http.client.connection.duration)。这些是没有 EventCounter 对应项的新指标。
- Multi-dimensionality 允许我们将标签(又名属性或标签)附加到测量值上,这意味着我们可以将 server.address (标识 URI 来源)或 error.type(描述请求失败时的错误原因)之类的信息与测量值一起报告。多维还可以实现简化:为了报告打开的 HTTP 连接数,SocketsHttpHandler 使用 3 个 EventCounters:http11-connections-current-total、http20-connections-current-total 和 http30-connections-current-total,而这些计数器的 Metrics 等效项是单个工具 http.client.open_connections,其中使用 network.protocol.version 标记报告 HTTP 版本。
- 为了帮助内置标签不足以对传出 HTTP 请求进行分类的用例,http.client.request.duration 指标支持注入用户定义的标签。这称为扩充。
- IMeterFactory 集成可以隔离用于发出 HTTP 指标的 Meter 实例,从而更轻松地编写针对内置测量值运行验证的测试,并启用此类测试的并行执行。
- 虽然这并不是特定于内置网络指标,但值得一提的是 System.Digangostics.Metrics 中的集合 API 也更高级:它们是强类型且性能更高,并且允许多个同时侦听器和侦听器访问未聚合的测量结果。
这些优势结合在一起带来了更好、更丰富的指标,这些指标可以通过 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):
csharp
- 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 将请求/响应对与连接数据相关联:
csharp
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:
csharp
-void Redirect();
+void Redirect(string redirectUri);
HTTP 错误代码
HttpClient 在诊断方面的问题之一是,当发生异常时,很难以编程方式区分错误的确切根本原因。区分它们的唯一方法是解析来自 HttpRequestException 的异常消息。此外,其他 HTTP 实现(如带有 ERROR_WINHTTP_* 错误码的 WinHTTP)以数字代码或枚举的形式提供了此类功能。所以 .NET 8引入了一个类似的枚举,并在 HTTP 处理抛出的异常中提供了它,它们是:
HttpRequestException 用于接收响应头之前的请求处理。
读取响应内容时抛出 HttpIOException。
在 dotnet/runtime#76644 API 提案中描述了 HttpRequestError 枚举的设计以及如何将其插入 HTTP 异常。
现在,HttpClient 方法的使用者可以更容易、更可靠地处理特定的内部错误:
csharp
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 代理支持
这个版本中实现的最受欢迎的功能之一是支持 HTTPS 代理(dotnet/runtime#31113)。现在可以使用代理处理通过 HTTPS发送的请求,这意味着与代理的连接是安全的。这并没有涉及来自代理本身的请求,它仍然可以是 HTTP 或 HTTPS。对于纯文本 HTTP 请求,与 HTTPS 代理的连接是安全的(通过 HTTPS),然后是从代理到目标的纯文本请求。如果是 HTTPS 请求(代理隧道),打开隧道的初始 CONNECT 请求将通过安全通道 (HTTPS) 发送到代理,然后是从代理通过隧道到目的地的 HTTPS 请求。
如果要利用该功能,只需在设置代理时使用 HTTPS 方案即可:
csharp
using HttpClient client = new HttpClient(new SocketsHttpHandler()
{
Proxy = new WebProxy("https://proxy.address:12345")
});
using HttpResponseMessage response = await client.GetAsync("https://httpbin.org/");
HttpClientFactory
.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。
csharp
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 方法提取公共部分:
csharp
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 方法,需要分别修改先前配置的主处理程序或附加处理程序列表。
csharp
// 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)));
修改 HttpClient 日志记录
自定义(或者干脆关闭)HttpClientFactory 日志记录是长期请求的功能之一(dotnet/runtime#77312)。
旧日志记录概述
HttpClientFactory 添加的默认("旧")日志记录非常冗长,每个请求发出 8 条日志消息:
- 使用请求 URI 启动通知 ------ 在通过委托处理程序管道传播之前;
- 请求标头 ------ 在处理程序管道之前;
- 使用请求 URI 启动通知 ------ 在处理程序管道之后;
- 请求标头 ------ 处理程序管道之后;
- 随着时间的流逝停止通知 ------ 在通过委托处理程序管道传播回响应之前;
- 响应标头 ------ 在传播回响应之前;
- 随着时间的流逝停止通知 ------ 在传播回响应之后;
- 响应标头 ------ 将响应传播回来之后。
这可以用下面的图来说明。在下图中,* 和 [...] 表示日志记录事件(在默认实现中,日志消息被写入 ILogger),--> 表示通过应用程序层和传输层的数据流。
csharp
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 日志记录的控制台输出如下所示:
csharp
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 结合起来特别强大。这样,您就可以在一行中删除所有客户端的"旧"日志记录:
csharp
services.ConfigureHttpClientDefaults(b => b.RemoveAllLoggers()); // remove HttpClientFactory default logging for all clients
如果您需要恢复"旧"日志记录,例如 针对特定客户端,您可以使用 AddDefaultLogger() 来执行此操作。
添加自定义日志记录
除了能够删除"旧"日志记录之外,新的 HttpClientFactory API 还允许您完全自定义日志记录。您可以指定当 HttpClient 启动请求、接收响应或引发异常时记录的内容和方式。
您可以同时添加多个自定义记录器 - 例如,控制台和 ETW 记录器,或"包装"和"不包装"记录器。由于其附加性质,您可能需要事先显式删除默认的"旧"日志记录。
如果要添加自定义日志记录,您需要实现 IHttpClientLogger 接口,然后使用 AddLogger 将自定义记录器添加到客户端。请注意,日志记录实现不应引发任何异常,否则可能会中断请求执行。
注册:
csharp
services.AddSingleton<SimpleConsoleLogger>(); // register the logger in DI
services.AddHttpClient("foo") // add a client
.RemoveAllLoggers() // remove previous logging
.AddLogger<SimpleConsoleLogger>(); // add the custom logger
示例记录器实现:
csharp
// 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}");
}
示例输出:
csharp
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");
csharp
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。
以下示例显示了如何使用上下文对象来传递自定义请求标识符。
csharp
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);
}
csharp
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 参数来指定记录器是否将被
- 包装处理程序管道(添加到管道的顶部,对应于上面旧日志记录概述部分中的 1、2、7 和 8 号消息)
csharp
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 <--
- 或者,不包装处理程序管道(添加到底部,对应于上面旧日志记录概述部分中的第 3、4、5 和 6 号消息)。
csharp
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)。
这可以用下图来说明:
- 包装案例(wrapHandlersPipeline=TRUE)
csharp
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
- 不包装案例(wrapHandlersPipeline=FALSE)
csharp
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 <--
csharp
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
简化的 SocketsHttpHandler 配置
.NET 8 添加了更方便、更流畅的方式来使用 SocketsHttpHandler 作为 HttpClientFactory 中的主处理程序(dotnet/runtime#84075)。
您可以使用 UseSocketsHttpHandler 方法设置和配置 SocketsHttpHandler。您可以使用 IConfiguration 从配置文件设置 SocketsHttpHandler 属性,也可以从代码中配置它,或者可以结合使用这两种方法。
请注意,将 IConfiguration 应用于 SocketsHttpHandler 时,仅解析 bool、int、Enum 或 TimeSpan 类型的 SocketsHttpHandler 属性。IConfiguration 中所有不匹配的属性都将被忽略。配置仅在注册时解析一次并且不会重新加载,因此在应用程序重新启动之前,处理程序不会反映任何配置文件更改。
csharp
// 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"
}
}
}
QUIC
OpenSSL 3 支持
当前大多数 Linux 发行版在其最新版本中都采用了 OpenSSL 3:
- Debian 12+:Bookworm OpenSSL
- Ubuntu 22+:Jammy OpenSSL
- Fedora 37+:Fedora OpenSSL
- OpenSUSE:Tumbleweed OpenSSL
- AlmaLinux 9+:AlmaLinux 9 软件包存储库
.NET 8 的 QUIC 支持已准备就绪(dotnet/runtime#81801)。
实现这一目标的第一步是确保 System.Net.Quic 下使用的 QUIC 实现 MsQuic 可以与 OpenSSL 3+ 一起使用。这项工作在 MsQuic 存储库 microsoft/msquic#2039 中进行。下一步是确保构建并发布的 libmsquic 包相应的依赖于特定发行版和版本的默认 OpenSSL 版本。例如 Debian 发行版:
- Debian 11 libmsquic 依赖于 OpenSSL 1.1
- Debian 12 libmsquic 依赖于 OpenSSL 3
最后一步是确保正在测试的MsQuic 和 OpenSSL版本正确,并且测试覆盖了所有 .NET 支持的发行版。
异常
在 .NET 7 中发布 QUIC API(作为预览功能)后,我们收到了几个有关异常的问题:
-
找不到主机时 QuicConnection.ConnectAsync 引发 SocketException
-
QuicListener AcceptConnectionAsync 和 OperationCanceledException
-
dotnet/runtime#75115:QuicListener.AcceptConnectionAsync 重新抛出异常
在 .NET 8 中,System.Net.Quic 异常行为在 dotnet/runtime#82262 中进行了彻底修改,并且解决了上述问题。
修订的主要目标之一是确保 System.Net.Quic 中的异常行为在整个命名空间中尽可能一致。总的来说,当前的行为可以总结如下:
-
QuicException:特定于 QUIC 协议或与其处理相关的所有错误。
- 连接由本地或由对等方关闭。
- 连接因不活动而超时。
- 流被本地或由对等方中止。
- QuicError 中描述的其他错误
-
SocketException:针对网络问题,例如网络状况、名称解析或用户错误。
- 地址已被使用。
- 无法访问目标主机。
- 指定的地址无效。
- 无法解析主机名。
-
AuthenticationException:所有与 TLS 相关的问题。目标是具有与 SslStream 类似的行为。
- 证书相关错误。
- ALPN 协商错误。
- 握手期间用户取消。
-
ArgumentException:当提供 QuicConnectionOptions 或 QuicListenerOptions 无效时。
- 提供的流限制不在 0-65535 范围内。
- 省略强制属性,例如:DefaultCloseErrorCode 或 DefaultStreamErrorCode。
- 未指定 ClientAuthenticationOptions 或 ServerAuthenticationOptions。
-
OperationCanceledException:每当 CancellationToken 被触发时取消。
-
ObjectDisposedException:每当在已释放的对象上调用方法时。
请注意,上述示例并不详尽。
除了改变行为之外,QuicException 也发生了改变。其中一项变化是调整 QuicError 枚举值。现在 SocketException 涵盖的项目已被删除,并为用户回调错误添加了一个新值(dotnet/runtime#87259)。新添加的 CallbackError 用于区分
QuicListenerOptions.ConnectionOptionsCallback 引发的异常与 System.Net.Quic 引发的异常(dotnet/runtime#88614)。因此,如果用户代码抛出 ArgumentException,QuicListener.AcceptConnectionAsync 会将其包装在 QuicException 中,并将 QuicError 设置为 CallbackError,并且内部异常将包含原始用户抛出的异常。它可以这样使用:
csharp
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
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 代码的效率:
csharp
// 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 现在有几个额外的成员,使其本身更有用:
- getter Buffer:访问整个底层地址缓冲区。
- setter Size:能够调整上述缓冲区大小(只能调整到较小的尺寸)。
- static GetMaximumAddressSize:根据地址类型获取所需的缓冲区大小。
- 接口 IEquatable<socketaddress< span="">>:SocketAddress 可用于区分 Socket 与之通信的对等点,例如作为字典中的键(这不是新功能,它只是使其可通过接口调用)。
最后,删除了一些内部生成的 IP 地址数据副本,以提高性能。
Networking Primitives
MIME 类型
添加缺失的 MIME 类型是网络空间中投票最多的问题之一(dotnet/runtime#1489)。这是一个主要由社区驱动的更改,最终形成了 dotnet/runtime#85807 API 提案。由于此添加需要经过 API 审核流程,因此有必要确保添加的类型是相关的并遵循规范(IANA 媒体类型)。对于这项准备工作,我们要感谢社区贡献者 Bilal-io 和 mmarinchenko。
IPNetwork
.NET 8 中添加的另一个新 API 是新类型 IPNetwork(dotnet/runtime#79946)。该结构允许指定 RFC 4632 中定义的无类 IP 子网。例如:
- 127.0.0.0/8 用于与 A 类子网对应的无类定义。
- 42.42.128.0/17 用于 215 个地址的无类子网。
- 2a01:110:8012::/100 用于 228 个地址的 IPv6 子网。
新的 API 可以使用构造函数从 IPAddress 和前缀长度进行构造,也可以通过 TryParse 或 Parse 从字符串进行解析。最重要的是,它允许使用 Contains 方法检查 IPAddress 是否属于子网。示例用法如下:
csharp
// 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 存储库中与我们联系。
最后,我要感谢我的合著者:
- Metrics 的作者:@antonfirsov。
- HttpClientFactory 的作者:@CarnaViire。