.NET 中的 WebSocket 支持

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 类型的攻击,有关详细信息,请参阅 CRIMEBREACH。 强烈建议在发送包含机密数据时,通过指定 DisableCompression 标志来关闭压缩。

Keep-Alive 策略

.NET 8 及更早版本中,唯一可用的 Keep-Alive 策略是未经请求的 PONG。 此策略足以防止底层 TCP 连接空闲。然而,当远程主机无响应(例如远程服务器崩溃)时,检测此类情况的唯一方法是依赖 TCP 超时进行未经请求的 PONG 检测。

.NET 9 引入了人们期待已久的 PING/PONG Keep-Alive 策略,用新的 KeepAliveInterval 设置完善了现有的 KeepAliveTimeout 设置。 从 .NET 9 开始,选择 Keep-Alive 策略,如下所示:

  1. Keep-Alive 关闭,如果
    • KeepAliveIntervalTimeSpan.ZeroTimeout.InfiniteTimeSpan
  2. 未经请求的 PONG,如果
    • KeepAliveInterval 是一个正有限 TimeSpan,-并且-
    • KeepAliveTimeoutTimeSpan.ZeroTimeout.InfiniteTimeSpan
  3. PING/PONG,如果
    • KeepAliveInterval 是一个正有限 TimeSpan,-并且-
    • KeepAliveTimeout 是正有限 TimeSpan

默认 KeepAliveTimeoutTimeout.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.KeepAliveIntervalWebSocketCreationOptions.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 保持挂起读取。