Как правильно делать http-запросы в .NET Core приложениях

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Привет! Вова Вердыш на связи.

Почему я решил поговорить про http-запросы? Коммуникация между приложениями посредством http — это один из самых широко используемых способов сегодня. Поэтому очень хорошо бы понимать, как лучше выполнять эту коммуникацию на стороне приложения.

Для работы с http-запросами у нас в арсенале есть класс HttpClient. HttpClient появился ещё в .NET Framework каком-то. Он эволюционировал. Он пришел в .NET Core. Но таким, как мы его в .NET Core знаем сейчас, он тоже стал не сразу. Изначально он был устроен немного иначе. API по управлению http-соединениями тоже было немного другим. А таким, как мы его знаем сейчас, он стал, кажется, с версии .NET Core 2.1.

Начнем с простого. Выполнить запрос к главной странице Майкрософта можно так:

using var client = new HttpClient();
var result = await client.GetAsync("https://microsoft.com/");

Код ответа можно получить так:

Console.WriteLine(result.StatusCode);

И если нам надо достать тело ответа, то делаем так:

var content = await response.Content.ReadAsStringAsync();
Console.WriteLine(content);

Код рабочий. Но есть вопрос.

Нужно ли освобождать ресурсы за HttpClient

Если посмотреть в иерархию того, из чего состоит http-клиент, мы увидим, что он реализует интерфейс IDisposable. И это нам вроде как говорит о том, что после использования клиента мы должны освободить какие-то ресурсы, вызвав Dispose. И в большинстве случаев так действительно надо делать (вызывать Dispose у IDisposable объектов). Но в случае с http-клиентом не все так однозначно.

Внезапно оказывается, что в случае с http-клиентом это может привести к проблемам в некоторых случаях. В моей Windows максимальное количество открытых соединений сейчас может быть 65534. Узнать, сколько у вас (в случае с Windows), вы можете в ключе реестра HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\MaxUserPort. Рядом в том же разделе реестра лежит ещё один интересный ключ: TcpTimedWaitDelay. Если с данной конфигурацией вы у себя на компе откроете 65534 соединения и тут же их закроете, то в течение TcpTimedWaitDelay секунд после этого вы не сможете открыть новое подключение.

Вы можете сказать, что проблему я надумал. И действительно, наши бекенды для CRUDов на Angular никогда в жизни не столкнутся с этой бедой. Но тут важно обратить внимание на пару моментов:

  1. Если соединения будут исчерпаны, это коснется не только вашего приложения. Это коснется не только http-соединений. Это коснется любых соединений на машине. То есть ваше приложение может «захватить» соединения, оказывая таким образом воздействие на все другие приложения.
  2. Каждое соединение управляется операционной системой. То есть чем больше соединений, тем больше ресурсов на это будет тратиться.

В виде резюме по этому разделу я хотел бы сказать следующее: когда мы освобождаем ресурсы http-клиента, мы закрываем соединение и еще какое-то время оно не может быть использовано и у вас в системе на какое-то время стало минус одно потенциально доступное соединение.

На 100% я не уверен, но на 90% уверен, что в других операционных системах это работает похожим образом.

Что делать

Да что, просто добавим один разряд к MaxUserPort и уберем один в TcpTimedWaitDelay.

А что если:

public static HttpClient AppHttpClient = new HttpClient();

Что только что произошло: мы создали одного клиента и будем везде использовать его. Нормальный варик, как бы странно это не выглядело. Но здесь может возникнуть одна проблема. Если вы делаете запрос по имени и запрашиваемый вами домен переехал на другой IP-адрес, ваше приложение может продолжать слать запросы по старому адресу. Почему «может»? Типа иногда может расчехлиться и слать куда надо? Да, может быть и так. Как Бог даст? Не совсем.

Когда мы создаем клиента с помощью конструктора по умолчанию, мы получаем настройки по умолчанию. Интересующие нас сейчас настройки по умолчанию выглядят так:

  • PooledConnectionLifetime = бесконечно
  • PooledConnectionIdleTimeout = 2 минуты

Внутри у http-клиента есть некоторый пул соединений, который он использует. Правды ради стоит сказать, что это не внутри самого http-клиента сделано, но для простоты восприятия будем считать, что у него.

Когда мы делаем первый http-запрос к новой конечной точке (конечная точка — это адрес + порт), в пуле для этой конкретной конечной точки создается соединение. Если вы получили ответ за 3 секунды и ещё через 5 сделали запрос туда снова, этот запрос выполнится через соединение, которое находится в пуле. Если делаете запрос к новой конечной точке — для неё создастся новое соединение в пуле.

