Як ви знаєте, коли ви переглядаєте вебсайти, ваш браузер відправляє HTTP запити.
Однак також можна вручну відправляти HTTP запити з одного сервера на інший, що в C# можна зробити за допомогою HttpClient
.
Це часто корисно в середовищі мікросервісів або при інтеграції з сторонніми додатками.
Якщо у вас є вебсервер, який реалізує RESTful-API, ви можете відправити запит GET
і розпарсити JSON відповідь всього в кілька рядків:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", async () =>
{
// магія відбувається тут
HttpClient client = new HttpClient();
GetQuotesResponse response = await client.GetFromJsonAsync("https://dummyjson.com/quotes");
return response;
});
app.Run();
Але, як завжди, все трохи складніше 😅.
У C# робота з HttpClient
вимагає розуміння того, як правильно його створювати, впроваджувати middleware, забезпечувати стійкість, обробляти повтори запитів, використовувати circuit-breaker, і оптимізувати виконання запитів.
Думаєте, що ви все знаєте? Прочитайте до кінця, і я доведу, що ви помиляєтесь 😉. Давайте зануримось у це!
Створення HTTP клієнта
Перед тим як почати використовувати HttpClient
, потрібно знати, як його правильно створювати. Є кілька варіантів, кожен з яких має свої переваги та недоліки:
- Конструктор
- Статичний екземпляр
IHttpClientFactory
- Іменовані клієнти
- Типізовані клієнти
- Згенеровані клієнти *
Розглянемо їх.
Конструктор
Найпростіший спосіб роботи з HttpClient
— це просто створити новий екземпляр і в кінці викликати .Dispose()
.
app.MapGet("/", () =>
{
HttpClient client = new HttpClient();
. . .
client.Dispose();
});
Однак, HttpClient
дещо особливий і не звільняється належним чином 🤪.
З кожним екземпляром HttpClient
створюється нове HTTP з'єднання. Але навіть коли клієнт буде знищений, TCP-сокет не звільняється одразу. Якщо ваш додаток постійно створює нові з'єднання, це може призвести до виснаження доступних портів.
Тому, HttpClient
слід створювати один раз на додаток, а не для кожного використання.
Статичний екземпляр
Трохи кращий підхід — створити один HttpClient
і використовувати його повторно.
static readonly HttpClient client = new HttpClient();
app.MapGet("/", async () =>
{
var response = await client.GetAsync("https://dummyjson.com/quotes");
. . .
});
Однак, все ще є проблема з змінами DNS.
HttpClient
здійснює резолюцію DNS записів тільки при створенні з'єднання.
Якщо записи DNS змінюються регулярно, клієнт не буде враховувати ці оновлення.
Ви можете встановити час життя з'єднання, щоб воно відтворювалося час від часу:
var handler = new SocketsHttpHandler
{
// перезавантажувати кожні 2 хвилини
PooledConnectionLifetime = TimeSpan.FromMinutes(2)
};
var sharedClient = new HttpClient(handler);
IHttpClientFactory
Хоча статичний екземпляр чудово працює, він часто важко піддається мокуванню в юніт-тестах 😒.
Рекомендується використовувати IHttpClientFactory
для створення екземпляра HttpClient
.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient(); // 👈 реєструємо фабрику
var app = builder.Build();
app.MapGet("/quotes", async ([FromServices] IHttpClientFactory factory) =>
{
var client = factory.CreateClient(); // 👈 створюємо клієнта
var response = await client.GetFromJsonAsync("https://dummyjson.com/quotes");
return response;
});
app.Run();
Це дозволяє управляти життєвим циклом HttpClient
, правильно повторно використовувати та звільняти TCP-сокети, обробляти зміни DNS і так далі.
Іменовані клієнти
Фабрика — це чудово, але коли клієнт використовується в кількох місцях, можна помітити дублювання коду. Така сама URL адреса, HTTP заголовки, авторизаційний токен і так далі можуть бути повторно використані між клієнтами.
Замість того, щоб налаштовувати їх для кожного клієнта, можна використовувати іменовані клієнти:
var builder = WebApplication.CreateBuilder(args);
builder
.Services
.AddHttpClient("DummyJson", configureClient => // 👈 налаштовуємо клієнта
{
configureClient.BaseAddress = new Uri("https://dummyjson.com");
configureClient.DefaultRequestHeaders.Add(HeaderNames.Accept, MediaTypeNames.Application.Json);
// configureClient.DefaultRequestHeaders.Accept.Add(MediaTypeNames.Application.Json);
configureClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "your-token-here");
});
var app = builder.Build();
app.MapGet("/", async ([FromServices] IHttpClientFactory factory) =>
{
var client = factory.CreateClient(name: "DummyJson"); // 👈 отримуємо клієнта за іменем
var response = await client.GetFromJsonAsync("quotes");
return response;
});
app.Run();
Зверніть увагу, що цього разу клієнт отримується за зареєстрованим іменем.
Типізовані клієнти
Є ще кілька вдосконалень, які ми хочемо зробити:
- замість того, щоб отримувати клієнтів за рядками, ми хочемо інжектувати їх як інші сервіси через DI
- ми хочемо інкапсулювати всю логіку в одному місці, замість того, щоб розкидувати її по різних частинах коду
- нам потрібно полегшити навігацію по коду
Це можна зробити за допомогою типізованого клієнта.
Це просто звичайний клас, який інжектує HttpClient
через свій конструктор:
class DummyJsonHttpClient
{
private readonly HttpClient _httpClient;
public DummyJsonHttpClient(HttpClient httpClient)
{
_httpClient = httpClient;
_httpClient.BaseAddress = new Uri("https://dummyjson.com");
}
public Task GetQuotes()
{
return _httpClient.GetFromJsonAsync("quotes");
}
}
Далі цей клас потрібно зареєструвати за допомогою .AddHttpClient()
:
var builder = WebApplication.CreateBuilder(args);
builder
.Services
.AddHttpClient(); // 👈 реєструємо клієнта
var app = builder.Build();
app.MapGet("/", async ([FromServices] DummyJsonHttpClient apiClient) => // 👈 отримуємо клієнта
{
var response = await apiClient.GetQuotes();
return response;
});
app.Run();
Зверніть увагу:
- ви можете написати додаткову логіку в типізованих клієнтах, наприклад, обробку виключень, трасування запитів tracing і так далі
- типізований клієнт може успадковувати інтерфейс, щоб полегшити мокування в юніт-тестах
interface IDummyJsonHttpClient
{
Task GetQuotes();
}
class DummyJsonHttpClient: IDummyJsonHttpClient { . . . } // 👈 успадкування
builder
.Services
.AddHttpClient(); // 👈 реєструємо клієнта
- ви можете налаштувати конфігурацію під час реєстрації клієнта в DI (що є більш бажаним варіантом)
builder
.Services
.AddHttpClient(x =>
{
x.BaseAddress = new Uri("https://dummyjson.com");
})
- типізовані клієнти також реєструються за іменем і можуть бути отримані тим самим способом
// отримуємо налаштований HttpClient з DI
HttpClient typedClient = clientFactory.CreateClient(nameof(DummyJsonHttpClient));
Це поверне HttpClient
.
- типізовані клієнти також можуть бути отримані за допомогою типізованої фабрики
app.MapGet("/", async (
[FromServices] IHttpClientFactory clientFactory,
[FromServices] ITypedHttpClientFactory typedHttpClientFactory) =>
{
// отримуємо налаштований HttpClient з DI
HttpClient httpClient = clientFactory.CreateClient(nameof(DummyJsonHttpClient));
// типізований клієнт
// викликається конструктор DummyJsonHttpClient
DummyJsonHttpClient typedClient = typedHttpClientFactory.CreateClient(httpClient);
}
Так ми можемо отримати DummyJsonHttpClient
, а не лише його підлягаючий HttpClient
.
- типізовані клієнти реєструються з транзієнтним життєвим циклом
- якщо ви інжектуєте транзієнтний типізований клієнт у сервіс синглтон, він стане синглтоном і не буде правильно звільняти ресурси. Тому в такому випадку слід використовувати фабрику
Генеровані клієнти *
Це не підтримується офіційно, але є одна крута бібліотека NuGet, про яку варто знати.
Це називається Refit
.
Цей підхід дозволяє вам оголосити інтерфейс
(interface), і він автоматично згенерує типізований HttpClient
.
interface IDummyJsonHttpClient
{
[Get("/quotes")]
Task GetQuotes();
}
Інтерфейс, звісно, потрібно зареєструвати перед використанням:
var builder = WebApplication.CreateBuilder(args);
builder
.Services
.AddRefitClient() // 👈 реєструємо клієнта
.ConfigureHttpClient(c => // 👈 додаємо додаткові налаштування
{
c.BaseAddress = new Uri("https://dummyjson.com");
});
var app = builder.Build();
app.MapGet("/", async ([FromServices] IDummyJsonHttpClient apiClient) =>
{
var response = await apiClient.GetQuotes();
return response;
});
app.Run();
Що я можу сказати? Це спрощує створення клієнта і виглядає круто 🙃.
Однак я бачу кілька проблем з цим NuGet:
- це додаткова залежність, яку ваша команда розробників повинна вивчити
- складніше додавати кастомну логіку до вашого HTTP клієнта
- зазвичай ви маєте пару абстракції та реалізації (
IDummyJsonApiClient
,DummyJsonHttpClient
). Якщо базовий шар з'єднання змінюється, достатньо оновити реалізацію, залишаючи всі місця використання абстракції без змін. З Refit ви маєте інтерфейс (IDummyJsonHttpClient
), але це не абстракція. Ви тісно пов'язані з HTTP-комунікацією - з архітектурної точки зору не зовсім зрозуміло, де має бути розташований
IDummyJsonHttpClient
. Чи є він частиною шару додатку чи інфраструктури?
Як завжди, остаточний вибір за вами. Проте я відчув, що мав це згадати 😬.
Проміжне програмне забезпечення (Middleware)
Типізовані клієнти є чудовими, оскільки вони дозволяють інкапсулювати обробку помилок, логування, кешування або будь-яку іншу кастомну логіку в одному, багаторазовому компоненті.
Проте є інша техніка для повторного використання перехресних стурбовань між різними HttpClient
та зробити їх акуратними.
Наприклад, для кожного HTTP виклику ви хочете отримати або оновити токен доступу та зафіксувати час, який був витрачений на виконання запиту.
Це можна зробити за допомогою декораторів. Таким чином, ви можете розширити всі методи додатковою функціональністю, що виконується до або після виклику оригінального методу.
Просто додайте відповідний DelegatingHandler
:
public class PerformanceAuditorDelegatingHandler : DelegatingHandler
{
protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Console.WriteLine($"Start {request.RequestUri} at {DateTime.UtcNow}");
// оригінальний виклик
var response = await base.SendAsync(request, cancellationToken);
Console.WriteLine($"Finish {request.RequestUri} at {DateTime.UtcNow}");
return response;
}
}
Це пізніше реєструється в DI та додається до пайплайну запитів HttpClient
:
builder
.Services
.AddTransient() // 👈
.AddHttpClient()
.AddHttpMessageHandler(); // 👈
До речі, вам не обов'язково потрібно збагачувати оригінальні виклики.
Ви можете просто заблокувати або переозначити всі запити, що часто буває корисним під час інтеграційних тестів.
public class RequestBlockingDelegatingHandler : DelegatingHandler
{
protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var response = new
{
Status = "Blocked",
};
return new HttpResponseMessage()
{
StatusCode = HttpStatusCode.OK,
Content = JsonContent.Create(response),
};
}
}
Тривалість життя Delegating Handler *
Ще один розділ з зірочкою. Можете пропустити його, оскільки він лише для професіоналів 😁.
Можливо, ви помітили, що обробник реєструється як transient, але він не буде створюватися заново для кожного запиту, що є незвичним. Ви можете чітко побачити це, якщо додасте стан, який буде зберігатися деякий час.
public class StatefulDelegatingHandler : DelegatingHandler
{
public int i = 0; // стан буде збережений
protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
i++;
return await base.SendAsync(request, cancellationToken);
}
}
Давайте спробуємо зрозуміти, що відбувається.
Кожен раз, коли запит здійснюється, він проходить через ланцюг декількох DelegatingHandlers
.
Було б неефективно створювати ланцюг знову при кожному виклику, тому обробники створюються, коли HttpClient
резольвується, і тимчасово зберігаються в кеші на 2 хвилини.
Ви можете змінити тривалість життя обробників за потреби:
builder
.Services
.AddTransient()
.AddHttpClient()
.AddHttpMessageHandler()
.SetHandlerLifetime(TimeSpan.FromSeconds(30)); // 👈
Це дозволяє transient обробнику жити трохи довше та зберігати стан. Чудово!
Тепер, деякі розробники можуть захотіти змінити життєвий цикл обробника на singleton
:
builder
.Services
.AddSingleton() // 👈
.AddHttpClient()
.AddHttpMessageHandler();
Це призведе до InvalidOperationException
. Не круто 😒.
Кожен обробник посилається на наступний через властивість InnerHandler
. Якщо DelegatingHandler
реєструється як singleton, властивість InnerHandler
залишатиметься встановленою, що викличе помилку при створенні ланцюга.
Підсумовуючи:
DelegatingHandler
повинен реєструватися як transient- хоча він і є transient, він буде кешуватися та використовуватися повторно протягом 2 хвилин
- ви можете мати стан у вашому обробнику, який буде зберігатися короткий час (але краще цього не робити, бо це може призвести до помилок)
- будьте уважні до цієї поведінки, коли ви інжектуєте сервіси з станом у обробник
- ви можете змінити тривалість життя обробників
- встановіть тривалість життя в
Timeout.InfiniteTimeSpan
, щоб вимкнути термін дії обробника - пулінг обробників є бажаним, оскільки кожен обробник зазвичай управляє своїми власними підключеннями HTTP
Зробити HttpClient стійким
Ви думали, що вивчити, як створити HttpClient
, це все, що вам потрібно для його використання? О, як же ви помиляєтесь 😌.
Мережна комунікація ненадійна, тому потрібно зробити ваш HttpClient
стійким.
Стійкість означає здатність програми продовжувати працювати правильно, навіть за умов виникнення помилок
Коли один сервіс викликає інший, ви можете зіткнутися з кількома проблемами:
- запит може зайняти занадто багато часу
- запит може не вдатися
- сервер може бути повільним або взагалі недоступним
- і так далі
Тому вам слід ознайомитися з:
- тайм-аутами запитів
- повторними спробами
- circuit-breaker
Існує ще одна популярна бібліотека NuGet, під назвою Polly
, яка дозволяє писати стійкий код в лаконічній манері:
var pipeline = new ResiliencePipelineBuilder()
.AddTimeout(TimeSpan.FromSeconds(10))
.Build();
await pipeline.ExecuteAsync(() =>
{
// ваш алгоритм тут
});
Microsoft побачив, наскільки це зручно, і вирішив використовувати Polly
в своїй власній бібліотеці NuGet (Microsoft.Extensions.Http.Resilient)
для додавання стійкості до HttpClient
.
Давайте побачимо це на практиці.
Тайм-аут запиту
Коли користувач натискає кнопку, він не турбується, чи виконуєте ви складні обчислення або викликаєте зовнішній сервіс. Користувач просто чекає на відповідь одразу.
Однак, коли зовнішній сервіс перевантажений і запит займає занадто багато часу, це спричинить повільність у вашому застосунку.
Тому вам слід встановити тайм-аут для того, скільки часу ви готові чекати на відповідь:
builder
.Services
.AddHttpClient()
.AddResilienceHandler("default", configure =>
{
configure.AddTimeout(TimeSpan.FromSeconds(2)); // 👈
});
Якщо запит займає більше часу, він буде перерваний і вважатиметься невдалим.
Повторні спроби
Ми можемо зіткнутися з іншою проблемою. Відповідь повертається в прийнятний інтервал, однак вона не вдалася через помилку.
В такому випадку варто спробувати надіслати той самий запит знову.
При використанні повторних спроб потрібно врахувати наступні фактори:
- які статус-коди слід повторювати
- кількість спроб
- інтервал між спробами
- ідемпотентність
Статус-коди для повтору
HTTP статус-код вказує на результат запиту, чи він був успішним, чи виникла помилка. Коди поділяються на п'ять категорій залежно від першої цифри:
І лише групи 4XX
та 5XX
вказують на помилку.
Важливо зрозуміти, які коди слід повторювати.
Якщо запит повернувся з помилкою клієнта (4XX
), це означає, що запит був некоректно сформований. Параметри неправильні, відсутній токен авторизації, не вдалася валідація і так далі. Повторення такого запиту, ймовірно, призведе до тієї ж помилки.
Тому не слід повторювати запити з групи 4XX
. Але є винятки:
408 Request Timeout
. Цей статус-код вказує, що сервер не зміг завершити запит в очікуваний час. Повторення запиту після розумної затримки може допомогти429 Too Many Requests
. Якщо ви отримали цей статус-код, це означає, що ви надсилаєте забагато запитів за визначений проміжок часу.
Повторні спроби після певного періоду можуть допомогти уникнути перевищення лімітів запитів.
З іншого боку, помилки сервера (5XX
), зазвичай означають тимчасові проблеми, які будуть вирішені при повторній спробі (окрім 503 Service Unavailable
😅).
На практиці вам не потрібно думати про те, які статус-коди потрібно повторювати, бо Microsoft вже реалізував це за вас 🙏:
builder
.Services
.AddHttpClient()
.AddResilienceHandler("default", configure =>
{
configure.AddTimeout(TimeSpan.FromSeconds(2));
configure.AddRetry(new HttpRetryStrategyOptions()); // 👈
});
Кількість повторних спроб
Зазвичай корисною практикою є встановлення обмеження на кількість спроб, щоб уникнути нескінченних циклів повторних спроб у разі постійних помилок.
builder
.Services
.AddHttpClient()
.AddResilienceHandler("default", configure =>
{
configure.AddTimeout(TimeSpan.FromSeconds(2));
configure.AddRetry(new HttpRetryStrategyOptions
{
MaxRetryAttempts = 3, // 👈
});
});
Звісно, якщо ви інтегруєтеся з стороннім сервісом, вам слід врахувати кількість запитів, які ви надсилаєте, ліміти запитів, які він накладає, і потенційний вплив на продуктивність та вартість.
Інтервал між повторними спробами
Ми визначили кількість повторних спроб. Тепер важливо вибрати затримку між ними.
Кожна повторна спроба може бути виконана після постійного тайм-ауту, скажімо, 20ms
.
Це можна легко налаштувати в нашій політиці повторних спроб:
builder
.Services
.AddHttpClient()
.AddResilienceHandler("default", configure =>
{
configure.AddTimeout(TimeSpan.FromSeconds(2));
configure.AddRetry(new HttpRetryStrategyOptions
{
MaxRetryAttempts = 3,
BackoffType = DelayBackoffType.Constant, // 👈
Delay = TimeSpan.FromMilliseconds(20), // 👈
});
});
Це просто, але не надто ефективно.
Якщо сервер повертає помилку, повторна спроба після короткої затримки може допомогти швидко отримати відповідь від сервера.
Однак, якщо друга спроба не вдалася, це зазвичай означає, що сервер перевантажений, і надсилання ще більше запитів лише погіршить ситуацію.
Отже, якщо клієнт “бачить”, що сервер недоступний, але йому терміново потрібна відповідь, краще почекати довше.
Рекомендується збільшувати затримку між кожною повторною спробою лінійно або експоненційно. Так, ви чекаєте 20ms
, потім 40ms
, потім 80ms
і так далі.
Давайте трохи налаштуємо нашу стратегію:
builder
.Services
.AddHttpClient()
.AddResilienceHandler("default", configure =>
{
configure.AddTimeout(TimeSpan.FromSeconds(2));
configure.AddRetry(new HttpRetryStrategyOptions
{
MaxRetryAttempts = 3,
BackoffType = DelayBackoffType.Linear, // 👈
Delay = TimeSpan.FromMilliseconds(20),
});
});
Хоча ця стратегія трохи краща, вона все одно має певні недоліки.
Уявіть таке: ваш сервер повністю відмовляється працювати. Багато HttpClient
починають повторювати свої запити, і ці спроби збігаються по часу, створюючи пікові навантаження на вже перевантажений сервер.
Це може потенційно призвести до самої атаки DDoS.
Вам потрібно додати випадкову затримку до кожної повторної спроби, що також відома як джіттер.
builder
.Services
.AddHttpClient()
.AddResilienceHandler("default", configure =>
{
configure.AddTimeout(TimeSpan.FromSeconds(2));
configure.AddRetry(new HttpRetryStrategyOptions
{
MaxRetryAttempts = 3,
BackoffType = DelayBackoffType.Linear,
Delay = TimeSpan.FromMilliseconds(20),
UseJitter = true, // 👈
});
});
Ідемпотентність
При реалізації повторних спроб слід пам'ятати, що деякі HTTP методи можуть мати побічні ефекти.
Згідно з REST, ви можете класифікувати будь-яку операцію як Create, Read, Update або Delete (CRUD).
Припустімо, що ми отримуємо 408 Request Timeout
від сервера. Це не означає, що запит не був оброблений. Це просто вказує на те, що ми більше не чекаємо відповіді.
Як очікується, наш клієнт виконає повторну спробу. Поведінка буде різною залежно від операції:
DELETE
— на першій спробі сервер видалить ресурс, а на другій спробі вже нічого не буде для видалення, тому повторна спроба не зашкодить серверуPUT
— зазвичай операція оновлення не є шкідливою. Наприклад, якщо ви надішлете ті самі дані кілька разів, сервер має перезаписати ресурс однаковими значеннямиGET
— не має значення, скільки разів ви отримуєте дані, оскільки запит на отримання є операцією тільки для читання, без побічних ефектівPOST
— повторна спроба запиту, відповідального за створення, може призвести до дубльованих записів 😖
Тому сервер повинен забезпечити ідемпотентність.
Ідемпотентність це властивість операції, яка гарантує, що виконання операції кілька разів дасть той самий результат, якби вона була виконана лише один раз
Інакше кажучи, якщо у вас є API-ендпойнт для оплати замовлення, і нетерплячі користувачі натискають 10 разів, буде знято тільки 1 платіж.
Ми не будемо обговорювати ідемпотентність тут. Це ще одна велика тема, яка заслуговує на окрему статтю 🙃. Просто пам'ятайте, що якщо ваш сервер не реалізує ідемпотентність (що часто буває з сторонніми додатками), повторні спроби можуть викликати більше проблем, ніж вирішити.
Circuit-Breaker
Отже, у нас є тайм-аут та повторні спроби. Але є ще одна відома проблема.
Припустимо, що наш сервер перевантажений запитами, і на відповідь потрібно час.
Звісно, у нас є тайм-аут, однак під час періоду очікування клієнт продовжує надсилати повторні запити і також вичерпує ресурси, такі як пам'ять, TCP-з'єднання, доступні потоки тощо.
З часом клієнт виснажить свої ресурси і також зазнає невдачі.
Іноді краще відмовити швидко, аніж намагатися надсилати запити, які алокують ресурси клієнта і все одно зазнають невдачі.
Це вирішується за допомогою патерну Circuit-Breaker. Ідея така:
- ми додаємо проксі, яка буде збирати статистику про кількість неуспішних запитів
- якщо ми на деякому етапі розуміємо, що сервер перестав відповідати, ми повинні перервати всі запити на деякий час. Це відомо як відкритий стан
- з часом наш проксі переходить у напіввідкритий стан
Це дозволяє пропустити деякі запити для перевірки, чи відновився сервер.
- якщо сервер починає відповідати з розумною швидкістю, ми можемо повернутися до закритого стану, коли між підключеннями немає перерви
Хороша новина полягає в тому, що нам не потрібно реалізовувати Circuit-Breaker з нуля або додавати будь-який проксі-сервіс. Як і раніше, все можна налаштувати за допомогою однієї конфігураційної строки 😁:
builder
.Services
.AddHttpClient()
.AddResilienceHandler("default", configure =>
{
configure.AddTimeout(TimeSpan.FromSeconds(2));
configure.AddRetry(new HttpRetryStrategyOptions
{
MaxRetryAttempts = 3,
BackoffType = DelayBackoffType.Constant,
Delay = TimeSpan.FromMilliseconds(500),
UseJitter = true,
});
configure
.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions()); // 👈
});
Ви можете тонко налаштувати параметри в залежності від ваших потреб, але я залишу все як є, бо я лінивий 🙃.
Завершення налаштувань стійкості
Як ви бачите, налаштування ланцюга стійкості є досить складним. Microsoft також знає, що це досить легко зробити неправильно 😅. Ви можете встановити політики в неправильному порядку, задати недійсні параметри і так далі. Тому вони дають нам метод розширення, який зареєструє стандартні політики:
builder
.Services
.AddHttpClient()
.AddStandardResilienceHandler(); // 👈
Вони виглядають ось так:
Але все одно можна налаштувати деякі параметри, якщо це необхідно:
builder
.Services
.AddHttpClient()
.AddStandardResilienceHandler()
.Configure(options =>
{
options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(5);
options.Retry.MaxRetryAttempts = 5;
options.CircuitBreaker.FailureRatio = 0.9;
options.CircuitBreaker.BreakDuration = TimeSpan.FromSeconds(5);
. . .
});
Надсилання HTTP-запиту
Нарешті, коли всі налаштування завершено, ми можемо надіслати HTTP-запити 😌.
Ці дні ви просто викликаєте відповідний метод (наприклад, .GetAsync()
, .PostAsync()
), вказуєте правильну URL-адресу, надаєте тіло, яке потрібно серіалізувати в JSON, і десеріалізуєте відповідь.
var request = new
{
Message = "Hello world",
};
var response = await client.PostAsJsonAsync("https://echo.free.beeceptor.com", request);
var result = await response.Content.ReadFromJsonAsync();
Раніше не завжди було так. Якщо ви використовуєте інший серіалізатор, наприклад Newtonsoft.Json
, або працюєте зі старим кодом, ви можете зустріти щось на кшталт цього.
var request = new
{
Message = "Hello world",
};
var serializedRequest = JsonSerializer.Serialize(request);
var stringContent = new StringContent(serializedRequest, Encoding.UTF8, MediaTypeNames.Application.Json);
var response = await _client.PostAsync("https://echo.free.beeceptor.com", stringContent);
var stringResponse = await response.Content.ReadAsStringAsync(); // 👈
var result = JsonSerializer.Deserialize(stringResponse);
У цьому прикладі ми:
- створюємо об'єкт запиту
- серіалізуємо його в JSON
- створюємо HTTP-тіло, використовуючи
StringContent
, вказуючи правильне кодування та тип медіа - надсилаємо HTTP-запит, де
HttpClient
чекає на всю відповідь і записує її в внутрішній буферMemoryStream
- потім вміст відповіді читається як
string
- в кінці ми десеріалізуємо цей
string
у типобезпечний об'єкт
Розробники часто роблять поширену помилку на кроці 5.
Вони читають усе тіло відповіді як string
, десеріалізують його в JSON, а потім більше не використовують цей string
. Це додає непотрібне навантаження на продуктивність та виділяє пам'ять для об'єкта, який зрештою потребуватиме очищення.
Ви можете читати та десеріалізувати безпосередньо з MemoryStream
:
. . .
var streamResponse = await response.Content.ReadAsStreamAsync();
var result = await JsonSerializer.DeserializeAsync(streamResponse);
Це менш зручно під час налагодження, оскільки ви не бачите отриману відповідь, але це більш ефективно з точки зору продуктивності.
Однак, MemoryStream
— це не справжній потік 🙃. Він утримує усе тіло відповіді. Це фактично byte[]
. Ми можемо продовжити покращення тут.
Серед методів .GetAsync()
, .PostAsync()
, є один універсальний метод .SendAsync()
.
public class HttpClient : IDisposable
{
. . .
public Task SendAsync(HttpRequestMessage request, HttpCompletionOption completionOption)
. . .
}
Це цікаво з наступних причин:
- ви можете надіслати запит будь-якого типу (наприклад,
GET
,POST
,DELETE
, …) - його другий параметр — це
HttpCompletionOption
HttpCompletionOption
— це просто enum з двома варіантами, що визначають поведінку відповіді:
public enum HttpCompletionOption
{
ResponseContentRead = 0,
ResponseHeadersRead = 1
}
ResponseContentRead
(за замовчуванням)— читає все вміст відповіді в буферResponseHeadersRead
— як тільки заголовки зчитуються, ви можете читати тіло відповіді частинами як потік 😌
var request = new
{
Message = "Hello world",
};
var jsonContent = JsonContent.Create(request); // 👈 зверніть увагу, JsonContent замість StringContent
var requestMessage = new HttpRequestMessage()
{
Method = HttpMethod.Post,
Content = jsonContent,
RequestUri = new Uri("https://echo.free.beeceptor.com"),
};
var response = await client.SendAsync(
requestMessage,
HttpCompletionOption.ResponseHeadersRead // 👈
);
var streamResponse = await response.Content.ReadAsStreamAsync(); // ChunkedEncodingReadStream замість MemoryStream 😌
var result = await JsonSerializer.DeserializeAsync(streamResponse);
Це означає, що ми будемо використовувати менше пам'яті та швидше обробляти дані, оскільки нам не потрібно зберігати весь вміст в буфері.
До речі, перед тим як споживати потік, ви все одно можете дослідити відповідь та її заголовки:
. . .
var response = await client.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
if ((int)response.StatusCode > 400)
{
// логіка обробки помилок
}
Останні слова
Як ви бачите, робота з таким простим, на перший погляд, HttpClient
вимагає високих навичок і знань 😤.
🐞 Коли ви створюєте новий екземпляр, пам'ятайте про проблеми виснаження портів та зміни DNS.
🏭 Краще використовувати типізовані клієнти, однак, якщо область ін'єкції залежностей не збігається з тимчасовим життєвим циклом HttpClient
, тоді краще використовувати фабрику або статичний клієнт.
🔗 Ви можете використовувати ланцюг проміжного програмного забезпечення (middleware), щоб обробляти кросс-функціональні проблеми, такі як журналювання, автентифікацію або повтори запитів.
🦾 Політики стійкості корисні, але вам слід запитати себе:
- що краще для мого додатка: помилитися швидко чи зробити повтор?
- чи можу я зробити повтор, чи це вдарить по ліміту API?
- які помилки та коди статусу слід повторювати?
- чи підтримує сервер ідемпотентність?
- чи спричинить повтор атаку DDoS?
- чи потрібен мені розмикання кола (circuit-breaker), чи це зробить мою систему більш нестабільною?
⚠️ Це особливо важливо при інтеграції з третіми сторонами, оскільки ви не маєте доступу до їхнього коду, і вони часто мають обмеження в цих питаннях.
⚡️ Уникайте читання тіла відповіді як string
для покращення продуктивності.
Якщо продуктивність є критичною, розгляньте використання потоків, але обов'язково зрозумійте, як вони працюють 😉.
Враховуйте всі ці фактори, щоб створити найкращий HttpClient
у світі 😁:
💬 Поділіться, чи стикалися ви з проблемою виснаження портів, або це лише жахливі історії, які старші розробники розповідають, щоб налякати молодших?
👏 Сподіваюся, що вам сподобалась ця стаття. Якщо так, плескайте, плескайте, плескайте
☕️ Ви можете пригостити мене кавою за допомогою посилання нижче
✅ І не забудьте підписатися, якщо хочете поглибити свої знання з C#
Перекладено з: HTTP Client in C#: Best Practices for Experts