点击上方蓝字
关注我们
(本文阅读时间:20分钟)
最新的 .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 节。
RFC 9114 第 3.1.1 节
https://www.rfc-editor.org/rfc/rfc9114#section-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 一起使用。
HTTP/3 平台依赖项文档
https://learn.microsoft.com/dotnet/core/extensions/httpclient-http3#platform-dependencies?ocid=AID3052907
▌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) 中找到。
RFC 9000 Section 10.2
https://www.rfc-editor.org/rfc/rfc9000#section-10.2
dotnet/runtime#68902
https://github.com/dotnet/runtime/issues/68902
▌QuicStream
QuicStream 是 QUIC 协议中用于发送和接收数据的实际类型。它起源于普通的流 Stream。可以和普通的流一样使用,但它也提供了一些特定于 QUIC 协议的特性。首先,QUIC 流可以是单向的,也可以是双向的,参见 RFC 9000 Section 2.1。双向流能够在两端发送和接收数据,而单向流只能从发起端输入数据,从接受端读取。每端都可以限制每种类型的并发流的数量,参见QuicConnectionOptions.MaxInboundBidirectionalStreams 与 QuicConnectionOptions.MaxInboundUnidirectionalStreams。
QUIC 流的另一个特点是能够在流的工作过程中显式地关闭写入端,参见CompleteWrites or WriteAsync-system-boolean-system-threading-cancellationtoken)) 重载 CompleteWrites 参数。关闭写入端可以让接受端明确不会再有更多的数据输入了,但另一端仍然可以继续发送数据流(在双向流的情况下)。这在 HTTP 请求/响应交换等场景中非常有用,客户端发送请求并关闭写入端,服务器就知道这是请求发送的所有内容了。在此之后,服务器仍然能够发送响应,但客户端却不会发送更多的数据流了。而对于错误的情况,流的写入或读取端都可以进行中止,请参阅 Abort。下表总结了每种流类型的每种方法的表现(注意客户端和服务器都可以开放和接受流):
Peer opening stream | Peer accepting stream | |
CanRead | bidirectional: true unidirectional: false | true |
CanWrite | true | bidirectional: true unidirectional: false |
ReadAsync | bidirectional: reads data unidirectional: InvalidOperationException | reads data |
WriteAsync | sends data => peer read returns the data | bidirectional: sends data => peer read returns the data unidirectional: InvalidOperationException |
CompleteWrites | closes writing side => peer read returns 0 | bidirectional: closes writing side => peer read returns 0 unidirectional: no-op |
Abort(QuicAbortDirection.Read) | bidirectional: STOP_SENDING => peer write throws QuicException(QuicError.OperationAborted) unidirectional: no-op | STOP_SENDING => peer write throws QuicException(QuicError.OperationAborted) |
Abort(QuicAbortDirection.Write) | RESET_STREAM => peer read throws QuicException(QuicError.OperationAborted) | bidirectional: RESET_STREAM => peer read throws QuicException(QuicError.OperationAborted) unidirectional: no-op |
在以上这些方法中,quickstream 提供了两个专门的属性,用于在流的读写端关闭时获得通知:它们是 readclosed 和 WritesClosed。两者都返回一个任务,该任务完成后相应的边被关闭。无论任务是成功还是中止,其中都将包含相应的字段。当用户代码需要知道流端的关闭情况而不需要调用 ReadAsync 或 WriteAsync 时,这些属性非常就派上作用了。
最后,当流的工作完成时,它需要使用 DisposeAsync 方法。这一方法将确保读取/写入端(取决于流类型)都关闭。如果流直到结束还没有被正确读取,dispose 将发出一个等效的 Abort(QuicAbortDirection.Read) 命令。但是,如果流写入端还没有关闭,写入端会像使用 CompleteWrites 方法一样被关闭。之所以存在这种差异,是为了确保普通流的使用能够按照预期的方式进行,并进入相应的路径。
更多关于这个类设计的细节可以在 the QuicStream API Proposal (dotnet/runtime#69675) 中找到。
RFC 9000 Section 2.1
https://www.rfc-editor.org/rfc/rfc9000#section-2.1
CompleteWrites
https://learn.microsoft.com/dotnet/api/system.net.quic.quicstream.completewrites?view=net-7.0?ocid=AID3052907
WriteAsync
https://learn.microsoft.com/dotnet/api/system.net.quic.quicstream.writeasync?view=net-7.0#system-net-quic-quicstream-writeasync(system-readonlymemory((system-byte)?ocid=AID3052907
Abort
https://learn.microsoft.com/dotnet/api/system.net.quic.quicstream.abort?view=net-7.0?ocid=AID3052907
dotnet/runtime#69675
https://github.com/dotnet/runtime/issues/69675
▌未来
由于 System.Net.Quic 是新公开的,并且用法有限,因此我们将不胜感激有关 API 形状的任何错误报告或见解。由于 API 处于预览模式,我们仍然有机会根据收到的反馈针对 .NET 8 调整它们。可以在 dotnet/runtime 存储库中提交问题。
dotnet/runtime
https://github.com/dotnet/runtime
安全
▌Negotiate API
Windows Authentication 是一个包含多种技术的术语,用于企业中针对中央机构(通常是域控制器)对用户和应用程序进行身份验证。它支持诸如单点登录电子邮件服务或 Intranet 应用程序之类的场景。用于身份验证的基础技术是 Kerberos、NTLM 和包含的 Negotiate 协议,其中为特定身份验证方案选择最合适的技术。
在 .NET 7 之前,Windows 身份验证在高级 API 中公开。例如 HttpClient (Negotiate 和 NTLM 身份验证方案),SmtpClient (GSSAPI 和 NTLM 身份验证方案),NegotiateStream、ASP.NET Core 和 SQL Server 客户端库。虽然这涵盖了最终用户的大多数场景,但它对库作者来说是有限的。其他库,如 Npgsql PostgreSQL client、 MailKit、 Apache Kudu client 需要诉诸各种技巧,为并非基于 HTTP 或其他可用的高级构建块构建的低级协议实施相同的身份验证方案。
.NET 7 引入了新的 API,提供低级构建块来执行上述协议的身份验证交换,请参阅dotnet/runtime#69920。与 .NET 中的所有其他 API 一样, 它在构建时考虑了跨平台互操作性。在 Linux、macOS、iOS 和其他类似平台上,它使用 GSSAPI 系统库。在 Windows 上,它依赖于 SSPI 库。对于系统实现不可用的平台,例如 Android 和 tvOS,存在有限的仅客户端实现。
dotnet/runtime#69920
https://github.com/dotnet/runtime/issues/69920
▌如何使用 API
为了理解身份验证 API 的工作原理,让我们从一个示例开始,了解身份验证会话在 SMTP 等高级协议中的外观。该示例取自 Microsoft protocol documentation,该文档对其进行了更详细的解释。
- S: 220 server.contoso.com Authenticated Receive Connector
- C: EHLO client.contoso.com
- S: 250-server-contoso.com Hello [203.0.113.1]
- S: 250-AUTH GSSAPI NTLM
- S: 250 OK
- C: AUTH GSSAPI <token1>
- S: 334 <token2>
- C: <token3>
- S: 235 2.7.0 Authentication successful
身份验证从客户端生成质询令牌开始。然后服务器产生响应。客户端处理响应,并向服务器发送新的质询。这种质询/响应交换可以发生多次。当任何一方拒绝认证或双方都接受认证时,它结束。令牌的格式由 Windows 身份验证协议定义,而封装是高级协议规范的一部分。在此示例中,SMTP 协议预先添加334代码以告知客户端服务器产生了身份验证响应,该235代码表示身份验证成功。
大部分新 API 都以新 NegotiateAuthentication 类为中心。它用于实例化客户端或服务器端身份验证的上下文。有多种选项可以指定建立经过身份验证的会话的要求,例如要求加密或确定要使用的特定协议 (Negotiate, Kerberos 或 NTLM) 指定参数后,身份验证将通过在客户端和服务器之间交换身份验证质询/响应来进行。GetOutgoingBlob 方法用于此目的。它可以处理字节跨度或 base64 编码的字符串。
以下代码将同时执行客户端和服务器,对同一台机器上的当前用户进行部分身份验证:
- using System.Net;
- using System.Net.Security;
-
-
- var serverAuthentication = new NegotiateAuthentication(new NegotiateAuthenticationServerOptions { });
- var clientAuthentication = new NegotiateAuthentication(
- new NegotiateAuthenticationClientOptions
- {
- Package = "Negotiate",
- Credential = CredentialCache.DefaultNetworkCredentials,
- TargetName = "HTTP/localhost",
- RequiredProtectionLevel = ProtectionLevel.Sign
- });
-
-
- string? serverBlob = null;
- while (!clientAuthentication.IsAuthenticated)
- {
- // Client produces the authentication challenge, or response to server's challenge
- string? clientBlob = clientAuthentication.GetOutgoingBlob(serverBlob, out var clientStatusCode);
- if (clientStatusCode == NegotiateAuthenticationStatusCode.ContinueNeeded)
- {
- // Send the client blob to the server; this would normally happen over a network
- Console.WriteLine($"C: {clientBlob}");
- serverBlob = serverAuthentication.GetOutgoingBlob(clientBlob, out var serverStatusCode);
- if (serverStatusCode != NegotiateAuthenticationStatusCode.Completed &&
- serverStatusCode != NegotiateAuthenticationStatusCode.ContinueNeeded)
- {
- Console.WriteLine($"Server authentication failed with status code {serverStatusCode}");
- break;
- }
- Console.WriteLine($"S: {serverBlob}");
- }
- else
- {
- Console.WriteLine(
- clientStatusCode == NegotiateAuthenticationStatusCode.Completed ?
- "Successfully authenticated" :
- $"Authentication failed with status code {clientStatusCode}");
- break;
- }
- }
一旦建立了经过身份验证的会话,NegotiateAuthentication 实例就可以用于对传出消息进行签名/加密并验证/解密传入消息。这是通过 Wrap 和 Unwrap 方法完成的。
Microsoft protocol documentation
https://learn.microsoft.com/openspecs/windows_protocols/ms-ssean/0fa15a99-af88-428a-a51d-742b8450d877?ocid=AID3052907
▌证书验证选项
当客户端收到服务器的证书时,反之亦然,如果请求客户端证书,证书将通过 X509Chain。验证始终进行,即使 RemoteCertificateValidationCallback 提供了验证,并且在验证期间可能会下载其他证书。由于无法控制此行为,因此提出了几个问题。其中包括完全阻止证书下载、设置超时或提供自定义存储以从中获取证书的要求。为了缓解这整组问题,我们决定在 SslClientAuthenticationOptions 和 SslServerAuthenticationOptions 上引入一个新属性 CertificateChainPolicy。此属性的目标是在 AuthenticateAsClientAsync / AuthenticateAsServerAsync 操作期间生成链时覆盖 SslStream 的默认行为。在正常情况下,X509ChainPolicy 在后台自动构建。但是,如果指定了这个新属性,它将优先并被使用,从而使用户能够完全控制证书验证过程。
更多信息可以在 API 提案 (dotnet/runtime#71191) 中找到。
dotnet/runtime#71191
https://github.com/dotnet/runtime/issues/71191
▌TLS 简介
建立新的 TLS 连接是相当昂贵的操作,因为它需要多个步骤和多次往返。在经常重新创建与同一服务器的连接的情况下,握手所消耗的时间将加起来。TLS 提供的被称为会话恢复的功能可以缓解这种情况,请参阅 RFC 5246 Section 7.3 和 RFC 8446 Section 2.2。简而言之,在握手期间,客户端可以发送先前建立的 TLS 会话的标识,如果服务器同意,则根据先前连接的缓存数据重新建立安全上下文。尽管不同 TLS 版本的机制不同,但最终目标是相同的,即在重新建立与先前连接的服务器的连接时节省往返时间和一些 CPU 时间。此功能由 Windows 上的 SChannel 自动提供,但在 Linux 上使用 OpenSSL 需要进行几处更改才能启用此功能:
服务器端(无状态)– dotnet/runtime#57079 和 dotnet/runtime#63030
客户端 – dotnet/runtime#64369
缓存大小控制 – dotnet/runtime#69065
如果不需要缓存 TLS 上下文,可以使用环境变量“DOTNET_SYSTEM_NET_SECURITY_DISABLETLSRESUME”或通过 AppContext.SetSwitch “System.Net.Security.TlsCacheSize” 在进程范围内关闭它。
RFC 5246 Section 7.3
https://www.rfc-editor.org/rfc/rfc5246.html#section-7.3
RFC 8446 Section 2.2
https://www.rfc-editor.org/rfc/rfc8446#section-2.2
▌OCSP Stapling
Online Certificate Status Protocol (OCSP) Stapling 是服务器提供已签名和时间戳证明(OCSP 响应)的机制,证明发送的证书尚未被吊销,参见 RFC 6961。因此,客户端无需联系 OCSP 服务器本身,从而减少了建立连接所需的请求数量以及施加在 OCSP 服务器上的负载。由于 OCSP 响应需要由证书颁发机构 (CA) 签名,因此提供证书的服务器无法伪造它。在此版本之前,我们没有利用此 TLS 功能,有关详细信息请参阅 dotnet/runtime#33377。
RFC 6961
https://www.rfc-editor.org/rfc/rfc6961.html
dotnet/runtime#33377
https://github.com/dotnet/runtime/issues/33377
▌跨平台的一致性
我们知道,.NET 提供的某些功能仅在某些平台上可用。但是每个版本我们都试图进一步缩小差距。在 .NET 7 中,我们对网络安全空间进行了一些更改,以改善差异:
在适用于 TLS 1.3 的 Linux 上支持握手后认证 – dotnet/runtime#64268
远程证书现已在 Windows 上设置 SslClientAuthenticationOptions.LocalCertificateSelectionCallback–dotnet/runtime#65134
支持在 OSX 和 Linux 上的 TLS 握手中发送受信任 CA 的名称 – dotnet/runtime#65195
WebSockets
▌WebSocket 握手响应详细信息
在 .NET 7 之前,WebSocket 的开场握手(对升级请求的 HTTP 响应)的服务器响应部分隐藏在 ClientWebSocket 实现中, 并且所有握手错误都将显示为 WebSocketException,在异常信息旁边没有太多详细信息。但是,有关 HTTP 响应标头和状态代码的信息在失败和成功方案中都可能很重要。如果发生故障,HTTP 状态代码可以帮助区分可重试和不可重试的错误(例如,服务器根本不支持 WebSockets,或者只是暂时性网络错误)。标头还可能包含有关如何处理这种情况的其他信息。即使在 WebSocket 握手成功的情况下,标头也很有用,例如,它们可以包含与会话绑定的令牌、与子协议版本相关的信息,或者服务器可能很快关闭的信息。
.NET 7 将 CollectHttpResponseDetails 设置添加到 ClientWebSocketOptions 中,该设置允许在 ConnectAsync 调用期间收集 ClientWebSocket 实例中的升级响应详细信息。你稍后可以使用 ClientWebSocket 实例的 HttpStatusCode 和 HttpResponseHeaders 属性访问数据,即使在 ConnectAsync 引发异常的情况下也是如此。请注意,在特殊情况下,信息可能不可用,即如果服务器从未响应请求。
另请注意,如果连接成功,并且在使用 HttpResponseHeaders 数据后,可以通过将 ClientWebSocket.HttpResponseHeaders 属性设置为 null 来减少 ClientWebSocket 的内存占用量。
- var ws = new ClientWebSocket();
- ws.Options.CollectHttpResponseDetails = true;
- try
- {
- await ws.ConnectAsync(uri, cancellationToken);
- // success scenario
- ProcessSuccess(ws.HttpResponseHeaders);
- ws.HttpResponseHeaders = null; // clean up (if needed)
- }
- catch (WebSocketException)
- {
- // failure scenario
- if (ws.HttpStatusCode != 0)
- {
- ProcessFailure(ws.HttpStatusCode, ws.HttpResponseHeaders);
- }
- }
▌提供外部 HTTP 客户端
在默认情况下,ClientWebSocket 使用缓存的静态 HttpMessageInvoker 实例来执行 HTTP 升级请求。但是,有一些 ClientWebSocketOptions 会阻止缓存调用程序,例如 Proxy、ClientCertificates 或 Cookie。具有这些参数的 HttpMessageInvoker 实例不安全,每次调用 ConnectAsync 时都需要创建。这会导致许多不必要的分配,并使 HttpMessageInvoker 连接池无法重用。
.NET 7 允许您使用 ConnectAsync(Uri, HttpMessageInvoker, CancellationToken) 重载将现有的 HttpMessageInvoker (例如 HttpClient) 实例传递给 ConnectAsync 调用。在这种情况下,将使用提供的实例执行 HTTP 升级请求。
- var httpClient = new HttpClient();
-
-
- var ws = new ClientWebSocket();
- await ws.ConnectAsync(uri, httpClient, cancellationToken);
请注意,如果传递了自定义 HTTP 调用程序,则不得设置以下任何 ClientWebSocketOptions,而应在 HTTP 调用程序上设置:
ClientCertificates
Cookies
Credentials
Proxy
RemoteCertificateValidationCallback
UseDefaultCredentials
以下是在 HttpMessageInvoker 实例上设置所有这些选项的方法:
- var handler = new HttpClientHandler();
- handler.CookieContainer = cookies;
- handler.UseCookies = cookies != null;
- handler.ServerCertificateCustomValidationCallback = remoteCertificateValidationCallback;
- handler.Credentials = useDefaultCredentials ?
- CredentialCache.DefaultCredentials :
- credentials;
- if (proxy == null)
- {
- handler.UseProxy = false;
- }
- else
- {
- handler.Proxy = proxy;
- }
- if (clientCertificates?.Count > 0)
- {
- handler.ClientCertificates.AddRange(clientCertificates);
- }
- var invoker = new HttpMessageInvoker(handler);
-
-
- var ws = new ClientWebSocket();
- await ws.ConnectAsync(uri, invoker, cancellationToken);
▌WebSockets over HTTP/2
.NET 7 还增加了通过 HTTP/2 使用 WebSocket 协议的功能,如 RFC 8441 中所述。这样,WebSocket 连接是通过 HTTP / 2 连接上的单个流建立的。这允许同时在多个 WebSocket 连接和 HTTP 请求之间共享单个 TCP 连接,从而更有效地使用网络。
要通过 HTTP/2 启用 WebSockets,您可以将 ClientWebSocketOptions.HttpVersion 选项设置为HttpVersion.Version20。您还可以通过设置 ClientWebSocketOptions.HttpVersionPolicy 属性来启用所使用的 HTTP 版本的升级/降级。这些选项的行为方式与HttpRequestMessage.Version 和HttpRequestMessage.VersionPolicy 的行为方式相同。
例如,以下代码将探测 HTTP/2 WebSocket,如果无法建立 WebSocket 连接,它将回退到 HTTP/1.1:
- var ws = new ClientWebSocket();
- ws.Options.HttpVersion = HttpVersion.Version20;
- ws.Options.HttpVersionPolicy = HttpVersionPolicy.RequestVersionOrLower;
- await ws.ConnectAsync(uri, httpClient, cancellationToken);
HttpVersion.Version11 和 HttpVersionPolicy.RequestVersionOrHigher 的组合将导致与上述相同的行为,而 HttpVersionPolicy.RequestVersionExact 将不允许升级/降级 HTTP 版本。
默认情况下,设置了 HttpVersion = HttpVersion.Version11 和 HttpVersionPolicy = HttpVersionPolicy.RequestVersionOrLower,这意味着只会使用 HTTP/1.1。
通过单个 HTTP/2 连接多路复用 WebSocket 连接和 HTTP 请求的能力是此功能的关键部分。为了使它按预期工作,您需要在调用 ConnectAsync 时从代码中传递并重用相同的 HttpMessageInvoker 实例(例如 HttpClient),即使用 ConnectAsync(Uri, HttpMessageInvoker, CancellationToken) 重载。这将重用 HttpMessageInvoker 实例中的连接池进行多路复用。
最后,感谢您成为 .NET 一员,如果您遇到问题或有任何反馈,都可以在文章下面留言,我们将竭力为您解决!
点击「阅读原文」了解更多~