PooledConnectionIdleTimeout устанавливает порог времени, через которое удалять соединение из пула, если оно не пользуется. Другими словами, если мы сделали запрос, а следующий туда же сделали, скажем, через 3 минуты, то этот запрос будет выполнен уже через другое соединение.

PooledConnectionLifetime — через какое время в принципе удалять соединение из пула, независимо от того, когда оно использовалось в последний раз.

Вот та «одна проблема», о которой я упомянул выше, может проявляться в том случае, если вы будете делать запросы чаще, чем PooledConnectionIdleTimeout. Если вы будете делать запросы к каждой уникальной конечной точке реже, чем раз в 2 минуты, тогда:

Вы также не столкнетесь с этой «одной проблемой» в случае, если старый адрес перестанет принимать соединения. Условно говоря, на новом адресе сайт развернули, а сервер со старым адресом выключили.

Что такое IHttpClientFactory

А если запросы мне надо делать чаще, чем раз в 2 минуты? Или что же будет, если я хочу сделать приложение, устойчивое к потенциальному переезду сайта? Тогда да, у вас есть одна проблемка. Хорошая новость в том, что такая проблема не только у вас и её уже решили.

Для создания http-клиентов мы можем использовать специальную фабрику. Может возникнуть вопрос: нафига? В смысле нафига нам использовать фабрику вместо создания экземпляра вручную? Как минимум, создание клиентов через фабрику решает проблему с DNS, описанную выше.

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

var socketsHandler = new SocketsHttpHandler
{
	PooledConnectionLifetime = TimeSpan.FromMinutes(5),
	PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2)
};
var client = new HttpClient(socketsHandler);

Но фабрика хорошая, она управляет соединениями оптимальным образом и думать нам о них надо будет меньше. Чтобы фабрика стала доступна, нам надо её зарегистрировать:

services.AddHttpClient();

Теперь у нас в DI-контейнере есть объект типа IHttpClientFactory, с помощью которого мы можем работать с http-клиентами. Есть основные 4 стратегии создания клиентов: базовое использование, именованные клиенты, типизированные клиенты, сгенерированные клиенты.

Базовое использование

Базовое использование выглядит следующим образом:

public class Service2 : IService2
{
	private readonly IHttpClientFactory _httpClientFactory;
	public Service2(IHttpClientFactory httpClientFactory)
	{
		_httpClientFactory = httpClientFactory;
	}
	public async Task<bool> IsResourceAvailable(string url)
	{
		var client = _httpClientFactory.CreateClient();
		var response = await client.GetAsync(url);
		return response.StatusCode == System.Net.HttpStatusCode.OK;
	}
}

У нас есть сервис, мы в него впиндюриваем внедряем фабрику и через неё создаем клиента, когда нам надо выполнить http-запрос. В методе IsResourceAvailable может стрельнуть исключение и в рабочем приложении его лучше бы обработать. А я сейчас не буду. Ещё может кто-то скажет, что ресурс доступен не только в случае получения 200 кода. Ну да. Но на бизнес логику нам тоже сейчас пофиг, мы тут для другого собрались.

Этот способ удобно использовать, когда у вас в коде уже есть куча мест, где клиент создается через new HttpClient(). Вы просто меняете создание клиента непосредственно на создание клиента через фабрику. И у вас ничего не сломается.

Именованные клиенты

Предположим, у нас есть какой-то внешний сервис, куда мы должны передавать какой-то API ключ или что-то ещё в заголовке. Конечно, мы можем пользоваться способом выше, но тогда нам в каждом месте, где мы используем клиента, надо будет дописывать передачу определенного заголовка или любые другие настройки, если они должны быть отличными от настроек по умолчанию.

Но мы можем зарегистрировать клиента для такого сервиса один раз и потом использовать его без дополнительных телодвижений.

services.AddHttpClient("Microsoft", httpClient =>
{
	httpClient.BaseAddress = new Uri("https://microsoft.com/");
	httpClient.DefaultRequestHeaders.Add(HeaderNames.UserAgent, "ThisIsVova");
});

В коде выше помимо того, что мы указали секретный заголовок, мы ещё и задали базовый адрес, что теперь нам даст возможность указывать относительные адреса в запросах.

Метод проверки доступности теперь может выглядеть так:

public async Task<bool> IsResourceAvailable(string url)
{
	var client = _httpClientFactory.CreateClient("Microsoft");
	var response = await client.GetAsync(url);
	return response.StatusCode == System.Net.HttpStatusCode.OK;
}

Типизированные клиенты

Считается самой кошерной стратегией. В этом варианте использования в наш сервис надо внедрить уже не фабрику, а непосредственно клиента:

