.NET 8 网络改进

作者:
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 密切合作设计的,确保新指标符合标准,并与 PrometheusGrafana 等流行工具良好配合。

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

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

HTTP 错误代码

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 代理支持

这个版本中实现的最受欢迎的功能之一是支持 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/");

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。

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

修改 HttpClient 日志记录

自定义(或者干脆关闭)HttpClientFactory 日志记录是长期请求的功能之一(dotnet/runtime#77312)。

旧日志记录概述

HttpClientFactory 添加的默认(“旧”)日志记录非常冗长,每个请求发出 8 条日志消息:

  1. 使用请求 URI 启动通知 —— 在通过委托处理程序管道传播之前;
  2. 请求标头 —— 在处理程序管道之前;
  3. 使用请求 URI 启动通知 —— 在处理程序管道之后;
  4. 请求标头 —— 处理程序管道之后;
  5. 随着时间的流逝停止通知 —— 在通过委托处理程序管道传播回响应之前;
  6. 响应标头 —— 在传播回响应之前;
  7. 随着时间的流逝停止通知 —— 在传播回响应之后;
  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 <--
  • 或者,不包装处理程序管道(添加到底部,对应于上面旧日志记录概述部分中的第 3、4、5 和 6 号消息)。
  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)
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)
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

简化的 SocketsHttpHandler 配置

.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"
    }
  }
}

QUIC

OpenSSL 3 支持

当前大多数 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(作为预览功能)后,我们收到了几个有关异常的问题:

在 .NET 8 中,System.Net.Quic 异常行为在 dotnet/runtime#82262 中进行了彻底修改,并且解决了上述问题。

修订的主要目标之一是确保 System.Net.Quic 中的异常行为在整个命名空间中尽可能一致。总的来说,当前的行为可以总结如下:

  • QuicException:特定于 QUIC 协议或与其处理相关的所有错误。

    • 连接由本地或由对等方关闭。
    • 连接因不活动而超时。
    • 流被本地或由对等方中止。
    • QuicError 中描述的其他错误
  • SocketException:针对网络问题,例如网络状况、名称解析或用户错误。

    • 地址已被使用。
    • 无法访问目标主机。
    • 指定的地址无效。
    • 无法解析主机名。
  • AuthenticationException:所有与 TLS 相关的问题。目标是具有与 SslStream 类似的行为。

    • 证书相关错误。
    • ALPN 协商错误。
    • 握手期间用户取消。
  • ArgumentException:当提供 QuicConnectionOptionsQuicListenerOptions 无效时。

  • 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

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 现在有几个额外的成员,使其本身更有用:

  • 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-iommarinchenko

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 和前缀长度进行构造,也可以通过 TryParseParse 从字符串进行解析。最重要的是,它允许使用 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 存储库中与我们联系。

最后,我要感谢我的合著者: