必看!.NET 7 在网络领域的四大更新
http://www.pczh.cn/news/26036.html
最新的 .NET 7 现已发布,我们想介绍一下其在网络领域所做的一些有趣的更改和添加。这篇文章我们将讨论 .NET 7 在 HTTP 空间、新 QUIC API、网络安全和 WebSockets 方面的变化。
HTTP
改进了对连接尝试失败的处理
在 .NET 6 之前的版本中,如果连接池中没有立即可用的连接,(处理程序上的设置允许的情况下,例如 HTTP /1.1 中的 MaxConnectionsPerServer,或 HTTP/2 中的EnableMultipleHttp2Connections)新的 HTTP 请求始终会发起新的连接尝试并等待响应。这样做的缺点是,建立该连接需要一段时间,而在这段时间里如果另一个连接已经可用,该请求仍将继续等待它生成的连接,从而影响延迟。在 .NET 6.0 中我们改变了这一进程,无论是新建立的连接还是与此同时准备好处理请求的另一个连接,第一个可用的连接会处理请求。这样仍会一个新连接被建立(受限制),如果发起的请求未使用这一连接,则它会被合并以供后续请求使用。
不幸的是,.NET 6.0 中的这一功能对某些用户来说是有问题的:失败的连接尝试也会使位于请求队列顶部的请求失败,这可能会在某些情况下导致意外的请求失败。此外,如果由于某些原因(例如由于服务器行为不当或网络问题)池中有一个永远未被使用的连接,与之关联的新传入的请求也将延迟并可能超时。
在 .NET 7.0 中,我们实施了以下更改来解决这些问题:
- 失败的连接尝试只能使其相关的发起请求失败,而不会导致无关的请求失败。如果在连接失败时原始请求已得到处理,则连接失败将被忽略 ( dotnet/runtime#62935 )。
- 如果一个请求发起了一个新连接,但随后被池中的另一个连接处理,则新的待使用的连接尝试将不管 ConnectTimeout,在短时间后自动超时。通过此更改,延迟的连接将不会延迟不相关的请求 ( dotnet/runtime#71785 )。请注意,不被使用的连接尝试自动超时失败的这一进程只会在后台自己运行,用户不会看到此进程。观察它们的唯一方法是启用 telemetry。
HttpHeaders 读取线程安全
这些 HttpHeaders 集合从来都不是线程安全的。访问 header 可能会强制延迟解析它的值,从而导致对底层数据结构的修改。
在 .NET 6 之前,同时读取集合在大多数情况下恰好是线程安全的。
从 .NET 6 开始,由于内部不再需要锁定,针对 header 解析执行的锁定较少。这一变化导致许多用户错误地同时访问 header,例如,在 gRPC (dotnet/runtime#55898)、NewRelic (newrelic/newrelic-dotnet-agent#803)甚至 HttpClient 本身( dotnet/runtime #65379)。违反 .NET 6 中的线程安全可能会导致 header 值重复/格式错误或在枚举(enumeration)/header 访问期间产生各种异常。
.NET 7 使 header 行为更加直观。该 HttpHeaders 集合现在符合 Dictionary 线程安全保证:
集合可以同时支持多个读者,只要它不被修改。极少数情况下,枚举(enumeration)与书写访问权限争用,则该集合必须在整个枚举期间被锁定。要允许多个线程访问集合以同时进行读写,您必须实现自己的同步。
这是通过以下更改实现的:
- 无效值的“验证读取”不会删除无效值 – dotnet/runtime#67833(感谢@heathbm)。
- 同时读取是线程安全的——dotnet/runtime#68115。
检测 HTTP/2 和 HTTP/3 协议错误
HTTP/2 和 HTTP/3 协议在 RFC 7540 第 7 节和 RFC 9114 第 8.1节中定义了协议级别的错误代码,例如,HTTP/2 中的 REFUSED_STREAM (0x7) 或 HTTP/3 中的 H3_EXCESSIVE_LOAD (0x0107) 。与 HTTP 状态代码不同,这是对大多数 HttpClient 用户来说不重要的低级错误信息,但它在高级 HTTP/2 或 HTTP/3 场景中有帮助,特别是 grpc-dotnet,其中区分协议错误对于实现客户端重试至关重要。
我们定义了一个新的异常 HttpProtocolException 来在其 ErrorCode 属性中保存协议级错误代码。
HttpClient 直接调用时,HttpProtocolException 可以是内部异常HttpRequestException:
try
{
using var response = await httpClient.GetStringAsync(url);
}
catch (HttpRequestException ex) when (ex.InnerException is HttpProtocolException pex)
{Console.WriteLine("HTTP error code: " + pex.ErrorCode)
}
使用 HttpContent 的响应流时,它被直接抛出:
using var response = await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
using var responseStream = await response.Content.ReadAsStreamAsync();
try
{
await responseStream.ReadAsync(buffer);
}
catch (HttpProtocolException pex)
{Console.WriteLine("HTTP error code: " + pex.ErrorCode)
}
HTTP/3
在 HttpClient 之前的 .NET 版本已经完成了对 HTTP/3 的支持,所以我们主要集中精力在这个领域的 System.Net.Quic 底层。尽管如此,我们确实在 .NET 7 中引入了一些修复和更改。
最重要的变化是现在默认启用 HTTP/3 ( dotnet/runtime#73153 )。这并不意味着从现在开始所有 HTTP 请求都将首选 HTTP/3,但在某些情况下它们可能会升级到HTTP/3。为此,请求必须通过将HttpRequestMessage.VersionPolicy设置为RequestVersionOrHigher,从而能够版本升级。然后,如果服务器在 Alt-Svc header 中有 HTTP/3 授权,HttpClient 将使用它进行进一步的请求,请参阅 RFC 9114 第 3.1.1 节。
QUIC
QUIC 是一种新的传输层协议。它最近已在 RFC 9000 中标准化。它使用 UDP 作为底层协议,并且它本质上是安全的,因为它要求使用 TLS 1.3。与众所周知的传输协议(如 TCP 和 UDP)的另一个有趣区别是它在传输层上内置了流多路复用。这使其能够拥有多个并发的独立数据流,且这些数据流不会相互影响。
QUIC 本身没有为交换的数据定义任何语义,因为它是一种传输协议。它更适用于应用层协议,例如 HTTP/3 或 SMB over QUIC。它还可以用于任何自定义协议。
与 TLS 的 TCP 相比,该协议具有许多优势。例如,它不需要像顶部带有 TLS 的 TCP 那样多的往返行程,所以能够更快地建立连接。它能够避免队头阻塞问题,一个丢失的数据包不会阻塞所有其他流的数据。另一方面,使用 QUIC 也有缺点。由于它是一个新协议,它的采用仍在增长并且是有限的。除此之外,QUIC 流量甚至可能被某些网络组件阻止。
.NET 中的 QUIC
我们在 System.Net.Quic 库中介绍了 .NET 5 中的 QUIC 实现。然而,到目前为止,这个库是仅限内部的,并且只为自己的 HTTP/3 实现服务。随着 .NET 7 的发布,我们公开了该库并公开了它的 API。由于我们只有 HttpClient 和 Kestrel 作为此版本 API 的使用者,因此我们决定将它们保留为预览功能。它使我们能够在确定最终形式之前在下一个版本中调整 API。
从实施的角度来看,System.Net.Quic 取决于 QUIC 协议的原生实现 MsQuic。因此,System.Net.Quic 平台支持和依赖项继承自 MsQuic,并记录在 HTTP/3 平台依赖项文档中。简而言之,MsQuic 库作为 .NET for Windows 的一部分提供。对于 Linux,libmsquic 必须通过适当的包管理器手动安装。对于其他平台,仍然可以手动构建 MsQuic,无论是针对 SChannel 还是 OpenSSL,并将其与 System.Net.Quic 一起使用。
API 概述
System.Net.Quic 携带了能够使用 QUIC 协议的三个主要类:
- QuicListener – 服务器端类,用于接受传入连接。
- QuicConnection – QUIC 连接,对应 RFC 9000 Section 5。
- QuicStream – QUIC 流,对应 RFC 9000 Section 2。
但是在使用这些类之前,用户代码应该检查当前系统是否支持 QUIC,因为系统可能缺失 libmsquic 或者不支持 TLS 1.3。为此, QuicListener 和 QuicConnection 都公开了一个静态属性 IsSupported:
if (QuicListener.IsSupported)
{QuicListenerOptions
// Use QuicListener
}
else
{
// Fallback/Error
}if (QuicConnection.IsSupported)
{
// Use QuicConnection
}
else
{
// Fallback/Error
}
请注意,目前这两个属性是同步的并将显示相同的值,但将来可能会改变。所以我们建议检查一下支持服务器场景的 QuicListener.IsSupported 和用于客户端的 QuicListener.IsSupported。
QuicListener
QuicListener 属于接受客户端的传入连接的服务器端类。该侦听设备是通过静态方法 QuicListener.ListenAsync 构造和启动的。该方法接受 QuicListenerOptions 类的一个实例,其中包含启动侦听设备和接受传入连接所需的所有设置。之后,侦听设备着手通过 AcceptConnectionAsync 分发连接。此方法返回的连接始终是完全连接的,这意味着 TLS 交互已经完成,连接可以使用了。最后,要关闭侦听设备并释放所有资源,必须调用 DisposeAsync 方法。
更多关于这个类设计的细节可以在QuicListener API Proposal (dotnet/runtime#67560) 中找到。
QuicConnection
是用于服务器端和客户端 QUIC 连接的类。服务器端连接由侦听设备内部创建,并通过 QuicListener.AcceptConnectionAsync 分发连接。客户端连接必须被打开并连接到服务器。静态方法 QuicConnection 和侦听设备一起,建立并实例连接。它接受 QuicClientConnectionOptions 的实例,这是一个类似于 QuicServerConnectionOptions 的类。初次之前,此链接的工作方式与客户端和服务器之间没有区别。它可以打开向外和向内的流。它还提供与连接信息有关的属性,如 LocalEndPoint、RemoteEndPoint 或 RemoteCertificate。
当连接的工作完成后,需要关闭和处置侦听设备。QUIC 协议要求使用应用层代码立即关闭侦听设备,参见 RFC 9000 Section 10.2。为此,可以调用带有应用层代码的 CloseAsync ,如果没有,DisposeAsync 将使用 QuicConnectionOptions.DefaultCloseErrorCode 中提供的代码。无论是哪种方式,都必须在连接工作结束时调用 DisposeAsync,涌起完全释放所有相关资源。
更多关于这个类设计的细节可以在QuicConnection API Proposal (dotnet/runtime#68902) 中找到。
QuicStream
QuicStream 是 QUIC 协议中用于发送和接收数据的实际类型。它起源于普通的流 Stream。可以和普通的流一样使用,但它也提供了一些特定于 QUIC 协议的特性。首先,QUIC 流可以是单向的,也可以是双向的,参见 RFC 9000 Section 2.1。双向流能够在两端发送和接收数据,而单向流只能从发起端输入数据,从接受端读取。每端都可以限制每种类型的并发流的数量,参见QuicConnectionOptions.MaxInboundBidirectionalStreams 与 QuicConnectionOptions.MaxInboundUnidirectionalStreams。