技术速递|.NET 9 网络优化

news/2025/2/27 6:07:31

作者:Máňa,Natalia,Anton

排版:Alan Wang

秉承我们的传统,我们很高兴与您分享这篇博客文章,以介绍新的 .NET 版本中网络领域相关的最新动态和最有趣的变化。今年,我们带来了 HTTP 领域的改变、新的 HttpClientFactory API、.NET Framework 兼容性优化等更多内容。

HTTP

在接下来的部分中,我们将介绍 HTTP 领域最具影响力的变化。其中包括连接池的性能优化、对多个 HTTP/3 连接的支持、Windows 代理的自动更新,以及重要的社区贡献。

开始使用 NES

在此版本中,我们对 HTTP 连接池进行了两项显著的性能优化。

我们增加了对多个 HTTP/3 连接的可选支持。根据 RFC 9114 标准文档,由于连接可以多路复用并行请求,因此不鼓励使用多个 HTTP/3 连接到对等端。然而,在某些场景下,例如服务器到服务器的通信,即使请求多路复用,单一连接也可能成为瓶颈。我们在 HTTP/2 中看到了这样的限制(dotnet/runtime#35088),它同样具有在单一连接上多路复用的概念。出于同样的原因(dotnet/runtime#51775),我们决定为 HTTP/3 实现多连接支持(dotnet/runtime#101535)。

该实现本身尽可能贴近 HTTP/2 多连接机制的行为。当前,它的策略是优先填充已有连接,直到达到对端允许的请求上限后,才会创建新的连接。不过,需要注意的是,这是一个具体的实现细节,该行为在未来可能会有所变化。

结果是,我们的基准测试显示每秒请求数(RPS)有显著提升,以下是10,000个并行请求的对比结果:

客户端单 HTTP/3 连接多 HTTP/3 连接
最大CPU使用率(%)3592
最大核心使用率(%)9712572
最大工作集(MB)38106491
最大私有内存(MB)44157228
处理器数量2828
首次请求时长(ms)519594
请求数3454464325325
平均每秒响应数23069288664

请注意,最大 CPU 使用率的增加意味着 CPU 利用率更高,也就是说,CPU 正在忙于处理请求,而不是处于空闲状态。

可以通过 SocketsHttpHandler 上的 EnableMultipleHttp3Connections 属性来启用此功能:

var client = new HttpClient(new SocketsHttpHandler
{
    EnableMultipleHttp3Connections = true
});

我们还解决了 HTTP/1.1 连接池中的锁争用问题(dotnet/runtime#70098)。 此前,HTTP/1.1 连接池使用单一锁来管理连接列表和待处理请求队列。在高吞吐量场景下,特别是在拥有大量 CPU 核心的机器上,这个锁成为了性能瓶颈。为了解决这个问题(dotnet/runtime#99364),我们用并发集合(Concurrent Collection)替换了原本带锁的普通列表。我们选择了 ConcurrentStack,因为它能保持请求始终由最新可用的连接处理,同时允许在连接的生命周期到期后回收旧连接。 在我们的基准测试中,这一优化使 HTTP/1.1 请求的吞吐量提高了30%以上。

客户端.NET 8.0.NET 9.0增长
请求数80028791107128778+33.86%
平均每秒响应数666886892749+33.87%

Windows 上的代理自动更新

在开发者使用早期版本 .NET 的应用程序进行 HTTP 流量调试时,他们的主要痛点之一是应用程序不会响应 Windows 代理设置的更改(dotnet/runtime#46910)。

此前,代理设置在每个进程启动时仅初始化一次,没有合理的方式来刷新这些设置 。例如,在 .NET 8 中,HttpClient.DefaultProxy 每次访问都会返回相同的实例,且不会重新获取代理设置。因此,像 Fiddler 这样将自己设置为系统代理以监听流量的工具,无法捕获已经运行进程的流量。这个问题在 dotnet/runtime#103364 中得到了缓解,其中HttpClient.DefaultProxy 被设置为一个Windows代理实例,该实例会监听注册表的变化,并在收到通知时重新加载代理设置。

以下是代码:

while (true)
{
    using var resp = await client.GetAsync("https://httpbin.org/");
    Console.WriteLine(HttpClient.DefaultProxy.GetProxy(new Uri("https://httpbin.org/"))?.ToString() ?? "null");
    await Task.Delay(1_000);
}

产生如下输出:

null
// After Fiddler’s “System Proxy” is turned on.
http://127.0.0.1:8866/

请注意,此更改仅适用于 Windows,因为 它具有独特的全局系统代理设置概念。而 Linux 及其他基于 UNIX 的系统仅允许通过环境变量设置代理,这些环境变量在进程运行期间无法更改。

社区贡献

我们想感谢社区做出的贡献。

HttpContent.LoadIntoBufferAsync 中缺少 CancellationToken 重载。@andrewhickman-aveva 的 API 提案 (dotnet/runtime#102659) 和 @manandre 的实现 (dotnet/runtime#103991) 解决了此问题。

另一个更改优化了 SocketsHttpHandler 和 HttpClientHandler 上的 MaxResponseHeadersLength 属性的单位不一致问题(dotnet/runtime#75137)。所有其他的大小和长度属性都被解释为字节,而这个属性却被解释为千字节。由于不能改变实际行为以保持向后兼容性,因此通过实现一个分析器(dotnet/roslyn-analyzers#6796)来解决了这个问题。该分析器旨在确保用户意识到提供的值是以千字节为单位的,并且如果用法表明有误,则会发出警告。

如果该值高于某个阈值,则如下所示:
在这里插入图片描述
该分析器由 @amiru3f 实现。

QUIC

在 .NET 9 中,QUIC 领域的显著变化包括将 QUIC 库公开、为连接提供更多配置选项以及多项性能优化。

公共 API

从此版本开始,System.Net.Quic 不再隐藏在 PreviewFeature 后面,所有的 API 都是随时可用的,无需添加任何可选开关(dotnet/runtime#104227)。

QUIC 连接选项

我们扩展了 QuicConnection 的配置选项(dotnet/runtime#72984)。该实现(dotnet/runtime#94211)向 QuicConnectionOptions 中添加了三个新属性:

  • HandshakeTimeout – 我们之前已经对连接建立的最大时长进行了限制,这个属性允许用户调整该限制。

  • KeepAliveInterval – 如果该属性设置为正值,则在此时间间隔内定期发送 PING 帧(如果连接上没有其他活动),以防止连接因空闲超时而被关闭。

  • InitialReceiveWindowSizes – 一组参数,用于调整传输参数中数据流控制的初始接收限制。这些数据限制仅适用于动态流量控制算法开始根据数据读取速度调整限制之前。由于 MsQuic 的限制,这些参数只能设置为 2 的幂次方。

所有这些参数都是可选的。它们的默认值源自 MsQuic 的默认值。以下代码可以通过编程方式报告默认值:

var options = new QuicClientConnectionOptions();
Console.WriteLine($"KeepAliveInterval = {PrettyPrintTimeStamp(options.KeepAliveInterval)}");
Console.WriteLine($"HandshakeTimeout = {PrettyPrintTimeStamp(options.HandshakeTimeout)}");
Console.WriteLine(@$"InitialReceiveWindowSizes =
{{
    Connection = {PrettyPrintInt(options.InitialReceiveWindowSizes.Connection)},
    LocallyInitiatedBidirectionalStream = {PrettyPrintInt(options.InitialReceiveWindowSizes.LocallyInitiatedBidirectionalStream)},
    RemotelyInitiatedBidirectionalStream = {PrettyPrintInt(options.InitialReceiveWindowSizes.RemotelyInitiatedBidirectionalStream)},
    UnidirectionalStream = {PrettyPrintInt(options.InitialReceiveWindowSizes.UnidirectionalStream)}
}}"
);
static string PrettyPrintTimeStamp(TimeSpan timeSpan)
    => timeSpan == Timeout.InfiniteTimeSpan ? "infinite" : timeSpan.ToString();
static string PrettyPrintInt(int sizeB)
    => sizeB % 1024 == 0 ? $"{sizeB / 1024} * 1024" : sizeB.ToString();
// Prints:
KeepAliveInterval = infinite
HandshakeTimeout = 00:00:10
InitialReceiveWindowSizes =
{
    Connection = 16384 * 1024,
    LocallyInitiatedBidirectionalStream = 64 * 1024,
    RemotelyInitiatedBidirectionalStream = 64 * 1024,
    UnidirectionalStream = 64 * 1024
}

流容量 API

.NET 9 还引入了新的 API 以支持在 SocketsHttpHandler 中使用多个 HTTP/3 连接(dotnet/runtime#101534)。这些 API 是专门为这种特定用法设计的,我们预计除了非常小众的应用场景外这些 API可能不会被频繁使用。

QUIC 协议内置了流量限制管理逻辑。因此,如果没有可用的流容量,调用 OpenOutboundStreamAsync 来创建新流会被暂停。此外,目前没有有效的方法来判断流限制是否已达到。所有这些限制使得 HTTP/3 层无法判断何时应打开新的连接。因此,我们引入了一个新的 StreamCapacityCallback,每当流容量增加时都会调用该回调。该回调本身通过 QuicConnectionOptions 注册。有关回调的更多细节,请参阅文档。

性能优化

System.Net.Quic 中的两项性能优化都与 TLS 相关,并且都只影响连接建立的时间。

第一项与性能相关的更改是在 .NET 线程池中异步运行对等证书验证 (dotnet/runtime#98361)。证书验证本身可能非常耗时,甚至可能包括执行用户回调。将此逻辑移动到 .NET 线程池可防止我们阻塞 MsQuic 线程(MsQuic 的线程数量有限),从而使 MsQuic 能够同时处理更多新连接。

更重要的是,我们还引入了 MsQuic 配置的缓存(dotnet/runtime#99371)。MsQuic 配置是一组本地结构,包含来自 QuicConnectionOptions 的连接设置,可能包括证书及其中介证书。构建和初始化这些本地结构可能非常昂贵,因为这可能需要将所有证书数据序列化和反序列化为 PKS #12 格式。此外,该缓存允许在设置相同的情况下,重用相同的 MsQuic 配置来处理不同的连接。特别是服务器场景,具有静态配置的场景可以从缓存中获益,以下是相关代码:

var alpn = "test";
var serverCertificate = X509CertificateLoader.LoadCertificateFromFile("../path/to/cert");
// Prepare the connection option upfront and reuse them.
var serverConnectionOptions = new QuicServerConnectionOptions()
{
    DefaultStreamErrorCode = 123,
    DefaultCloseErrorCode = 456,
    ServerAuthenticationOptions = new SslServerAuthenticationOptions
    {
        ApplicationProtocols = new List<SslApplicationProtocol>() { alpn },
        // Re-using the same certificate.
        ServerCertificate = serverCertificate
    }
};
// Configure the listener to return the pre-prepared options.
await using var listener = await QuicListener.ListenAsync(new QuicListenerOptions()
{
    ListenEndPoint = new IPEndPoint(IPAddress.Loopback, 0),
    ApplicationProtocols = [ alpn ],
    // Callback returns the same object.
    // Internal cache will re-use the same native structure for every incoming connection.
    ConnectionOptionsCallback = (_, _, _) => ValueTask.FromResult(serverConnectionOptions)
});

我们还为此功能构建了一个备用方式,可以通过任一环境变量关闭该功能:

export DOTNET_SYSTEM_NET_QUIC_DISABLE_CONFIGURATION_CACHE=1
# run the app

或者使用 AppContext 开关:

AppContext.SetSwitch("System.Net.Quic.DisableConfigurationCache", true);

WebSockets

.NET 9 为 WebSockets 引入了大家期盼已久的 PING/PONG Keep-Alive 策略 (dotnet/runtime#48729)。

在 .NET 9 之前,唯一可用的 Keep-Alive 策略是 Unsolicited PONG。它足以防止底层 TCP 连接空闲,但在远程主机无响应的情况下(例如,远程服务器崩溃),而检测此类情况的唯一方法是依赖 TCP 超时。

在此版本中,我们用新的 KeepAliveTimeout 设置补充了现有的 KeepAliveInterval 设置,因此 Keep-Alive 策略的选择如下:

  • Keep-AliveOFF,当

    • KeepAliveIntervalTimeSpan.ZeroTimeout.InfiniteTimeSpan 时。
  • Unsolicited PONG,当

    • KeepAliveInterval 为正有限 TimeSpan 值, 并且

    • KeepAliveTimeoutTimeSpan.ZeroTimeout.InfiniteTimeSpan 时。

  • PING/PONG,当

    • KeepAliveInterval 为正有限 TimeSpan 值, 并且

    • KeepAliveTimeout 为正有限 TimeSpan 值时。

默认情况下,将保留预先存在的 Keep-Alive 行为:KeepAliveTimeout 默认值为 Timeout.InfiniteTimeSpan,因此 Unsolicited PONG 仍然是默认策略。

以下示例展示了如何为 ClientWebSocket 启用 PING/PONG 策略:

var cws = new ClientWebSocket();
cws.Options.KeepAliveInterval = TimeSpan.FromSeconds(10);
cws.Options.KeepAliveTimeout = TimeSpan.FromSeconds(10);
await cws.ConnectAsync(uri, cts.Token);
// NOTE: There should be an outstanding read at all times to
// ensure incoming PONGs are promptly processed
var result = await cws.ReceiveAsync(buffer, cts.Token);

如果在 KeepAliveTimeout 时间过去后仍未收到 PONG 响应,则远程端点将被视为无响应,WebSocket 连接将自动中止。同时,任何未完成的 ReceiveAsync 操作都会抛出 OperationCanceledException 以解除阻塞。

要了解有关该功能的更多信息,您可以查看专门的概念文档。

.NET Framework 兼容性

在将项目从 .NET Framework 迁移到 .NET Core 时,网络领域面临的最大障碍之一是 HTTP 技术栈的差异。在 .NET Framework 中,处理 HTTP 请求的主要类是 HttpWebRequest,它依赖全局的 ServicePointManager 以及各自的 ServicePoint 来管理连接池。而在 .NET Core 中,推荐的方式是使用 HttpClient 来访问 HTTP 资源。此外,.NET Framework 中的所有相关类在 .NET 中仍然存在,但要么已被标记为过时,要么缺少实现,或者根本不再维护。因此,我们经常会看到一些错误,例如使用 ServicePointManager 来配置连接的同时,又使用 HttpClient 访问资源。

我们的建议始终是完全迁移到 HttpClient,但有时候这并不现实。将项目从 .NET Framework 迁移到 .NET Core 本身就已相当困难,更不用说还要重写所有的网络代码。期望用户在一次迁移中完成所有这些工作并不现实,这也是导致许多用户对迁移持谨慎态度的原因之一。为了解决这些痛点,我们补充了部分 .NET Core 之前缺失的旧类实现,并提供了一份完整的迁移指南来帮助开发者更顺利地完成迁移。

首先,我们扩展了受支持的 ServicePointManagerServicePoint 中一些缺失的属性,这些属性此前在 .NET Core 中尚未实现(见 dotnet/runtime#94664 和 dotnet/runtime#97537)。有了这些变更后,它们现在可以在 HttpWebRequest 中被正确地使用。

对于 HttpWebRequest,我们在 dotnet/runtime#95001 中实现了对 AllowWriteStreamBuffering 的完全支持。此外,还在 dotnet/runtime#102038 中添加了对 ImpersonationLevel 的缺失支持。

除了这些更改之外,我们还淘汰了一些旧版类,以防止进一步混淆:

  • ServicePointManager(见 dotnet/runtime#103456)。它的设置对 HttpClientSslStream 没有任何影响,但很多开发者可能仍然会错误地尝试使用它来配置这些组件。

  • AuthenticationManager(见 dotnet/runtime#93171),该更改由社区贡献者 @deeprobin 提交。这个类要么缺少实现,要么其方法直接抛出 PlatformNotSupportedException,因此不再推荐使用。

最后,我们还整理了一份详细的从 HttpWebRequest 迁移到 HttpClient 的指南(HttpWebRequest to HttpClient migration guide)。它包括各个属性和方法之间映射的综合列表,例如迁移 ServicePoint(Manager) 用法,以及许多简单和不那么简单的场景的示例,例如示例:启用 DNS 轮询。

诊断工具

在此版本中,诊断工具的优化重点在于增强隐私保护和提升分布式追踪能力。

HttpClientFactory 日志中的 Uri 查询参数脱敏

从 Microsoft.Extensions.Http 9.0.0 版本开始,HttpClientFactory 日志逻辑默认优先保护隐私。

在旧版本中,HttpClientFactory 的默认日志逻辑会在 RequestStart 和 RequestPipelineStart 事件中记录完整的请求 URI。如果 URI 的某些部分包含敏感信息,这可能会导致数据泄露,从而引发隐私问题。

在 8.0.0 版本中,引入了一种通过自定义日志记录来提高 HttpClientFactory 安全性的方式。但默认行为仍可能对不知情的用户构成风险。

大多数有问题的情况下,敏感信息存在于查询组件中。因此,在 9.0.0 版本中引入了一项重大变更,默认情况下会从 HttpClientFactory 日志中移除整个查询字符串。为服务/应用提供了一个全局可选开关来安全记录完整 URI。

为了一致性和更高的安全性,System.Net.Http 中的 EventSource 事件也进行了类似的修改。

我们知道这种解决方案可能并不适用于所有情况。理想情况下,应该提供更细粒度的 URI 过滤机制,允许用户保留非敏感的查询参数,或者对其他 URI 组件(例如路径的一部分)进行过滤。我们计划在未来版本中探索此类功能(dotnet/runtime#110018)。

分布式追踪优化

分布式追踪是一种诊断技术,用于跟踪特定事务在多个进程和机器上的路径,从而帮助识别瓶颈和故障。

这种技术将事务建模为活动的分层树,在 OpenTelemetry 术语中,这些活动通常被称为跨度。

当启用追踪时,HttpClientHandlerSocketsHttpHandler 都会为每个请求启动一个活动,并通过标准的 W3C 头传播追踪上下文。

在 .NET 9 之前,用户需要使用 OpenTelemetry .NET SDK 来生成符合 OpenTelemetry 标准的有用追踪。这个 SDK 不仅用于收集和导出数据,还用于增强监控能力,因为内置的逻辑并不会填充请求数据到活动中。

从 .NET 9 开始,除非需要富集等高级功能,否则可以省略检测依赖项(OpenTelemetry.Instrumentation.Http)。在 dotnet/runtime#104251 中,我们扩展了内置的追踪功能,确保活动的结构符合 OTel 标准,名称、状态和大多数必需的标签都根据标准进行了填充。

实验性连接追踪

在调查瓶颈时,您可能希望深入了解特定的 HTTP 请求,以识别大部分时间花费在哪里。是在建立连接时,还是在内容下载时?当遇到连接问题,如果能够确定问题是出在 DNS 查找、TCP 连接建立,还是 TLS 握手的话,这将会非常有帮助。

.NET 9 引入了几个新的跨度,以表示 SocketsHttpHandler 中与连接建立相关的活动。其中最重要的是 HTTP 连接设置跨度,它细分为 DNS、TCP 和 TLS 活动的三个子跨度。

由于连接设置不依赖于 SocketsHttpHandler 连接池中的特定请求,因此连接设置跨度无法被建模为 HTTP 客户端请求跨度的子跨度。相反,请求和连接之间的关系使用跨度链接(也称为活动链接)。

注意
这些新跨度由匹配通配符 Experimental.System.Net.* 的各种 ActivitySources 生成。这些跨度是实验性的,因为像 Azure Monitor Application Insights 这样的监控工具很难有效地可视化生成的追踪信息,原因是存在大量的 connection_setup → request 的反向链接。为了改善监控工具中的用户体验,仍需要进一步的工作。这项工作涉及 .NET 团队、OTel 和工具作者的合作,可能会导致新跨度设计的重大变更。

设置和尝试连接跟踪收集的最简单方法是使用 .NET Aspire。通过 Aspire Dashboards,您可以展开 connection_setup 活动,并查看连接初始化的详细信息。
在这里插入图片描述
如果您认为 .NET 9 的追踪新增功能可能为您带来有价值的诊断见解,并且想要亲自体验一下,请随时阅读我们关于 System.Net 库中分布式追踪的完整文章。

HttpClientFactory

对于 HttpClientFactory,我们引入了 Keyed DI 支持,提供了一种新的便捷使用模式,并更改了默认的 Primary Handler,以减少常见的错误使用情况。

Keyed DI 支持

在之前的版本中,Keyed Services 被引入到 Microsoft.Extensions.DependencyInjection 包中。Keyed DI 允许您在注册单一服务类型的多个实现时指定键值,并随后使用相应的键值来检索特定的实现。

HttpClientFactory 和命名的 HttpClient 实例与 Keyed Services 的理念非常契合。HttpClientFactory 是一种弥补长期以来缺失的 DI 功能的方式。但它要求您获取、存储并查询 IHttpClientFactory 实例,而不是直接注入一个配置好的 HttpClient,这可能不太方便。虽然类型化客户端(Typed clients)试图简化这一部分,但它也有一个缺点:类型化客户端容易配置错误和误用(在某些场景中,支持基础设施也可能带来明显的开销)。因此,这两种情况下的用户体验都不太理想。

这一情况得到了改变,因为 Microsoft.Extensions.DependencyInjection 9.0.0 和 Microsoft.Extensions.Http 9.0.0 包将 Keyed DI 支持引入 HttpClientFactory(dotnet/runtime#89755)。现在,您可以获得两全其美的体验:既可以使用方便且高度可配置的 HttpClient 注册,也可以直接注入特定的已配置 HttpClient 实例。

从 9.0.0 开始,您需要通过调用 AddAsKeyed() 扩展方法来启用此功能。它会将命名的 HttpClient 注册为一个键控服务,键值等于客户端的名称,并允许您使用 Keyed Services API(例如 [FromKeyedServices(…)])来获取所需的 HttpClient。

以下代码演示了 HttpClientFactory、Keyed DI 和 ASP.NET Core 9.0 Minimal API 之间的集成:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient("github", c =>
    {
        c.BaseAddress = new Uri("https://api.github.com/");
        c.DefaultRequestHeaders.Add("Accept", "application/vnd.github.v3+json");
        c.DefaultRequestHeaders.Add("User-Agent", "dotnet");
    })
    .AddAsKeyed(); // Add HttpClient as a Keyed Scoped service for key="github"
var app = builder.Build();
// Directly inject the Keyed HttpClient by its name
app.MapGet("/", ([FromKeyedServices("github")] HttpClient httpClient) =>
    httpClient.GetFromJsonAsync<Repo>("/repos/dotnet/runtime"));
app.Run();
record Repo(string Name, string Url);

端点响应:

> ~  curl http://localhost:5000/
{"name":"runtime","url":"https://api.github.com/repos/dotnet/runtime"}

默认情况下,AddAsKeyed()HttpClient 注册为 Keyed Scoped 服务。Scoped 生命周期有助于捕获依赖关系的情况:

services.AddHttpClient("scoped").AddAsKeyed();
services.AddSingleton<CapturingSingleton>();
// Throws: Cannot resolve scoped service 'System.Net.Http.HttpClient' from root provider.
rootProvider.GetRequiredKeyedService<HttpClient>("scoped");
using var scope = provider.CreateScope();
scope.ServiceProvider.GetRequiredKeyedService<HttpClient>("scoped"); // OK
// Throws: Cannot consume scoped service 'System.Net.Http.HttpClient' from singleton 'CapturingSingleton'.
public class CapturingSingleton([FromKeyedServices("scoped")] HttpClient httpClient)
//{ ...

您还可以通过将 ServiceLifetime 参数传递给 AddAsKeyed() 方法来明确指定生存期:

services.AddHttpClient("explicit-scoped")
    .AddAsKeyed(ServiceLifetime.Scoped);
services.AddHttpClient("singleton")
    .AddAsKeyed(ServiceLifetime.Singleton);

您不必为每个客户端都调用 AddAsKeyed —— 您可以通过 ConfigureHttpClientDefaults 轻松地“全局”启用(适用于任何客户端名称)。从 Keyed Services 的角度来看,这将实现 KeyedService.AnyKey 注册。

services.ConfigureHttpClientDefaults(b => b.AddAsKeyed());
services.AddHttpClient("foo", /* ... */);
services.AddHttpClient("bar", /* ... */);
public class MyController(
    [FromKeyedServices("foo"
)] HttpClient foo,
    [FromKeyedServices("bar")] HttpClient bar)
//{ ...

尽管“全局”启用是一个单行代码,但遗憾的是该功能仍然需要显式调用,而不是“开箱即用”。有关该决策的完整背景和原因,请参阅 dotnet/runtime#89755 和 dotnet/runtime#104943。

您可以通过调用 RemoveAsKeyed() 明确地从 HttpClients 中移除 Keyed DI(例如,在“全局”启用的情况下,为特定客户端移除它)。

services.ConfigureHttpClientDefaults(b => b.AddAsKeyed());      // opt IN by default
services.AddHttpClient("keyed", /* ... */);
services.AddHttpClient("not-keyed", /* ... */).RemoveAsKeyed(); // opt OUT per name
​
provider.GetRequiredKeyedService<HttpClient>("keyed");     // OK
provider.GetRequiredKeyedService<HttpClient>("not-keyed"); // Throws: No service for type 'System.Net.Http.HttpClient' has been registered.
provider.GetRequiredKeyedService<HttpClient>("unknown");   // OK (unconfigured instance)

如果 AddAsKeyed()RemoveAsKeyed() 一起调用,或者其中任何一个调用超过一次,它们通常遵循 HttpClientFactory 配置和 DI 注册的规则:

  • 当在相同的名称下使用,最后一个设置生效:AddAsKeyed() 的最后一个生命周期设置将用于创建 Keyed 注册(除非最后调用的是 RemoveAsKeyed(),此时该名称将被排除)。

  • 当仅在 ConfigureHttpClientDefaults 中使用,最后一个设置生效。

  • 当同时使用了 ConfigureHttpClientDefaults 和特定客户端名称,则所有默认设置在所有按名称的设置之前“发生”。因此,默认设置可以被忽略,最后一个按名称的设置生效。

您可以在专门的概念文档中了解有关此功能的更多信息。

默认主处理程序更改

使用 HttpClientFactory 的用户最常遇到的问题之一是:当一个命名或类型化客户端被错误地捕获在一个单例服务中,或者一般情况下,被存储的时间超过了指定的 HandlerLifetime。由于 HttpClientFactory 无法轮换这些处理程序,它们可能最终无法正确响应 DNS 变化。不幸的是,将类型化客户端注入单例服务看起来很直观且“合理”,但很难通过任何检查或分析器确保 HttpClient 没有被错误地捕获,这也使得故障排查变得更加困难。

另一方面,可以通过使用 SocketsHttpHandler 来缓解此问题,它可以控制 PooledConnectionLifetime。与 HandlerLifetime 类似,该属性允许定期重新创建连接,以便捕获 DNS 变化,但作用层级更低。如果为客户端设置了 PooledConnectionLifetime,则可以安全地将其用作单例。

因此,为了最大程度地减少错误使用模式的影响,.NET 9 在支持的平台上默认使用 SocketsHttpHandler 作为主处理程序(其他平台,如 .NET Framework,仍然使用 HttpClientHandler)。更重要的是,SocketsHttpHandlerPooledConnectionLifetime 属性已预设为与 HandlerLifetime 匹配(如果您多次配置 HandlerLifetime,它会反映最新的值)。

此更改仅影响未通过 ConfigurePrimaryHttpMessageHandler() 等方法配置自定义主处理程序的情况。

虽然主处理程序的默认实现是一个内部细节,并未在官方文档中明确规定,但它仍然被视为一次重大变更。在某些情况下,开发者可能会依赖特定的处理程序类型,例如将主处理程序转换为 HttpClientHandler 以设置 ClientCertificatesUseCookiesUseProxy 等属性。如果需要使用这些属性,建议在配置操作中同时检查 HttpClientHandlerSocketsHttpHandler

services.AddHttpClient("test")
    .ConfigurePrimaryHttpMessageHandler((h, _) =>
    {
        if (h is HttpClientHandler hch)
        {
            hch.UseCookies = false;
        }
        if (h is SocketsHttpHandler shh)
        {
            shh.UseCookies = false;
        }
    });

或者,您可以为每个客户端显式指定一个主处理程序:

services.AddHttpClient("test")
    .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler() { UseCookies = false });

或者,使用 ConfigureHttpClientDefaults 为所有客户端配置默认的 主处理程序:

services.ConfigureHttpClientDefaults(b =>
    b.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler() { UseCookies = false }));

安全

System.Net.Security 中,我们引入了备受期待的 SSLKEYLOGFILE 支持、更多支持 TLS 会话恢复的场景,以及协商 (Negotiate) API 的新功能。

SSLKEYLOGFILE 支持

安全领域中被投票最多的问题是支持记录预主密钥(dotnet/runtime#37915)。记录的密钥可用于数据包捕获工具 Wireshark 来解密流量,在调查网络问题时是一个有用的诊断工具。此外,Firefox(通过 NSS)、Chrome 以及命令行 HTTP 工具(如 cURL)也提供了相同的功能。我们已在 SslStream 和 QuicConnection 中实现了该功能。对于 SslStream,该功能仅限于使用 OpenSSL 作为加密库的平台。从官方发布的 .NET 运行时的角度来看,这意味着它仅支持 Linux 操作系统。而对于 QuicConnection,则无此限制,并且在所有平台上都受支持。这是因为 TLS 是 QUIC 协议(RFC 9001)的一部分,因此用户态 MsQuic 可以访问所有密钥,.NET 也同样可以。SslStream 在 Windows 上的限制来自于 SChannel,它使用一个独立的特权进程来处理 TLS,出于安全考虑,该进程不会允许导出密钥(dotnet/runtime#94843)。

此功能会暴露安全密钥,仅依赖环境变量可能会导致意外泄露。因此,我们决定引入一个额外的 AppContext 开关来启用该功能(dotnet/runtime#100665)。用户需要证明对应用程序的所有权,可以通过以下方式在代码中以编程方式进行设置:

AppContext.SetSwitch("System.Net.EnableSslKeyLogging", true);

或者通过修改应用程序下的 {appname}.runtimeconfig.json 文件:

{
  "runtimeOptions": {
    "configProperties": {
      "System.Net.EnableSslKeyLogging": true
    }
  }
}

最后一步是设置环境变量 SSLKEYLOGFILE 并运行应用程序:

export SSLKEYLOGFILE=~/keylogfile
./<appname>

此时,~/keylogfile 将包含可以被 Wireshark 用来解密流量的预主密钥。有关更多信息,请参阅 TLS 使用(预)主密钥的文档。

使用客户端证书恢复 TLS

TLS 恢复使得能够重用之前存储的 TLS 数据,重新建立与先前连接的服务器的连接。它可以节省握手过程中的往返时间以及 CPU 处理。此功能是 Windows SChannel 的本地一部分,因此它在 Windows 平台上被 .NET 隐式使用。然而,在 Linux 平台上,我们使用 OpenSSL 作为加密库,启用 TLS 数据的缓存和重用则更为复杂。我们在 .NET 7 中首次引入了对该功能的支持(参见 TLS Resume)。它有一些限制,这些限制通常在 Windows 上是不存在的。一个限制是,它不支持使用客户端证书进行的双向身份验证会话(dotnet/runtime#94561)。这一问题已经在 .NET 9 中修复(dotnet/runtime#102656),并且在满足以下条件时可以正常工作:

  • ClientCertificateContext

  • LocalCertificateSelectionCallback 在第一次调用时返回非空证书

  • ClientCertificates 集合中至少包含一个带有私钥的证书

Negotiate API 完整性检查

在 .NET 7 中,我们添加了 NegotiateAuthentication API,参见 Negotiate API。原始实现的目标是通过反射移除对 NTAuthentication 内部的访问。然而,该提案缺少从 RFC 2743 生成和验证消息完整性代码的功能。这些通常作为使用协商密钥的加密签名操作来实现。该 API 的提案出现在 dotnet/runtime#86950 中,并在 dotnet/runtime#96712 中实现,与原始更改一样,API 提案到实现的所有工作都由社区贡献者 @filipnavara 完成。

网络基础组件

本节涵盖了 System.Net 命名空间中的更改。我们引入了对服务器端事件的新支持以及一些 API 的小改动,例如新的 MIME 类型。

服务器发送事件解析器

服务器发送事件是一种允许服务器通过 HTTP 连接将数据更新推送到客户端的技术。它在现行 HTML 标准中定义,使用 text/event-stream MIME 类型,并始终以 UTF-8 解码。与客户端拉取相比,服务器推送方法的优势在于它可以更好地利用网络资源,还能节省移动设备的电池寿命。

在本版本中,我们引入了一个 OOB 包 System.Net.ServerSentEvents。它作为 .NET Standard 2.0 的 NuGet 包提供。该包提供了一个服务器发送事件流的解析器,遵循该规范。该协议是基于流的,单个项之间通过空行分隔。

每个项有两个字段:

  • type – 默认类型是 message

  • data – 数据本身

除此之外,还有两个其他可选字段,用于逐步更新流的属性:

  • id – 确定在需要重新连接时,作为 Last-Event-Id 标头发送的最后事件 ID

  • retry – 重新连接尝试之间等待的毫秒数

该库的 API 提案出现在 dotnet/runtime#98105,并包含解析器和项的类型定义:

  • SseParser – 静态类,用于从流创建实际的解析器,允许用户可选地为项数据提供解析委托

  • SseParser – 解析器本身,提供同步或异步枚举流并返回解析后的项的方法

  • SseItem– 结构体,包含解析后的项数据

然后,解析器可以像这样使用,例如:

using HttpClient client = new HttpClient();
using Stream stream = await client.GetStreamAsync("https://server/sse");
var parser = SseParser.Create(stream, (type, data) =>
{
    var str = Encoding.UTF8.GetString(data);
    return Int32.Parse(str);
});
await foreach (var item in parser.EnumerateAsync())
{
    Console.WriteLine($"{item.EventType}: {item.Data} [{parser.LastEventId};{parser.ReconnectionInterval}]");
}

和以下输入:

: stream of integers

data: 123
id: 1
retry: 1000

data: 456
id: 2

data: 789
id: 3

输出:

message: 123 [1;00:00:01]
message: 456 [2;00:00:01]
message: 789 [3;00:00:01]

基础组件扩展

除了服务器发送事件,System.Net 命名空间还新增了一些小的功能:

  • dotnet/runtime#97940 中 Uri 的 IEquatable 接口实现
    允许在需要 IEquatable 的函数中使用 Uri,例如 Span.Contains 或 SequenceEquals。

  • 基于 span 的 (Try)EscapeDataString 和 (Try)UnescapeDataString 用于 Uri(dotnet/runtime#40603)
    目标是支持低分配场景,现在我们在 FormUrlEncodedContent 中利用了这些方法。

  • 新的 MIME 类型用于 MediaTypeNames(dotnet/runtime#95446)
    这些类型是在发布过程中收集的,并由社区贡献者 @CollinAlpert 在 dotnet/runtime#103575 中实现。

最终说明

和往年一样,我们尽力写下网络领域中有趣且有影响力的变化。本文不可能涵盖所有做出的更改。如果您感兴趣,可以在我们的 dotnet/runtime 仓库中找到完整的改动列表,您也可以在那儿向我们提出问题和报告 bug。此外,许多未在此提及的性能优化,可以在 Stephen 的精彩文章《.NET 9 中的性能优化》中找到。我们也很想听到您的声音,如果您遇到问题或有任何反馈,可以在我们的 GitHub 仓库中提交。

最后,我要感谢我的共同作者:

  • @antonfirsov 编写了诊断相关内容。

  • @CarnaViire 编写了 HttpClientFactory 和 https://devblogs.microsoft.com/dotnet/dotnet-9-networking-improvements/#websockets 相关内容。


http://www.niftyadmin.cn/n/5869609.html

相关文章

kiln微调大模型-使用deepseek R1去训练一个你的具备推理能力的chatGPT 4o

前言 随着deepseek的爆火&#xff0c;对于LLM的各种内容也逐渐步入我的视野&#xff0c;我个人认为&#xff0c;可能未来很长一段时间&#xff0c;AI将持续爆火&#xff0c;进入一段时间的井喷期&#xff0c;AI也会慢慢的走入我们每个家庭之中&#xff0c;为我们的生活提供便利…

【人工智能】数据挖掘与应用题库(101-200)

1、有矩阵A32 ,B23 ,C33 ,下列运算有意义的是( ) 答案:BC 2、13524 的逆序数为( ) 答案:3 3、矩阵A中元素a14的余子式记作M14,代数余子式记作A14,二者关系为( ) 答案:相反 4、关于机器学习与深度学习的范畴关系,下列说法正确的是? 答案:深度学…

【Rust中级教程】2.13. 结语(杂谈):我学习Rust的心路历程

2.13.1. 【Rust自学】专栏的缘起 笔者我在去年12月份之前对Rust还一无所知&#xff0c;后来看到JetBrains推出了Rust Rover&#xff0c;想着自己毕竟是买的全产品证书就下载下来玩了一下。原本就是看看&#xff0c;都打算卸载了&#xff0c;后来去网上查才发现Rust这门语言挺牛…

阿里重磅模型深夜开源;DeepSeek宣布开源DeepGEMM;微软开源多模态AI Agent基础模型Magma...|网易数智日报

阿里重磅模型深夜开源&#xff1a;表现超越Sora、Pika&#xff0c;消费级显卡就能跑 2月26日&#xff0c;25日深夜阿里云视频生成大模型万相2.1&#xff08;Wan&#xff09;正式宣布开源&#xff0c;此次开源采用Apache2.0协议&#xff0c;14B和1.3B两个参数规格的全部推理代码…

设计模式Python版 备忘录模式

文章目录 前言一、备忘录模式二、备忘录模式示例1三、备忘录模式示例2 前言 GOF设计模式分三大类&#xff1a; 创建型模式&#xff1a;关注对象的创建过程&#xff0c;包括单例模式、简单工厂模式、工厂方法模式、抽象工厂模式、原型模式和建造者模式。结构型模式&#xff1a…

神经网络 - 函数饱和性、软性门、泰勒级数

在接下来对于神经网络的学习中&#xff0c;我们会涉及到函数饱和性、软性门的概念&#xff0c;还需要用到泰勒级数&#xff0c;本文我们来理解这些基础知识&#xff0c;为后续学习神经网络的激活函数做准备。 一、函数饱和性 “函数具有饱和性”通常指的是当函数的输入达到某…

算法题(79):两个数组的交集

审题&#xff1a; 本题需要我们查找两个给定数组的无重复数据交集&#xff0c;并以数组的形式返回 思路&#xff1a; 方法一&#xff1a;set 之前我们学习过unordered_set的使用&#xff0c;但是unordered_set是无序的&#xff0c;而这里我们的比对算法需要有序数据&#xff0c…

MATLAB基础应用精讲-【数模应用】牛顿迭代法(附MATLAB、C++、R语言和python代码实现)

目录 前言 算法原理 什么是牛顿迭代法? 牛顿迭代如何迭代? 啥时候停止迭代呢? 特点 牛顿迭代法的扩展 迭代过程 数学模型 电力系统中牛顿拉夫逊法(N-R)潮流计算的直角坐标形式详细推导 潮流计算的牛顿-拉夫逊方法 牛顿-拉夫逊法的原理 牛顿-拉夫逊法的意义和…