public class Service2 : IService2
{
	private readonly HttpClient _client;
	public Service2(HttpClient client)
	{
		_client = client;
		_client.BaseAddress = new Uri("https://microsoft.com/");
		_client.DefaultRequestHeaders.Add(HeaderNames.UserAgent, "ThisIsVova");
	}
	public async Task<bool> IsResourceAvailable(string url)
	{
		var response = await _client.GetAsync(url);
		return response.StatusCode == System.Net.HttpStatusCode.OK;
	}
}

Регистрируем клиента так:

services.AddHttpClient<Service2>();
или так:
services.AddHttpClient<IService2, Service2>();

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

Сгенерированные клиенты

Эту стратегию в фабрике я никогда не использовал. Если интересно — в интернете есть информация что это и как использовать.

Polly

Есть библиотечка одна, Polly зовется. Она позволяет без напряга использовать у себя в коде политики Retry, Circuit Breaker, Timeout, Bulkhead Isolation и Fallback. И делает она это настолько неплохо, что в Microsoft даже решили прикрутить её к фабрике. Для этого надо поставить пакет Microsoft.Extensions.Http.Polly.

Давайте, например, сделаем следующее. Если наш запрос не увенчался успехом, попробуем ещё несколько раз с паузой, которая будет равна количеству секунд, соответствующему номеру попытки. Мы даже можем особо не думать, что же такое «увенчался успехом», потому что ребята из Microsoft уже сделали метод, который обрабатывает 5ХХ коды, 408 код и сетевые проблемы:

services.AddHttpClient<IService2, Service2>()
	.AddPolicyHandler(
		HttpPolicyExtensions
		.HandleTransientHttpError()
		.WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(retryAttempt))
	)
;

В примере выше у нас может быть 4 запроса в случае каких-то проблем: 2-й будет через 1 секунду, 3-й через 2, 4-й через 3. Не здорово ли, а?

Тут ещё такой прикол, что для того, чтобы это сработало, клиента надо зарегистрировать именно <IService2, Service2>. Просто с <Service2> у меня эта политика не отработала. Не знаю баг это или фича. Если кто знает — отпишите в комментах.

Выводы

Основная мысль следующая: если вы начнете использовать в своем приложении фабрику вместо создания клиентов вручную, ваше приложение может заработать лучше, особенно если там активно используются http-запросы.

👍ПодобаєтьсяСподобалось5
До обраногоВ обраному3
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Как это кстати на ubuntu работает? Тоже коннекшены не закрываются сразу? А то на винде ни разу .net core не хостили:)

Будет тот же самый Socket Exhaustion

Странно почему не раскрыта тема flurl.

А что именно вам намекнуло на то, что она здесь должна быть раскрыта?

А кому нахрен упал голый

HttpClient

без вкусняшек ?

Не голый, он одетый в фабрику. Я знаю, это может быть не просто, но постарайтесь осилить и то, что дальше 3го абзаца написано. Если вам это удастся и у вас по прежнему будет актуален вопрос про кому что на хрен падает, напишите по существу, что flurl умеет лучше. Я с ним не работал, не знаю, но мне было бы интересно послушать.

В любом случае, ваша претензия выглядела бы обоснованно при условии, что в заголовке статьи было бы слово «flurl». А так я не знаю, что вам ответить.

а что там раскрывать, стринговый экстеншн с самопальной фабрикой и прикрученым сериализатором?

Статья копипаста мануала? docs.microsoft.com/...​t-resilient-http-requests

Сравните посимвольно, если на глаз разницы не заметно.

По сути ваша статья это пересказ мануала :)

Я стикнувся з проблемами клієнта на попередньому проекті. Прийшлось переписатист на фабрику з використанням Поллі

А можете описать, пожалуйста, более конкретно, какие проблемы были? Думаю, это было бы полезным дополнением к материалу в статье.

Та типова проблема, із-за низької надійності при навантаженні ресурсу (чи може якісь проблеми були в фаєрволах на обох сторонах, бо до певного моменту проблем взагалі не було), з яким потрібно було взаємодіяти, запити йшли, а на частину з них не було відповідей (іноді навіть більше половини), тому прийшлось переписати логіку відсилання запитів по тому принципу як розказано в статті, типу послали запит, 5 секунд немає відповіді, робим ретрай

Можно использовать PolicyRegistry для регистрации разных видов policy, которые затем применяются на именованные httpclients.
Вот довольно не новая статья, но принципы там верно описаны
nodogmablog.bryanhogan.net/...​ased-on-the-http-request

Добавлю, что IHttpClientFactory действительно неплохо справляется с созданием и утилизацией HttpClients

Или ну его и просто использовать Refit... ;)

У него есть какие-то преимущества или недостатки, если сравнивать с фабрикой?

Это ж тёплое с мягким)

Для рефита тоже часто нужно конфигурировать HttpClient через фабрику, как указано в статье

Підписатись на коментарі