Всем привет! Меня зовут Андрей Федотов, я бэкенд-разработчик в одной из команд платформы интернета вещей ZIIoT Oil&Gas. Наша сфера ответственности — набор сервисов, которые являются универсальным слоем доступа к данным для всех потребителей на платформе. Мы предоставляем данные, оставляя источники этих данных за кадром. У нас практикуется микросервисный подход к архитектуре, и мы активно используем стандартный HttpClient для отправки запросов. Это базовый класс, и в интернете много информации о нем. Однако практика показывает, что он совсем не так прост, как может показаться на первый взгляд. Есть ряд нюансов, о которых нужно знать, чтобы не получить трудноподдерживаемый и избыточно сложный код, который еще и теряет устойчивость по мере роста нагрузки на сервисы. Об этих нюансах я и расскажу в этой статье, а в конце будут ответы на вопросы и небольшой список рекомендаций, которые, я надеюсь, помогут практикам избежать излишней сложности там, где ее можно избежать.

Но сначала предыстория, подвигшая меня на написание статьи.

Предыстория

Сервисы для доступа к данным на платформе ZIIoT Oil&Gas нам достались по наследству. Когда мы только взялись за проект, он показался нам изрядно сложным. Со временем мы поняли, что эта сложность избыточна для текущих и ближайших задач. Покажу вам фрагменты тогдашнего кода. Для наглядности они мною были упрощены.

Пример 1. Метод GetToken (используется повсеместно):

public TokenInfo GetToken(string authUrl, string authClientId, string authClientSecret)
{
	var client = new HttpClient();
	var req = new HttpRequestMessage(HttpMethod.Post, authUrl)
	{
		Content = new FormUrlEncodedContent(new[]
		{
			new KeyValuePair<string, string>("grant_type", "client_credentials"),
			new KeyValuePair<string, string>("client_id", authClientId),
			new KeyValuePair<string, string>("client_secret", authClientSecret),
		})
	};
	var res = client.SendAsync(req).Result;
	res.EnsureSuccessStatusCode();
	var tmp = res.Content.ReadAsStringAsync().Result;
	var a = JsonConvert.DeserializeObject<TokenInfo>(tmp);
	return a;
}

Тут стоит обратить внимание на то, что создается новый экземпляр HttpClient (на остальное, пожалуйста, закройте глаза, чтобы из них не пошла кровь). А когда вызывающая сторона получит TokenInfo, она создаст другой экземпляр HttpClient, чтобы выполнить сам запрос.

Пример 2. Метод расширения над HttpClient:

public static async Task<ValueDesc> GetSomeDataAsync(this HttpClient tmpHc, IProvider provider, long id, DateTime time)
{
	var tm = time.ToString(TimeConstHelper.HttpFormat).Replace(":", "%3A");
	var response = await tmpHc.GetAsync(provider.GetPath("/api/v1/value/" + id + "?ts=" + tm + "&mode=Demo&filter=Any"));
	if (response.IsSuccessStatusCode)
	{
		var res = await response.Content.ReadAsStringAsync();
		var tmp = JsonConvert.DeserializeObject<FuAnswer>(res);
		if (tmp != null)
		{
			if (tmp.Error == null)
			{
				if (tmp.Result != null)
				{
					return tmp.Result;
				}
				throw new GetValueException("Error get value for id=[" + id + "] > Answer value is null");
			}
			throw new GetValueException("Error get value for id=[" + id + "] > " + tmp.Error.Message);
		}
		throw new GetValueException("Error get value for id=[" + id+"]");
	}
	throw await response.ToException(nameof(DataHelper));
}

Тут, конечно, не создается новый HttpClient, но происходят другие ужасные вещи. Например, обратите внимание на метод расширения над HttpClient для получения данных (на остальное все еще закрываем глаза, в рамках статьи говорим только про HttpClient и связанные с ним вещи).

Пример 3. Еще один метод расширения — смотрим на формирование query:

