Некоторое время назад на Хабре вышел обзор того-о-чём-не-стоит-говорить-в-этом-блоге. И, в числе прочих способов того-о-чём-не-стоит-говорить, был упомянут «транспорт 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-среде?
