Поделиться через


Поддержка WebSockets в .NET

Протокол WebSocket обеспечивает двустороннее взаимодействие между клиентом и удаленным узлом. System.Net.WebSockets.ClientWebSocket предоставляет возможность установить подключение WebSocket через открывающее рукопожатие, которое создается и отправляется методом ConnectAsync.

Различия в HTTP/1.1 и HTTP/2 WebSockets

WebSockets по протоколу HTTP/1.1 используют одно TCP-подключение, поэтому управление осуществляется заголовками всего соединения. Дополнительные сведения см. в статье RFC 6455. Рассмотрим следующий пример установки WebSocket по протоколу HTTP/1.1:

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 из-за его мультиплексирования. WebSockets устанавливаются для каждого потока, для получения дополнительной информации см. RFC 8441. С помощью HTTP/2 можно использовать одно подключение для нескольких веб-сокет-потоков вместе с обычными HTTP-потоками и распространить более эффективное использование сети HTTP/2 на WebSockets. Существует специальная перегрузка 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. Его можно изменить перед вызовом 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. При наличии параметры отправляются на сервер во время этапа подтверждения. Если сервер поддерживает дефланцию по сообщению и принимает параметры, экземпляр ClientWebSocket будет создан со сжатием, включенным по умолчанию для всех сообщений.

using ClientWebSocket ws = new()
{
    Options =
    {
        DangerousDeflateOptions = new WebSocketDeflateOptions()
        {
            ClientMaxWindowBits = 10,
            ServerMaxWindowBits = 10
        }
    }
};

Важный

Прежде чем использовать сжатие, пожалуйста, имейте в виду, что его включение делает приложение уязвимым для атак типа CRIME/BREACH. Дополнительную информацию см. в разделах CRIME и BREACH. Настоятельно рекомендуется отключить сжатие при отправке данных, содержащих секреты, указав флаг DisableCompression для таких сообщений.

стратегии Keep-Alive

На .NET 8 и более ранних версиях доступна только стратегия непрошенного PONGKeep-Alive. Эта стратегия достаточно эффективна для того, чтобы базовое TCP-подключение не простаивало. Однако в случае, когда удаленный узел не отвечает (например, удаленный сервер отключается), единственный способ обнаружения таких ситуаций с непреднамеренного PONG заключается в том, чтобы полагаться на тайм-аут TCP.

.NET 9 представила долгожданную стратегию PING/PONG Keep-Alive, дополняющую существующий KeepAliveInterval параметр новым KeepAliveTimeout параметром. Начиная с .NET 9, стратегия Keep-Alive выбрана следующим образом:

  1. Keep-Alive OFF, если
    • KeepAliveInterval TimeSpan.Zero или Timeout.InfiniteTimeSpan
  2. непрошенный PONG, если
    • KeepAliveInterval является положительным конечным TimeSpan, -AND-
    • KeepAliveTimeout TimeSpan.Zero или Timeout.InfiniteTimeSpan
  3. PING/PONG, если
    • KeepAliveInterval является положительным конечным TimeSpan, -AND-
    • KeepAliveTimeout является положительным конечным TimeSpan

Значение по умолчанию KeepAliveTimeout равно Timeout.InfiniteTimeSpan, поэтому поведение Keep-Alive по умолчанию остается согласованным между версиями .NET.

Если вы используете ClientWebSocket, то значение ClientWebSocketOptions.KeepAliveInterval по умолчанию задается как WebSocket.DefaultKeepAliveInterval (обычно 30 секунд). Это означает, что ClientWebSocket по умолчанию имеет Keep-Alive включённым, а в качестве стратегии по умолчанию используется Unsolicited PONG.

Если Вы хотите переключиться на стратегию PING/PONG, достаточно переопределения ClientWebSocketOptions.KeepAliveTimeout.

var ws = new ClientWebSocket();
ws.Options.KeepAliveTimeout = TimeSpan.FromSeconds(20);
await ws.ConnectAsync(uri, cts.Token);

Для базового WebSocketKeep-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);

Если используется стратегия Unsolicited PONG, кадры PONG используются как однонаправленный сигнал оповещения. Они отправляются регулярно с интервалами KeepAliveInterval, независимо от того, происходит ли взаимодействие с удаленной конечной точкой или нет.

Если стратегия PING/PONG активна, кадр PING отправляется после того, как прошло KeepAliveInterval времени с момента () последнего общения () из удаленной конечной точки. Каждый кадр PING содержит целый идентификатор для соответствия с ожидаемым ответом PONG. Если ответ PONG не получен после того, как истекло KeepAliveTimeout, удаленная конечная точка считается не отвечающей, а подключение 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()
...

Продолжайте чтение для обработки PONGs

Заметка

В настоящее время WebSocket обрабатывает входящие кадры во время ожидания ReceiveAsync.

Важный

Если вы хотите использовать время ожидания Keep-Alive, важно, что ответы PONG быстро обработаны. Даже если удаленная конечная точка жива и правильно отправляет ответ PONG, но WebSocket не обрабатывает входящие кадры, механизм Keep-Alive может выдавать "ложноположительные" прерывания. Эта проблема может произойти, если кадр PONG не извлекается из транспортного потока до истечения срока ожидания.

Чтобы избежать разрыва хороших подключений, пользователям рекомендуется держать ожидающее чтение во всех WebSockets, для которых настроен таймаут Keep-Alive.