继续我们的传统,我们很高兴分享一篇博客文章,重点介绍新 .NET 发布版本中网络领域的最新和最有趣的变更。今年,我们在 HTTP 领域引入了更新,新增了 HttpClientFactory API,改进了 .NET Framework 的兼容性等。
在以下部分中,我们将介绍 HTTP 领域最有影响力的变更。其中包括连接池性能的提升、对多个 HTTP/3 连接的支持、Windows 代理自动更新,以及社区贡献。
在本次发布中,我们在 HTTP 连接池方面进行了两项显著的性能改进。
我们增加了对多个 HTTP/3 连接的可选支持。使用多个 HTTP/3 连接到对端是 RFC 9114 所不推荐的,因为该连接可以复用并行请求。然而,在某些场景下(例如服务器到服务器通信),即使有请求复用,单一连接仍可能成为瓶颈。我们在 HTTP/2 中看到了类似的限制(dotnet/runtime#35088),它具有相同的概念,即在一个连接上复用。基于同样的原因(dotnet/runtime#51775),我们决定为 HTTP/3 实现多连接支持(dotnet/runtime#101535)。
该实现本身试图尽可能贴近 HTTP/2 多连接的行为。目前,总是优先在现有连接上处理尽可能多的请求,直到达到对端允许的上限后才会打开新连接。请注意,这是一个实现细节,未来行为可能会发生变化。
因此,我们的基准测试显示每秒请求数(RPS)有了显著增加,10,000 并发请求的对比结果如下:
客户端 | 单一 HTTP/3 连接 | 多个 HTTP/3 连接 |
---|---|---|
最大 CPU 使用率 (%) | 35 | 92 |
最大核心使用率 (%) | 971 | 2,572 |
最大工作集 (MB) | 3,810 | 6,491 |
最大私有内存 (MB) | 4,415 | 7,228 |
处理器数量 | 28 | 28 |
第一个请求持续时间 (ms) | 519 | 594 |
请求总数 | 345,446 | 4,325,325 |
平均 RPS | 23,069 | 288,664 |
请注意,最大 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)。我们选择了 ConcurrentStack
,因为它保留了由最新可用连接处理请求时的可观察行为,从而允许在配置的生命周期到期时收集旧连接。我们的基准测试显示 HTTP 1.1 请求的吞吐量增加了 30% 以上:
客户端 | .NET 8.0 | .NET 9.0 | 增加幅度 |
---|---|---|---|
请求总数 | 80,028,791 | 107,128,778 | +33.86% |
平均 RPS | 666,886 | 892,749 | +33.87% |
调试使用早期版本 .NET 的应用程序的 HTTP 流量时,主要痛点之一是应用程序无法响应 Windows 代理设置的变化(dotnet/runtime#70098)。代理设置以前在每个进程初始化一次,并且没有合理的方法刷新设置。例如(使用 .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(_000);
}
产生类似以下输出:
null
// 启用 Fiddler 的“系统代理”后。
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)解决了问题。分析器试图确保用户知道提供的值被解释为千字节,并在使用方式暗示否则时发出警告。
如果值高于某个阈值,看起来像这样:MaxResponseHeadersLength 分析器警告
分析器由 @amiru3f 实现。
.NET 9 中 QUIC 领域的显著变更包括使库公开化、更多的连接配置选项和多项性能改进。
从此次发布开始,System.Net.Quic
不再隐藏在 PreviewFeature 后面,所有 API 均无需任何选择开关即可使用(dotnet/runtime#104227)。
我们扩展了 QuicConnection
的配置选项(dotnet/runtime#72984)。实现(dotnet/runtime#94211)为 QuicConnectionOptions
添加了三个新属性:
所有这些参数都是可选的。它们的默认值来自 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 % == ? $"{sizeB / } * 1024" : sizeB.ToString();
// 输出:
KeepAliveInterval = infinite
HandshakeTimeout = ::
InitialReceiveWindowSizes =
{
Connection = * ,
LocallyInitiatedBidirectionalStream = * ,
RemotelyInitiatedBidirectionalStream = * ,
UnidirectionalStream = *
}
.NET 9 还引入了新的 API,以支持 SocketsHttpHandler
中的多个 HTTP/3 连接(dotnet/runtime#101534)。这些 API 是专门为这种特定用途设计的,我们预计它们不会在非常小众的场景之外使用。
QUIC 在协议内部内置了管理流限制的逻辑。因此,如果调用 OpenOutboundStreamAsync
时没有可用的流容量,它会被挂起。此外,没有有效的方法了解是否达到了流限制。所有这些限制结合在一起,HTTP/3 层无法知道何时打开新连接。因此,我们引入了一个新的 StreamCapacityCallback
,每当流容量增加时都会调用它。回调本身通过 QuicConnectionOptions
注册。有关回调的更多详细信息,请参阅文档。
System.Net.Quic
中的两项性能改进都与 TLS 相关,并且只影响连接建立时间。
第一项性能相关更改是异步运行对等证书验证(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");
// 提前准备连接选项并重复使用它们。
var serverConnectionOptions = new QuicServerConnectionOptions()
{
DefaultStreamErrorCode = ,
DefaultCloseErrorCode = ,
ServerAuthenticationOptions = new SslServerAuthenticationOptions
{
ApplicationProtocols = new List() { alpn },
// 重用相同的证书。
ServerCertificate = serverCertificate
}
};
// 配置侦听器以返回预先准备的选项。
awaitusingvar listener = await QuicListener.ListenAsync(new QuicListenerOptions()
{
ListenEndPoint = new IPEndPoint(IPAddress.Loopback, ),
ApplicationProtocols = [ alpn ],
// 回调返回相同对象。
// 内部缓存将为每个传入连接重用相同的本机结构。
ConnectionOptionsCallback = (_, _, _) => ValueTask.FromResult(serverConnectionOptions)
});
我们还为此功能构建了一个退出机制,可以通过以下环境变量禁用:
export DOTNET_SYSTEM_NET_QUIC_DISABLE_CONFIGURATION_CACHE=1
# 运行应用程序
或通过 AppContext 开关:
AppContext.SetSwitch("System.Net.Quic.DisableConfigurationCache", true);
.NET 9 引入了长期以来备受期待的 PING/PONG 保持活动策略到 WebSockets(dotnet/runtime#48729)。
在 .NET 9 之前,唯一可用的保持活动策略是未经请求的 PONG。它足以防止底层 TCP 连接空闲超时,但如果远程主机变得无响应(例如,远程服务器崩溃),检测这种情况的唯一方法是依赖 TCP 超时。
在此版本中,我们用新的 KeepAliveTimeout
设置补充了现有的 KeepAliveInterval
设置,因此保持活动策略的选择如下:
KeepAliveInterval
为 TimeSpan.Zero
或 Timeout.InfiniteTimeSpan
KeepAliveInterval
为正有限 TimeSpan
,并且KeepAliveTimeout
为 TimeSpan.Zero
或 Timeout.InfiniteTimeSpan
KeepAliveInterval
为正有限 TimeSpan
,并且KeepAliveTimeout
为正有限 TimeSpan
默认情况下,维持先前存在的保持活动行为:KeepAliveTimeout
的默认值为 Timeout.InfiniteTimeSpan
,因此未经请求的 PONG 仍然是默认策略。
以下示例说明了如何为 ClientWebSocket
启用 PING/PONG 策略:
var cws = new ClientWebSocket();
cws.Options.KeepAliveInterval = TimeSpan.FromSeconds();
cws.Options.KeepAliveTimeout = TimeSpan.FromSeconds();
await cws.ConnectAsync(uri, cts.Token);
// 注意:始终应有一个未完成的读取操作,以确保及时处理传入的 PONG。
var result = await cws.ReceiveAsync(buffer, cts.Token);
如果在 KeepAliveTimeout
之后未收到 PONG 响应,则远程端点被视为无响应,WebSocket 连接将自动中止。它还会通过 OperationCanceledException
解除挂起的 ReceiveAsync
。
要了解更多关于此功能的信息,您可以查看专用的概念文档。
在网络领域,从 .NET Framework 迁移项目到 .NET Core 时最大的障碍之一是 HTTP 栈之间的差异。在 .NET Framework 中,处理 HTTP 请求的主要类是 HttpWebRequest
,它使用全局 ServicePointManager
和单独的 ServicePoints
来处理连接池。而在 .NET Core 中,HttpClient
是访问 HTTP 资源的推荐方式。此外,.NET Framework 中的所有类都存在于 .NET 中,但它们要么已过时,要么缺少实现,要么根本不再维护。结果,我们经常看到错误的用法,例如使用 ServicePointManager
配置连接,同时使用 HttpClient
访问资源。
建议始终是完全迁移到 HttpClient
,但有时这是不可能的。将项目从 .NET Framework 迁移到 .NET Core 本身就很困难,更不用说重写所有网络代码了。期望客户一步完成所有这些工作被证明是不现实的,这也是客户可能不愿意迁移的原因之一。为了减轻这些痛点,我们填补了一些遗留类的缺失实现,并创建了一份全面的指南以帮助迁移。
第一部分是扩展了 ServicePointManager
和 ServicePoint
支持的属性,这些属性在 .NET Core 中直到此次发布之前都没有实现(dotnet/runtime#94664 和 dotnet/runtime#97537)。通过这些更改,当使用 HttpWebRequest
时,现在会考虑它们。
对于 HttpWebRequest
,我们在 dotnet/runtime#95001 中实现了对 AllowWriteStreamBuffering
的完全支持。还在 dotnet/runtime#102038 中添加了对 ImpersonationLevel
的缺失支持。
除了这些更改,我们还废弃了一些遗留类以防止进一步混淆:
ServicePointManager
在 dotnet/runtime#103456 中被废弃。它的设置对 HttpClient
和 SslStream
没有效果,但它可能被善意地误用。AuthenticationManager
在 dotnet/runtime#93171 中被废弃,由社区贡献者 @deeprobin 完成。它要么缺少实现,要么方法抛出 PlatformNotSupportedException
。最后,我们整理了一份从 HttpWebRequest
迁移到 HttpClient
的指南。它包括各个属性和方法之间的映射综合列表,例如迁移 ServicePoint(Manager)
使用,以及许多简单和不太简单的场景示例,例如示例:启用 DNS 轮询。
在此版本中,诊断改进的重点是增强隐私保护和推进分布式跟踪能力。
从 Microsoft.Extensions.Http 版本 9.0.0 开始,HttpClientFactory 的默认日志逻辑优先保护隐私。在旧版本中,它在 RequestStart
和 RequestPipelineStart
事件中发出完整的请求 URI。如果 URI 的某些组件包含敏感信息,这可能导致隐私事故,泄露此类数据到日志中。
版本 8.0.0 引入了通过自定义日志记录来保护 HttpClientFactory 使用的能力。然而,这并没有改变默认行为可能对不知情用户存在风险的事实。
在大多数有问题的情况下,敏感信息位于查询组件中。因此,在 9.0.0 中引入了一项重大更改,默认情况下从 HttpClientFactory 日志中删除整个查询字符串。提供了一个全局退出开关,用于那些安全记录完整 URI 的服务/应用程序。
为了保持一致性和最大程度的安全性,对 System.Net.Http 中的 EventSource 事件也实施了类似的更改。
我们认识到此解决方案可能并不适合所有人。理想情况下,应该有一个细粒度的 URI 过滤机制,允许用户保留非敏感查询条目或过滤其他 URI 组件(例如路径的一部分)。我们计划在未来版本中探索这样的功能(dotnet/runtime#110018)。
分布式跟踪是一种诊断技术,用于跟踪特定事务在多个进程和机器之间的路径,帮助识别瓶颈和故障。该技术将事务建模为活动的分层树,也称为 OpenTelemetry 术语中的跨度。
HttpClientHandler
和 SocketsHttpHandler
已经经过检测,以便在每次请求时启动一个活动,并在启用跟踪时通过标准 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 客户端请求跨度的子跨度。相反,请求和连接之间的关系通过 Span Links 表示,也称为 Activity Links。
注意:新跨度由匹配通配符 Experimental.System.Net.* 的各种 ActivitySources 生成。这些跨度是实验性的,因为像 Azure Monitor Application Insights 这样的监控工具难以有效可视化生成的跟踪,原因是众多 connection_setup → request 反向链接。为了改善监控工具中的用户体验,还需要进一步的工作。它涉及 .NET 团队、OTel 和工具作者之间的协作,可能会导致新跨度设计中的重大更改。
设置和尝试连接跟踪收集的最简单方法是使用 .NET Aspire。使用 Aspire 仪表板,可以展开 connection_setup 活动并查看连接初始化的细分。
.NET Aspire 仪表板上的 connection_setup 活动细分
如果您认为 .NET 9 跟踪添加可能会为您带来有价值的诊断见解,并且想要获得一些动手经验,请不要犹豫,阅读我们关于 System.Net 库中分布式跟踪的完整文章。
对于 HttpClientFactory,我们引入了 Keyed DI 支持,提供了新的便捷消费模式,并更改了默认主处理器以减轻常见的错误用例。
在之前的发布中,Keyed Services 被引入到 Microsoft.Extensions.DependencyInjection 包中。Keyed DI 允许您在注册单个服务类型的多个实现时指定键,并在稍后使用相应键检索特定实现。
HttpClientFactory 和命名的 HttpClient 实例不出所料地与 Keyed Services 概念很好地对齐。除此之外,HttpClientFactory 曾经是克服这一长期缺失的 DI 功能的一种方式。但它要求您获取、存储和查询 IHttpClientFactory
实例——而不是简单地注入配置好的 HttpClient
——这可能不方便。虽然 Typed clients 尝试简化这部分,但它有一个陷阱: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 服务,并允许您使用 Keyed Services API(例如 [FromKeyedServices(...)]
)获取所需的 HttpClients。
以下代码演示了 HttpClientFactory、Keyed DI 和 ASP.NET Core 9.0 Minimal APIs 的集成:
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(); // 将 HttpClient 注册为 Keyed Scoped 服务,key="github"
var app = builder.Build();
// 直接通过其名称注入 Keyed HttpClient
app.MapGet("/", ([FromKeyedServices("github")] HttpClient httpClient) =>
httpClient.GetFromJsonAsync("/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();
// 抛出:无法从根提供程序解析 Scoped 服务 'System.Net.Http.HttpClient'。
rootProvider.GetRequiredKeyedService("scoped");
using var scope = provider.CreateScope();
scope.ServiceProvider.GetRequiredKeyedService("scoped"); // OK
// 抛出:无法从 Singleton 'CapturingSingleton' 消费 Scoped 服务 'System.Net.Http.HttpClient'。
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()
显式选择退出 HttpClient 的 Keyed DI(例如,在“全局”选择加入的情况下按特定客户端):
services.ConfigureHttpClientDefaults(b => b.AddAsKeyed()); // 默认选择加入
services.AddHttpClient("keyed", / /);
services.AddHttpClient("not-keyed", / /).RemoveAsKeyed(); // 按名称选择退出
provider.GetRequiredKeyedService("keyed"); // OK
provider.GetRequiredKeyedService("not-keyed"); // 抛出:未注册类型 'System.Net.Http.HttpClient' 的服务。
provider.GetRequiredKeyedService("unknown"); // OK(未配置实例)
如果一起使用,或者多次使用其中之一,AddAsKeyed()
和 RemoveAsKeyed()
通常遵循 HttpClientFactory 配置和 DI 注册的规则:
AddAsKeyed()
的生命周期创建 Keyed 注册(除非最后调用了 RemoveAsKeyed()
,在这种情况下排除该名称)。ConfigureHttpClientDefaults
内使用,最后一个设置生效。ConfigureHttpClientDefaults
和特定客户端名称,则所有默认值被认为“发生在”该客户端的所有按名称设置之前。因此,默认值可以忽略,最后一个按名称设置生效。您可以了解更多关于该功能的信息在专用的概念文档中。
HttpClientFactory 用户遇到的最常见问题之一是,当命名或类型化客户端错误地被捕获到 Singleton 服务中,或者总体上存储的时间比指定的 HandlerLifetime
更长时。因为 HttpClientFactory 无法轮换此类处理器,它们最终可能不尊重 DNS 更改。不幸的是,将类型化客户端注入到 Singleton 中既容易又看似“直观”,但很难有任何检查/分析器来确保 HttpClient 不被捕获(当它不应该被捕获时)。排查由此产生的问题可能更加困难。
另一方面,通过使用 SocketsHttpHandler
,可以缓解此问题,它可以控制 PooledConnectionLifetime
。类似于 HandlerLifetime
,它允许定期重新创建连接以拾取 DNS 更改,但在较低级别。设置了 PooledConnectionLifetime
的客户端可以安全地用作 Singleton。
因此,为了尽量减少错误使用模式的潜在影响,.NET 9 将默认主处理器更改为 SocketsHttpHandler
(在支持它的平台上;其他平台,例如 .NET Framework,继续使用 HttpClientHandler
)。最重要的是,SocketsHttpHandler
还预设了 PooledConnectionLifetime
属性以匹配 HandlerLifetime
值(它反映了最新值,如果您配置了 HandlerLifetime
一次或多次)。
此更改仅影响未配置为具有自定义主处理器(例如通过 ConfigurePrimaryHttpMessageHandler()
)的客户端。
虽然默认主处理器是一个实现细节,因为它从未在文档中指定,但它仍然被视为一项重大更改。可能存在您想使用特定类型的情况,例如,将主处理器强制转换为 HttpClientHandler
以设置 ClientCertificates
、UseCookies
、UseProxy
等属性。如果您需要使用此类属性,建议在配置操作中检查 HttpClientHandler
和 SocketsHttpHandler
:
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 恢复的场景,以及协商 API 中的新添加。
安全领域中投票最多的议题是支持预主密钥的日志记录(dotnet/runtime#37915)。记录的密钥可以由数据包捕获工具 Wireshark 用于解密流量。这是一个有用的诊断工具,用于调查网络问题。此外,浏览器如 Firefox(通过 NSS)、Chrome 和命令行 HTTP 工具如 cURL 也提供了相同的功能。我们为 SslStream
和 QuicConnection
实现了此功能。对于前者,功能限于我们使用 OpenSSL 作为加密库的平台。就官方发布的 .NET 运行时而言,这意味着仅限于 Linux 操作系统。对于后者,它在所有平台上都受支持,无论加密库如何。这是因为 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
./
此时,~/keylogfile
将包含可用于 Wireshark 解密流量的预主密钥。更多信息,请参阅 TLS 使用 (Pre)-Master-Secret 文档。
TLS 恢复允许重用先前存储的 TLS 数据以重新建立与先前连接的服务器的连接。它可以节省握手期间的往返次数以及 CPU 处理。此功能是 Windows SChannel 的原生部分,因此 .NET 在 Windows 平台上隐式使用它。然而,在我们使用 OpenSSL 作为加密库的 Linux 平台上,启用和重用 TLS 数据更为复杂。我们在 .NET 7 中首次引入了支持(参见 TLS 恢复)。它有自己的限制,通常在 Windows 上不存在。其中一个限制是它不支持通过提供客户端证书使用相互身份验证的会话(dotnet/runtime#94561)。此问题已在 .NET 9 中修复(dotnet/runtime#102656),并在设置以下属性之一时生效:
ClientCertificateContext
LocalCertificateSelectionCallback
在第一次调用时返回非空证书ClientCertificates
集合至少有一个带有私钥的证书在 .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
标头中发送的最后一个事件 IDretry
– 重新连接尝试之间等待的毫秒数库 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
命名空间还获得了其他一些小的添加:
Uri
在 dotnet/runtime#97940 中实现了 IEquatable
接口IEquatable
的函数中使用 Uri
,例如 Span.Contains
或 SequenceEquals
。Uri
的 span-based (Try)EscapeDataString
和 (Try)UnescapeDataString
在 dotnet/runtime#40603 中添加FormUrlEncodedContent
中利用这些方法。MediaTypeNames
的新 MIME 类型在 dotnet/runtime#95446 中添加