public static async Task<bool> WriteAnnotationAsync(this HttpClient tmpHc, IProvider provider, Desc d, Info info, ValueDesc valueDesc)
{
	var str = "[{\"timestamp\": " + valueDesc.Timestamp + ", \"annotation\": \"" + info.Value + "\"";
	if (valueDesc.Quality != null)
	{
		str += ", \"quality\": " + valueDesc.Quality;
	}
	if (valueDesc.ValueBool == null && valueDesc.ValueDbl == null && valueDesc.ValueFloat == null && valueDesc.ValueLong == null && valueDesc.ValueStr == null)
	{
		str += " }]";
	}
	else
	{
		switch (d.Type)
		{
			case TypeEnum.Double:
			{
				if (double.IsNaN(valueDesc.ValueDbl.Value))
				{
					str += ", \"valueDbl\": NaN }]";
				}
				else if (double.IsPositiveInfinity(valueDesc.ValueDbl.Value))
				{
					str += ", \"valueDbl\": Infinity }]";
				}
				else if (double.IsNegativeInfinity(valueDesc.ValueDbl.Value))
				{
					str += ", \"valueDbl\": -Infinity }]";
				}
				else
				{
					str += ", \"valueDbl\": " + valueDesc.ValueDbl + " }]";
				}

				break;
			}
			case PointTypeEnum.Float:
				// ...
				break;
			default:
				throw new ArgumentOutOfRangeException();
		}
	}
	var context = new StringContent(str, Encoding.UTF8, Hch.ApplicationJson);
	var response = await tmpHc.PostAsync(provider.GetPath("/api/v1/data/" + d.Id), context);
	if (response.IsSuccessStatusCode)
	{
		return true;
	}
	throw new Exception($"{(int) response.StatusCode}");
}

Этот пример сильно сокращен, иначе он никуда бы не поместился. Ручное формирование query строки пугает. А ведь для формирования параметров query есть отличный класс QueryBuilder.

Как видим, код проекта был сложноподдерживаемым и хуже того — становился неустойчивым по мере роста нагрузки и требований к нашим сервисам. Так как мы делаем много HTTP-запросов, то постоянное создание новых экземпляров HttpClient приводило к тому, что пул TCP-соединений использовался неэффективно, ведь с помощью одного экземпляра HttpClient можно (и нужно) отправлять больше, чем просто один запрос.

Разумеется, что такого кода, что я привел в примерах, у нас больше не осталось. Мы провели глобальный рефакторинг, в рамках которого было сделано следующее:

  1. Все методы расширения над HttpClient, которые были разбросаны по разным местам в проекте, мы удалили. Вместо них мы используем типизированные клиенты под каждый сторонний сервис.

  2. Ответственность за создание экземпляров клиентов мы переложили на IHttpClientFactory.

  3. Перешли на формирование строк запроса с помощью QueryBuilder.

Как итог, код стал понятным и более приспособленным для работы в реальных условиях.

Теперь поехали разбираться с нюансами работы HttpClient, чтобы не угодить в ловушки при его использовании и не нарваться на необходимость глобального рефакторинга.

Особенности и проблемы HttpClient

HttpClient — это класс из пространства имен System.Net.Http. В документации от Microsoft сказано, что он предназначен для создания экземпляра один раз и повторного использования на протяжении всего срока действия приложения. Он пулирует подключения внутри экземпляра обработчика и повторно использует подключение для нескольких запросов.

Есть у этого класса общеизвестная особенность: у него есть метод Dispose(), так как он реализует интерфейс IDisposable. И мы подсознательно можем захотеть обернуть его в конструкцию using для работы с disposable-объектами, ведь это хорошая практика. Но HttpClient — он другой. Хотя он и реализует интерфейс IDisposable, это все-таки shared-объект. Это значит, что он реентерабельный (то есть одна и та же копия в памяти может быть совместно использована несколькими пользователями или процессами) и потокобезопасный. Тем не менее HttpClient реализует IDisposable, и он должен быть disposed, но не нами.

Также стоит обратить внимание на конструкторы HttpClient:

  • по умолчанию — public HttpClient(). Он создаст HttpClient с новым обработчиком new HttpClientHandler();

  • public HttpClient(HttpMessageHandler handler);

  • public HttpClient(HttpMessageHandler handler, bool disposeHandler).

Параметр disposeHandler говорит, надо ли диспоузить сам handler, когда диспоузится экземпляр.

Хорошей практикой является создание HttpClientHandler по умолчанию или конкретная его реализация с конфигурированием всего, что нужно.

