Не только для «обхода». Что такое Http Upgrade и как его использовать в .NET

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

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

Наш тестовый сервер будет уметь:

  1. слушать порт 8080 через TcpListener и принимать подключение очередного клиента;

  2. парсить запрос от клиента по существующему соединению:

    • если это HTTP GET запрос, то ожидать в нём заголовок Upgrade с указанием в качестве протокола rawstring,

    • если такой заголовок в запросе имеется получен – дальнейшие сообщения обрабатывать уже в соответствии с протоколом (пересылать обратно клиенту всё, что он отправил серверу);

  3. если очередной командой оказывается 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 сформируем нам 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

В остальном код не отличается от кода из предыдущего пункта.

Полезные ссылки по теме

  • Описание заголовка Upgrade от Mozilla (англ)

  • Стандарт HTTP 1.1

  • Полный код реализации и клиента, и сервера из статьи

На этом у меня всё, спасибо за внимание!

Напишите в комментариях, какие необычные способы взаимодействия между сервером и клиентом вы используете в production-среде?

Источник: https://habr.com/ru/companies/rostelecom/articles/725322/


Интересные статьи

Интересные статьи

Добавляем функционал быстрых ответов на любом тарифе Битрикс24Как добавить одну из самых полезных функций для чатов открытых линий для любого коммерческого тарифа? Все очень просто, нужно лишь..... ...
Вам приходилось сталкиваться с ситуацией, когда сайт или портал Битрикс24 недоступен, потому что на диске неожиданно закончилось место? Да, последний бэкап съел все место на диске в самый неподходящий...
Индекс цветопередачи света влияет на то, как мы видим предметы. Чем выше этот индекс, тем естественней цвета и более различимы оттенки. У солнца и ламп накаливания индекс цветопередачи равен 100,...
Ровно 15 лет назад, в этот самый день, своими дрожащими коленками я переступил порог офиса небольшого провинциального интернет-провайдера. Моя первая настоящая работа в качестве программиста....
В Челябинске проходят митапы системных администраторов Sysadminka, и на последнем из них я делал доклад о нашем решении для работы приложений на 1С-Битрикс в Kubernetes. Битрикс, Kubernetes, Сep...