WebSocket 协议支持客户端和远程主机之间的双向通信。
System.Net.WebSockets.ClientWebSocket 公开了通过握手建立 WebSocket 连接的功能,它由 ConnectAsync
方法创建和发送。
HTTP/1.1 和 HTTP/2 WebSocket 的差异
基于 HTTP/1.1 的 WebSocket 使用单个 TCP 连接,因此它由连接范围的标头管理,有关详细信息,请参阅 RFC 6455。 请考虑以下示例,了解如何通过 HTTP/1.1 建立 WebSocket:
Uri uri = new("ws://corefx-net-http11.azurewebsites.net/WebSocket/EchoWebSocket.ashx");
using ClientWebSocket ws = new();
await ws.ConnectAsync(uri, default);
var bytes = new byte[1024];
var result = await ws.ReceiveAsync(bytes, default);
string res = Encoding.UTF8.GetString(bytes, 0, result.Count);
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Client closed", default);
由于 HTTP/2 的多路复用性质,必须采用不同的方法。 WebSocket 是为每个流建立的,有关详细信息,请参阅 RFC 8441。 通过HTTP/2,可以将一个连接用于多个WebSocket流以及普通HTTP流,并将HTTP/2更高效的网络使用能力扩展到WebSocket中。 ConnectAsync(Uri, HttpMessageInvoker, CancellationToken) 有一个特殊的重载,它接受 HttpMessageInvoker 以允许重用现有的池连接:
using SocketsHttpHandler handler = new();
using ClientWebSocket ws = new();
await ws.ConnectAsync(uri, new HttpMessageInvoker(handler), cancellationToken);
设置 HTTP 版本和策略
默认情况下,ClientWebSocket
使用 HTTP/1.1 发送初始握手并允许降级。 在 .NET 7 中,可以通过 HTTP/2 使用 Web 套接字。 在调用 ConnectAsync
之前,可以对其进行更改:
using SocketsHttpHandler handler = new();
using ClientWebSocket ws = new();
ws.Options.HttpVersion = HttpVersion.Version20;
ws.Options.HttpVersionPolicy = HttpVersionPolicy.RequestVersionOrHigher;
await ws.ConnectAsync(uri, new HttpMessageInvoker(handler), cancellationToken);
不兼容的选项
ClientWebSocket
具有用户可以在建立连接之前设置的属性 System.Net.WebSockets.ClientWebSocketOptions。 但是,当提供 HttpMessageInvoker
时,它还具有这些属性。 为了避免歧义,在这种情况下,应在 HttpMessageInvoker
上设置属性,ClientWebSocketOptions
应具有默认值。 否则,如果 ClientWebSocketOptions
已更改,则 ConnectAsync
的重载将引发 ArgumentException。
using HttpClientHandler handler = new()
{
CookieContainer = cookies;
UseCookies = cookies != null;
ServerCertificateCustomValidationCallback = remoteCertificateValidationCallback;
Credentials = useDefaultCredentials
? CredentialCache.DefaultCredentials
: credentials;
};
if (proxy is null)
{
handler.UseProxy = false;
}
else
{
handler.Proxy = proxy;
}
if (clientCertificates?.Count > 0)
{
handler.ClientCertificates.AddRange(clientCertificates);
}
HttpMessageInvoker invoker = new(handler);
using ClientWebSocket cws = new();
await cws.ConnectAsync(uri, invoker, cancellationToken);
压缩
WebSocket 协议支持按消息压缩(如 RFC 7692 中所定义)。 它由 System.Net.WebSockets.ClientWebSocketOptions.DangerousDeflateOptions控制。 如果存在,选项将在握手阶段发送到服务器。 如果服务器支持 per-message-deflate 并接受这些选项,则默认情况下将为所有消息创建一个启用压缩的 ClientWebSocket
实例。
using ClientWebSocket ws = new()
{
Options =
{
DangerousDeflateOptions = new WebSocketDeflateOptions()
{
ClientMaxWindowBits = 10,
ServerMaxWindowBits = 10
}
}
};
重要
在使用压缩之前,请注意,启用它会使应用程序受到 CRIME/BREACH 类型的攻击,有关详细信息,请参阅 CRIME 和 BREACH。 强烈建议在发送包含机密数据时,通过指定 DisableCompression
标志来关闭压缩。
Keep-Alive 策略
在 .NET 8 及更早版本中,唯一可用的 Keep-Alive 策略是未经请求的 PONG。 此策略足以防止底层 TCP 连接空闲。然而,当远程主机无响应(例如远程服务器崩溃)时,检测此类情况的唯一方法是依赖 TCP 超时进行未经请求的 PONG 检测。
.NET 9 引入了人们期待已久的 PING/PONG Keep-Alive 策略,用新的 KeepAliveInterval
设置完善了现有的 KeepAliveTimeout
设置。 从 .NET 9 开始,选择 Keep-Alive 策略,如下所示:
- Keep-Alive 关闭,如果
-
KeepAliveInterval
是TimeSpan.Zero
或Timeout.InfiniteTimeSpan
-
-
未经请求的 PONG,如果
-
KeepAliveInterval
是一个正有限TimeSpan
,-并且- -
KeepAliveTimeout
是TimeSpan.Zero
或Timeout.InfiniteTimeSpan
-
-
PING/PONG,如果
-
KeepAliveInterval
是一个正有限TimeSpan
,-并且- -
KeepAliveTimeout
是正有限TimeSpan
-
默认 KeepAliveTimeout
值 Timeout.InfiniteTimeSpan
,因此默认 Keep-Alive 行为在 .NET 版本之间保持一致。
如果使用 ClientWebSocket
,则默认 ClientWebSocketOptions.KeepAliveInterval 值为 WebSocket.DefaultKeepAliveInterval(通常为 30 秒)。 这意味着,默认情况下,ClientWebSocket
的 Keep-Alive 处于开启状态,并将“未经请求的 PONG”作为默认策略。
如果要切换到 PING/PONG 策略,重写 ClientWebSocketOptions.KeepAliveTimeout 就足够了:
var ws = new ClientWebSocket();
ws.Options.KeepAliveTimeout = TimeSpan.FromSeconds(20);
await ws.ConnectAsync(uri, cts.Token);
对于基本 WebSocket
,默认情况下,Keep-Alive 为 OFF。 若要使用 PING/PONG 策略,需要设置 WebSocketCreationOptions.KeepAliveInterval 和 WebSocketCreationOptions.KeepAliveTimeout:
var options = new WebSocketCreationOptions()
{
KeepAliveInterval = WebSocket.DefaultKeepAliveInterval,
KeepAliveTimeout = TimeSpan.FromSeconds(20)
};
var ws = WebSocket.CreateFromStream(stream, options);
如果使用未经请求的 PONG 策略,PONG 帧将用作单向检测信号。 无论远程终结点是否通信,它们都会以 KeepAliveInterval
间隔定期发送。
如果 PING/PONG 策略处于活动状态,则在距离远程终结点的KeepAliveInterval
经过 时间后发送 PING 帧。 每个 PING 帧都包含一个整数标记,用于与预期的 PONG 响应配对。 如果 KeepAliveTimeout
过后未收到 PONG 响应,则远程终结点被视为无响应,并且 WebSocket 连接会自动中止。
var ws = new ClientWebSocket();
ws.Options.KeepAliveInterval = TimeSpan.FromSeconds(10);
ws.Options.KeepAliveTimeout = TimeSpan.FromSeconds(10);
await ws.ConnectAsync(uri, cts.Token);
// NOTE: There must be an outstanding read at all times to ensure
// incoming PONGs are processed
var result = await _webSocket.ReceiveAsync(buffer, cts.Token);
如果超时时间到,则未完成的 ReceiveAsync
将会引发 OperationCanceledException
:
System.OperationCanceledException: Aborted
---> System.AggregateException: One or more errors occurred. (The WebSocket didn't receive a Pong frame in response to a Ping frame within the configured KeepAliveTimeout.) (Unable to read data from the transport connection: The I/O operation has been aborted because of either a thread exit or an application request..)
---> System.Net.WebSockets.WebSocketException (0x80004005): The WebSocket didn't receive a Pong frame in response to a Ping frame within the configured KeepAliveTimeout.
at System.Net.WebSockets.ManagedWebSocket.KeepAlivePingHeartBeat()
...
继续读取以处理 PONG
注意
目前,只有在 WebSocket
挂起时,ReceiveAsync
才会处理传入帧。
重要
如果要使用 Keep-Alive 超时,那么及时处理 PONG 响应至关重要。 即使远程终结点处于活动状态并正确发送 PONG 响应,但如果 WebSocket
不处理传入帧,Keep-Alive 机制也可能发出“误报”中止信号。 如果在超时前没有从传输流中取出 PONG 帧,可能会发生此问题。
为了避免切断良好的连接,建议用户对所有配置了 Keep-Alive 超时的 WebSocket 保持挂起读取。