Проблемы, которые могут возникнуть при использовании HttpClient, хорошо описаны в статье Подводные камни HttpClient в .NET. Вот краткие выдержки из нее с пояснениями:

  • Неочевидная утечка соединений. Создание новых экземпляров HttpClient мешает переиспользованию TCP-соединения под капотом. Соединения будут висеть в статусе TIME_WAIT (это специальное состояние сокета после его закрытия приложением), и нужно это для обработки пакетов, которые все еще могут идти по сети. У каждого экземпляра HttpClient эти соединения свои. А HttpClient обычно используется для более, чем просто одного запроса.

  • Проблема долгоживущих соединений и кеширование DNS. При установке соединения с удаленным сервером в первую очередь происходит разрешение доменного имени в соответствующий IP-адрес, затем полученный адрес помещается на некоторое время в кэш для ускорения последующих соединений. Для экономии ресурсов чаще всего соединение не закрывается после выполнения каждого запроса, а держится открытым длительное время. И если делать HttpClient статическим или синглтоном, чтобы избежать проблемы утечки соединений, то это может сыграть с нами злую шутку. Потому что если DNS TTL истекает, и после этого доменное имя указывает на новый IP-адрес, то наш код об этом не узнает. До тех пор, пока мы не перезапустим программу. По умолчанию в HttpClient нет логики для обработки такой ситуации.

  • Проблема лимита одновременных соединений с сервером. Касается .NET Framework. Дело в том, что взаимодействие с HTTP в .NET Framework, помимо всего прочего, идет через специальный класс System.Net.ServicePointManager, контролирующий различные аспекты HTTP-соединений. В этом классе есть свойство DefaultConnectionLimit, указывающее, сколько одновременных подключений можно создавать для каждого домена. Так исторически сложилось, что по умолчанию значение свойства равно 2. Но и тут есть нюанс. Если мы локально запустим пример, то все будет работать хорошо, потому что для localhost ConnectionLimit = int.MaxValue.

HttpClientHandler

Важно помнить, что класс HttpClient является оберткой с удобными для работы методами. Внутри же него работа ведется через класс HttpClientHandler. Именно внутри HttpClientHandler реализована логика отправки запросов и чтения ответов. От настройки HttpClientHandler, который мы передадим в HttpClient, зависит то, как именно будет отправлен запрос.

Многое, что касается работы с HTTP в .NET (и об обработчиках), подробно рассмотрено в докладе Евгения Пешкова на DotNext'е.

С HttpClientHandler можно работать через HttpMessageInvoker напрямую — в обход HttpClient (HttpClient является наследником от HttpMessageInvoker, и HttpClient вызывает базовый метод SendAsync(...) в HttpMessageInvoker).

Важно отметить, что в различных реализациях платформы .NET работа с клиентским HTTP устроена по-разному. В .NET Framework и с .NET Core будут использоваться разные реализации HttpClientHandler. До версии .NET 2.1 и вовсе использовались не managed-реализации, а managed-обертки над нативными библиотеками ОС. После 2.1 — managed-реализация (на основе класса Socket), которая имеет консистентное поведение на всех платформах.

Очень хорошо HttpClientHandler рассмотрен в статье Стива Гордона. Если говорить кратко, то HttpRequestMessage проходит следующий путь (см. Рис. 1), прежде чем преобразуется в байты HTTP-запроса и передается в сокет:

Рисунок 1. Путь HttpRequestMessage. Источник: https://www.stevejgordon.co.uk/

Ответ принимается через сокет и преобразуется в конечный объект HttpResponseMessage, который возвращается через стек к исходному вызывающему коду.

IHttpClientFactory

Начиная с .NET 2.1 можно больше не заниматься созданием HttpClient вручную, а делегировать управление IHttpClientFactory. Цель IHttpClientFactory — разделить управление HttpClient от цепочек HttpMessageHandler. Также фабрика отвечает за создание экземпляров HttpClient.

До появления IHttpClientFactory разработчики часто попадали в одну из двух ловушек:

  • создавали и диспоузили HttpClient;

  • создавали единый экземпляр HttpClient на все время жизни приложения, что приводило к тому, что не учитывались изменения в DNS.

IHttpClientFactory же предоставляет следующие преимущества:

  • центральное место для именования и настройки экземпляров HttpClient;

  • возможность объединения обработчиков в цепочки;

  • использование DI в Middleware для исходящих запросов;

  • управление кэшированием и временем существования базовых HttpClientHandler экземпляров;

  • настройка параметров ведения журнала (через ILogger) для всех запросов, отправленных через клиентов, созданных фабрикой и т.д.

