Как стоит и как не стоит использовать HttpClient в .NET
Всем привет! Меня зовут Андрей Федотов, я бэкенд-разработчик в одной из команд платформы интернета вещей 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 можно (и нужно) отправлять больше, чем просто один запрос.
Разумеется, что такого кода, что я привел в примерах, у нас больше не осталось. Мы провели глобальный рефакторинг, в рамках которого было сделано следующее:
Все методы расширения над HttpClient, которые были разбросаны по разным местам в проекте, мы удалили. Вместо них мы используем типизированные клиенты под каждый сторонний сервис.
Ответственность за создание экземпляров клиентов мы переложили на IHttpClientFactory.
Перешли на формирование строк запроса с помощью 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-запроса и передается в сокет:
Ответ принимается через сокет и преобразуется в конечный объект 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.
На этом у меня все. Оставляйте свои вопросы, замечания и советы в комментариях.