Некоторое время назад на Хабре вышел обзор того-о-чём-не-стоит-говорить-в-этом-блоге. И, в числе прочих способов того-о-чём-не-стоит-говорить, был упомянут «транспорт HttpUpgrade».
Так как я использую заголовок Upgrade на практике, меня смутило, что в поиске в русскоязычном сегменте интернета найти корректное описание механизма непросто. Конечно, метод является достаточно редко используемым «вчистую» (то есть не в паре с протоколом WebSocket), но тем не менее.
Статья может пригодиться всем, кому требуется создать полнодуплексное tcp-соединение (то есть такое соединение, в котором и читать данные, и писать их могут и клиент, и сервер, причем в любой момент времени жизни соединения).
Я расскажу о том, что есть «HttpUpgrade», как наш ЦК использует его для создания соединения, по которому клиент и сервер могут обмениваться пакетами: клиент запрашивает видео, а сервер передаёт клиенту пакеты. Ну и разумеется, поделюсь кодом клиента и сервера, создающим между собой «апгрейднутое» соединение в .NET 8.
Меня зовут Андриевский Леонид, я тимлид команды .NET в ЦК Видеонаблюдения Ростелеком. Давайте приступим!
Что на самом деле представляет собой «HttpUpgrade»
Cтандарт HTTP/1.1 (опубликованный ещё в 1999 году) в секции 14.42 содержит описание заголовка (request header) Upgrade. Данный заголовок указывается клиентом в запросе, например, так:
GET /index.html HTTP/1.1
Host: camera.rt.ru
Connection: Upgrade
Upgrade: example/1, foo/2
Заголовок Upgrade предназначен для указания серверу, что с момента ответа сервера на запрос клиент хочет сменить протокол с HTTP на один из протоколов, перечисленных в значении поля Upgrade. В вышеописанном примере серверу предлагается выбор: переключиться либо на протокол example/1
, либо на foo/2
.
И на подобный запрос у сервера есть три варианта ответа:
В самом плохом случае – сервер может ответить кодом ошибки, скажем
400 Bad request
(если в запросе, с точки зрения сервера, есть ошибка).Также сервер может вежливо отказаться от предложенной смены протокола, отправив на запрос ответ
200 OK
, после чего отдав тело ответа согласно обычным правилам протокола http.Ну и самый хороший для нас вариант – сервер может согласиться на смену протокола; тогда он отвечает
101 Switching Protocols
, с указанием в ответе какой из протоколов, предложенных клиентом, он выбрал (в примере ниже это протоколexample/1
):
HTTP/1.1 101 Switching Protocols
Upgrade: example/1
Connection: Upgrade
В результате, после чтения из соединения ответа от сервера, сервер и клиент будут иметь уже установленный друг между другом канал связи, правила «общения» по которому задаются уже не протоколом HTTP 1.1, а тем протоколом, который был выбран при обмене запросом и ответом между сторонами.
То есть по сути своей «HttpUpgrade» – это просто древний способ переключения уже созданного tcp-соединения с режима «клиент и сервер общаются по протоколу HTTP» на «голое» полнодуплексное (когда и клиент, и сервер могут и читать, и писать данные) соединение.
А есть ли другие способы создания двухстороннего соединения?
Разумеется, в зависимости от поставленных задач двухстороннее соединение может быть реализовано:
через «голую» работу с сокетами (без всякого Http Upgrade),
через WebSocket, который как раз и создаёт двухстороннее соединение на основе Upgrade-а http/https-соединения, но даёт дополнительный оверхед, так как оборачивает передаваемые данные в свои пакеты (подробнее о структуре пакетов WebSocket),
через WebRTC (преимущественно используется браузерами – и часто «под капотом» использует WebSocket),
через WebTransport (достаточно новая «тема» на основе протокола HTTP/3); кстати очень неплохо освещённая на хабре,
используя только возможности HTTP 1.1; Http Long Polling или есть же http-запрос с бесконечным таймаутом (тут ссылку вставить нет возможности, подскажу только гугл по словам
Twitter Streaming API
).
Для наших рабочих задач требовалось передавать видеопоток с камеры клиенту – и одновременно от клиента передавать команды по изменению параметров этого видеопотока.
Поэтому после анализа доступных возможностей, команда разработки и приняла решение использовать «чистый» Http Upgrade, так как:
Решение принималось лет 10 назад. В то время HTTP/2 только появлялся, да и стабильный релиз WebRTC ещё не выпустили.
Для реализации на основе Http Upgrade требуются только сервер и клиент, реализующие HTTP/1.1. Сервером (в нашем случае) выступает камера. А почти на всех камерах, даже самых слабых, какой-никакой http-сервер всегда присутствует (так как у камер есть web-интерфейс).
Не требуется «плясок с бубном», если в локальной сети заказчика имеются системы анализа траффика, прокси или иные хитрости. Например, некоторые клиенты осуществляют доступ в интернет в корпоративных сетях через специальным образом настроенные http-прокси. А .NET-овский TcpClient, да и другие tcp-клиенты, не умеют сами работать с http-проксями. И это вполне логично: TCP – протокол более низкого уровня, чем HTTP.
Альтернативы Http Upgrade на основе HTTP 1.1 не очень подошли:
Http-запрос с бесконечным таймаутом это по сути обычное http-соединение, оно не обеспечивает дуплексной связи (чтобы и сервер, и клиент в любой момент времени могли передать данные),
Http Long Polling может обеспечить полудуплексную связь (данные по очереди будут передаваться и сервером, и клиентом), но для передачи от клиента к серверу придётся пересоздавать соединение, что может происходить не очень быстро при наличии прокси-сервера.
Время практики
Итак, давайте напишем код сервера и клиента на C# (.NET 8), между которыми создаётся полнодуплексное http-соединение.
Для пробного «общения» будем передавать текстовые сообщения в кодировке UTF8, разделённые переводом строки (
\r\n
). Назовём этот придуманный протоколrawstring
.Клиент будет отправлять сообщения – а сервер принимать и дублировать их же клиенту обратно.
Завершение «общения» между клиентом и сервером – сообщение с текстом
BYE
. На него сервер реагирует закрытием сетевого соединения с клиентом.
Код сервера
Тут не буду вдаваться в подробности сильно, так как код сервера вполне можно реализовать на основе «голых» TCP-сокетов и далее «скрыть» реализацию за, скажем, nginx-ом.
По крайней мере аналогичный пример тестового самописного WebSocket-сервера приводят товарищи из Mozilla – а специалистам, делающим свой браузер, думаю, можно верить.
Наш тестовый сервер будет уметь:
слушать порт 8080 через TcpListener и принимать подключение очередного клиента;
парсить запрос от клиента по существующему соединению:
если это HTTP GET запрос, то ожидать в нём заголовок Upgrade с указанием в качестве протокола
rawstring
,если такой заголовок в запросе имеется получен – дальнейшие сообщения обрабатывать уже в соответствии с протоколом (пересылать обратно клиенту всё, что он отправил серверу);
если очередной командой оказывается
BYE
– завершить соединение и переходить снова к пункту 1 данного списка.
Уверен, что с использованием ASP Net Core вполне можно написать более красивый код – но для демонстрации работы и такой код должен подойти.
Код сервера
// На основе https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_server
static async Task RunServerAsync(string ip, int port)
{
// простой сервер, переключающий единственного клиента
// между протоколами HTTP 1.1 и rawstring с использованием заголовка Http Upgrade
// и при работе по протоколу rawstring отправляющий все сообщения от клиента обратно клиенту
using var server = new TcpListener(IPAddress.Parse(ip), port);
server.Start();
Console.WriteLine("Server has started on {0}:{1}, Waiting for a connection…", ip, port);
while (true)
{
using var client = server.AcceptTcpClient();
Console.WriteLine(new string('-', 25));
Console.WriteLine("New client has connected!");
using var stream = client.GetStream();
while (stream.Socket.Connected)
{
var request = await WaitForHttpRequestAsync(client, stream);
if (Regex.IsMatch(request, "^GET", RegexOptions.IgnoreCase))
{
Console.WriteLine("HTTP: Handshaking from client!");
var result = await SwitchProtocolsAsync(stream, request);
if (!result)
{
Console.WriteLine("Handshake failed!");
break;
}
else
Console.WriteLine($"Handshake success! Switched to {ProtocolName}");
}
else
{
Console.WriteLine($"{ProtocolName}: got new request {request.Length} bytes");
await WriteHttpResponseAsync(stream, request);
if (request.Split("\r\n", StringSplitOptions.RemoveEmptyEntries).Any(line => line == "BYE"))
{
Console.WriteLine("Got BYE command. Terminating");
break;
}
}
}
}
}
private static async Task<bool> SwitchProtocolsAsync(NetworkStream stream, string request)
{
var protocolToSwitch = Regex.Match(request, "Upgrade: (.*)").Groups[1].Value.Trim();
// HTTP/1.1 определяет перенос строки как CR LF
if (protocolToSwitch == ProtocolName)
{
await WriteHttpResponseAsync(stream,
"HTTP/1.1 101 Switching Protocols\r\n" +
"Connection: Upgrade\r\n" +
$"Upgrade: {ProtocolName}\r\n\r\n");
return true;
}
else
{
await WriteHttpResponseAsync(stream, "HTTP/1.1 400 Bad request\r\n\r\n");
return false;
}
}
private static async Task WriteHttpResponseAsync(NetworkStream stream, string response)
{
var responseBytes = Encoding.UTF8.GetBytes(response);
await stream.WriteAsync(responseBytes, 0, response.Length);
}
private static async Task<string> WaitForHttpRequestAsync(TcpClient client, NetworkStream stream)
{
while (!stream.DataAvailable)
await Task.Delay(1000);
// Ждём, пока в буфере накопится минимум 3 байта (там ожидается "get")
while (client.Available < 3)
await Task.Delay(1000);
var bytes = new byte[client.Available];
stream.Read(bytes, 0, bytes.Length);
var request = Encoding.UTF8.GetString(bytes);
return request;
}
Ссылка на репозиторий с полным кодом сервера.
Код клиента: используем HttpClient (.NET 5+)
Если ваш проект использует .NET 5+ и HttpClient – вам очень повезло. Вы сможете использовать Http Upgrade добавив буквально несколько строк при формировании HttpRequestMessage:
// Прямое создание HttpClient тут только для примера! В production используйте HttpClientFactory!
using var client = new HttpClient();
// url: url сервера, к которому требуется подключиться
var message = new HttpRequestMessage(HttpMethod.Get, url);
message.Headers.TryAddWithoutValidation("Connection", "Upgrade");
message.Headers.TryAddWithoutValidation("Upgrade", "rawstring");
var response = await client.SendAsync(message, HttpCompletionOption.ResponseHeadersRead);
После получения ответа вам надо проверить код ответа сервера (response.StatusCode
) чтобы понять:
согласен ли сервер на upgrade протокола (код 101),
не согласен (код 200),
или вспоминает известный мем, начинающийся с «вы кто такие, я вас не звал» (ответ 400).
Полученный из объекта response
поток при положительном исходе (101) будет иметь признак возможности записи – и далее можно работать с ним уже по вашему кастомному протоколу.
Полный код тестового приложения-клиента, подключающегося через HttpClient к написанному серверу по «rawstring» выложен на нашем gitlab.
Код клиента: используем WebRequest (.NET 5+)
К сожалению, далеко не всем нам везёт работать с актуальным кодом. Если вдруг ваш код по любым причинам работает с объектами WebRequest в .NET 5+, у вас ещё есть шанс создать upgrade-нутое соединение – правда, придётся обойти одну тонкость.
Дело в том, что в коде класса HttpWebRequest.cs в строке 1192 прописано, что следует кидать WebException при ответах меньше 200. То есть корректно сформировав заголовок и отправив запрос, на ответ сервера 101 .NET Framework тупо кинет WebException. Поэтому мы вынуждены обернуть вызов GetResponse в try-catch:
var request = (HttpWebRequest)WebRequest.Create(url);
request.Connection = "Upgrade";
request.Headers["Upgrade"] = Program.ProtocolName;
request.AllowAutoRedirect = true;
request.Method = "GET";
request.Accept = "*/*";
request.Proxy = WebRequest.DefaultWebProxy;
try
{
response = await request.GetResponseAsync();
}
catch (WebException e) when (e.Response is HttpWebResponse exResponse &&
exResponse.StatusCode == HttpStatusCode.SwitchingProtocols)
{
response = e.Response;
}
Далее анализируем полученный HttpWebResponse аналогично предыдущему пункту.
Ссылка на полный код приложения-клиента, подключающегося к серверу через WebRequest.
Код клиента: используем WebRequest (.NET Framework)
А вот тут, товарищи, без рефлексии никуда. Дело в том, что в .NET Framework почему-то не подумали про заголовок Upgrade отдельно от WebSocket. То есть передать вы его можете – но поток вам всегда будет возвращаться такой, в который нельзя ничего записать.
Возможно так случилось потому, что в .NET Framework сам WebSocket полностью не реализован. В частности, до выхода .NET 5, в Windows 7 у класса WebSocket не было реализации (sic!).
К счастью, есть грязный хак. У объекта HttpWebRequest есть internal-конструктор с параметром isWebSocketRequest. И, если вы создадите экземпляр HttpWebRequest с данным флагом, то возвращаемый при ответе объект Stream можно будет писать:
// переменная uri должна быть типа System.Uri
var request = CreateInstance<HttpWebRequest>(uri, null, true, "RawStringGroupName" + Guid.NewGuid());
request.Connection = "Upgrade";
request.Headers["Upgrade"] = "rawstring";
// далее выполняем запрос с созданным объектом request
В остальном код не отличается от кода из предыдущего пункта.
Полезные ссылки по теме
На этом у меня всё, спасибо за внимание!
Напишите в комментариях, какие необычные способы взаимодействия между сервером и клиентом вы используете в production-среде?