Чуть больше об этом и о способах использования IHttpClientFactory в приложениях можно почитать в статье от Microsoft.

Также нет необходимости диспоузить экземпляры HttpClient, созданные с помощью HttpClientFactory. Dispose ничего не сделает в этом случае, потому что фабрика управляет обработчиком и временем жизни соединения. Вызов Dispose в HttpClient не имеет никакого эффекта для экземпляров, предоставляемых фабрикой, и является избыточным кодом.

Внутреннее устройство IHttpClientFactory подробно рассмотрено в статье Эндрю Лока, но также можно обратиться к исходному коду самостоятельно. IHttpClientFactory показывает правильный способ управления HttpClient и HttpMessageHandler в приложении. Ознакомившись с кодом, становится понятно, почему никто так долго не мог сделать это управление полностью правильно — там много хитрых моментов.

Пара слов про альтернативные способы отправки HTTP-запросов

В мире .NET есть два популярных nuget-пакета для работы с HTTP. Это RestSharp и Flurl.Http.

RestSharp ранее использовал legacy HttpWebRequest (то есть был заточен под .NET Framework). Начиная с версии 107+, разработчики переделали все внутри, и теперь все хорошо, но могут быть проблемы, если мигрировать. RestShrap под капотом использует HttpClient и имеет больше аллокаций, поэтому по производительности использование RestSharp будет всегда уступать использованию HttpClient напрямую.

У Flurl.Http тоже все хорошо, но все по-прежнему происходит вручную, поэтому особых преимуществ не дает.

Оба пакета качественные и имеют своих поклонников.

Возможные вопросы по HttpClient

В заключении этой статьи хочу поделиться своим мнением по весьма частым вопросам, которые возникают у разработчиков, использующих HttpClient, и дать несколько рекомендаций.

Покрывает ли HttpClient все потребности разработчиков?

В общем случае — да, покрывает. Но если задачи специфические, то, вероятно, стоит посмотреть в сторону других протоколов, а не HTTP.

У HttpClient много разных методов для получения ответа. Есть GetStreamAsync, GetStringAsync, SendAsync и т.д. Когда какие методы лучше использовать?

Методов много, но их можно разделить на две категории: те, которые возвращают HttpResponseMessage, и те, которые не возвращают. В продакшене лучше всего получить HttpResponseMessage и работать с ним. Потому что в таком случае мы получаем больше контроля над тем, что вернулось. GetStreamAsync, GetStringAsync лучше использовать в консольных утилитах, если нужно сделать просто и быстро.

Почему не запечатали класс HttpClient, если теперь нужно использовать/создавать его через HttpClientFactory?

В этом нет необходимости. Его также удобно использовать для простых кейсов. Да и при должной конфигурации никто не запрещает использовать HttpClient. IHttpClientFactory — это один из способов. Мы можем реализовать свою фабрику, если захотим. А если мы пишем библиотеку, то можем оставлять точки для расширения и настройки клиента. Ну и для совместимости. Было бы не круто, если бы его запечатали.

HttpClient почти всегда используется с токеном. Какие есть best practices в этом плане?

Каких-то единых best practices нет. Можно создать Delegating Handler и встроить в пайплайн,
например. Но тут стоит смотреть по ситуации — какие требования, какой токен. В
простом случае можно добавлять дефолтные хедеры. А можно добавить сервис по
работе с токеном. Может, даже зарегистрировать его как отдельный HttpClient и хранить внутри логику с
работой токена.

Рекомендации

  • Не стоит использовать конструктор без параметров, потому что в таком случае мы даем платформе решать за нас, какой способ будет применяться. Можно использовать SocketsHttpHandler, а в библиотеках — HttpClientHandler.

  • Сторонние библиотеки не всегда лучший выбор. Особенно, если речь идет о производительности.

  • В современном .NET:

    • использовать статический HttpClient экземпляр (или синглтон) с нужным интервалом (PooledConnectionLifetime) в зависимости от ожидаемых изменений DNS.

    • С помощью IHttpClientFactory можно применять несколько разных клиентов, настроенных для разных сценариев использования. Нужно иметь в виду, что клиенты, созданные фабрикой, недолговечные: после создания клиента фабрика больше не имеет контроля над ними.

  • В .NET Framework использовать IHttpClientFactory.

 На этом у меня все. Оставляйте свои вопросы, замечания и советы в комментариях.