Рефракторим HttpClient в типизированный HttpClient

Рефракторим HttpClient в типизированный HttpClient
Photo by S O C I A L . C U T / Unsplash

Достаточно часто приходиться писать приложения, которые взаимодействуют с внешними (REST) API по протоколу HTTP. В среде .NET есть разные способы реализации этого, в этой статье я расскажу, как я предпочитаю это делать.

Раньше в .NET framework вызывать HTTP-запросы и обрабатывать ответы было несколько сложнее, чем сейчас. Популярным решением было использование библиотеки RestSharp, которая скрывала многие сложности.

В dotnet (Core) использование веб-интерфейсов стало намного проще, и необходимость в использовании сторонних библиотек отпала. Хоть это и здорово, но все же есть несколько проблем, о которых необходимо знать.

Особенности использования HttpClient

Сейчас, если вы создаете dotnet-приложение, взаимодействующее с API, вы, скорее всего, используете класс HttpClient для выполнения HTTP-запросов.

В силу его простоты им очень легко злоупотреблять. Часто встречающаяся ошибка заключается в том, что разработчики напрямую создают новый экземпляр HttpClient для запуска запроса. Это потенциально может привести к проблемам, которые на первый взгляд могут быть неочевидны и трудно отлаживаемы. К примеру, в один прекрасный момент у вас могут закончиться порты, и вы не сможете совершить Http запрос.

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

var builder = WebApplication.CreateBuilder(args);
 
var app = builder.Build();
 
var starwarsGroup = app.MapGroup("starwars");
starwarsGroup.MapGet("people/{peopleId}", async (string peopleId) =>
{
    var httpClient = new HttpClient(); // Создали один HttpClient
    var people = await httpClient.GetFromJsonAsync<StarWarsPeople>($"https://swapi.dev/api/people/{peopleId}");
    return Results.Ok(people);
});
 
starwarsGroup.MapGet("species/{speciesId}", async (string speciesId) =>
{
    var httpClient = new HttpClient(); // Создали ещё один HttpClient
    var species = await httpClient.GetFromJsonAsync<StarWarsSpecies>($"https://swapi.dev/api/species/{speciesId}");
    return Results.Ok(species);
});
 
starwarsGroup.MapGet("planets/{planetId}", async (string planetId) =>
{
    var httpClient = new HttpClient(); // И ещё один HttpClient
    var planet = await httpClient.GetFromJsonAsync<StarWarsPlanet>($"https://swapi.dev/api/planets/{planetId}");
    return Results.Ok(planet);
});
 
app.Run();

Program.cs

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

Почему использование Singleton не панацея?

Первое решение, которое приходит в голову, - не создавать экземпляр HttpClient, а использовать один экземпляр (singleton) на все время работы приложения.

Это может показаться хорошим решением, но оно не лучшее, поскольку вам все равно придется самостоятельно управлять временем жизни клиента. В противном случае вы можете столкнуться с проблемами DNS, о которых говорится в документации по ссылке выше.

Singleton также не является оптимальным вариантом, если вашему приложению требуется несколько клиентов, например, для взаимодействия с различными API. В этом случае трудно настроить клиентов по-разному.

IHttpClientFactory

Более эффективным решением является использование фабрики IHttpClientFactory. IHttpClientFactory — это фабрика, которая создает и управляет экземплярами HttpClient. Таким образом, dotnet обрабатывает все детали за вас, и вы можете сосредоточиться на написании кода, не отвлекаясь на детали HttpClient. Но тут мы столкнёмся с выбором, какой из трёх вариантов создания HttpClient применить. Пройдемся по всем от хорошего к самому лучшему!

Рефракторим до IHttpClientFactory

Самый простой способ - использовать IHttpClientFactory непосредственно для создания HTTP-клиента, когда он нам нужен.

Для этого сначала зарегистрируем фабрику IHttpClientFactory в контейнере инъекции зависимостей с помощью метода AddHttpClient. Затем инжектируем фабрику туда, где нужен клиент, создаем клиента с помощью CreateClient и используем его для выполнения HTTP-запроса.

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient(); //Регистрируем фабрику
 
var app = builder.Build();
 
var starwarsGroup = app.MapGroup("starwars");
starwarsGroup.MapGet("people/{peopleId}", async (string peopleId, IHttpClientFactory httpClientFactory) =>
{
    var httpClient = httpClientFactory.CreateClient(); //Создаем клиента
    var people = await httpClient.GetFromJsonAsync<StarWarsPeople>($"https://swapi.dev/api/people/{peopleId}");
    return Results.Ok(people);
});
 
starwarsGroup.MapGet("species/{speciesId}",  async (string speciesId, IHttpClientFactory httpClientFactory) =>
{
    var httpClient = httpClientFactory.CreateClient(); //Создаем клиента
    var species = await httpClient.GetFromJsonAsync<StarWarsSpecies>($"https://swapi.dev/api/species/{speciesId}");
    return Results.Ok(species);
});
 
starwarsGroup.MapGet("planets/{planetId}",  async (string planetId, IHttpClientFactory httpClientFactory) =>
{
    var httpClient = httpClientFactory.CreateClient(); //Создаем клиента
    var planet = await httpClient.GetFromJsonAsync<StarWarsPlanet>($"https://swapi.dev/api/planets/{planetId}");
    return Results.Ok(planet);
});
 
app.Run();

Обновленный Program.cs

Это решение работает, и мы избавились от нескольких проблем, которые у нас были , но оно не идеально.

Основная проблема решена - нам больше не нужно думать об управлении временем жизни HttpClient. Но, взглянув на код, можно что он требует доработок.

На первый взгляд, здесь сразу видно дублирование с (пере)объявлением домена API в разных запросах (https://swapi.dev/).

Второе, что мне не нравится, это то, что у нас нет возможности настроить клиента. Более того, если приложению необходимо взаимодействовать с несколькими HTTP API, мы все равно используем один и тот же HTTP-клиент.

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

Именованные HttpClient`ы

Более удачным решением является так называемый именованный HTTP-клиент. Данное решение отличается от предыдущего только тем, что мы передаём в метод AddHttpClient строку с именем клиента. Далее, чтобы создать клиента, передайте то же имя в метод CreateClient.

В следующем примере создается и используется именованный клиент с именем "starwars".

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient("starwars");
 
var app = builder.Build();
 
var starwarsGroup = app.MapGroup("starwars");
starwarsGroup.MapGet("people/{peopleId}", async (string peopleId, IHttpClientFactory httpClientFactory) =>
{
    var httpClient = httpClientFactory.CreateClient("starwars");
    var people = await httpClient.GetFromJsonAsync<StarWarsPeople>($"https://swapi.dev/api/people/{peopleId}");
    return Results.Ok(people);
});
 
starwarsGroup.MapGet("species/{speciesId}",  async (string speciesId, IHttpClientFactory httpClientFactory) =>
{
    var httpClient = httpClientFactory.CreateClient("starwars");
    var species = await httpClient.GetFromJsonAsync<StarWarsSpecies>($"https://swapi.dev/api/species/{speciesId}");
    return Results.Ok(species);
});
 
starwarsGroup.MapGet("planets/{planetId}",  async (string planetId, IHttpClientFactory httpClientFactory) =>
{
    var httpClient = httpClientFactory.CreateClient("starwars");
    var planet = await httpClient.GetFromJsonAsync<StarWarsPlanet>($"https://swapi.dev/api/planets/{planetId}");
    return Results.Ok(planet);
});
 
app.Run();

Использование именованного HttpClient

Просто, не правда ли? :)

Настраиваем HttpClient

Чтобы сделать код еще лучше, мы можем сконфигурировать клиента в обратном вызове метода AddHttpClient, что даст нам доступ к экземпляру HttpClient. Преимущество заключается в том, что мы можем сконфигурировать клиента один раз и использовать его везде в нашем приложении.

Создание клиентов с помощью AddHttpClient также позволяет создавать несколько HTTP-клиентов с различной конфигурацией, каждый из которых предназначен для своих целей.

В приведенной ниже переработанной версии конфигурация домена выполняется один раз. Для этого необходимо установить свойство BaseAddress в callback методе. Помимо BaseAddress можно настроить и другие параметры клиента, например, включить в HTTP-запросы заголовки запросов.

В результате мы можем удалить повторные указания домена внутри потребителей.

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient("starwars", (client) => {
        // Задаём базовый адрес в одном месте
        client.BaseAddress = new Uri("https://swapi.dev/api/");
    });
 
var app = builder.Build();
 
var starwarsGroup = app.MapGroup("starwars");
starwarsGroup.MapGet("people/{peopleId}", async (string peopleId, IHttpClientFactory httpClientFactory) =>
{
    var httpClient = httpClientFactory.CreateClient("starwars");
    var people = await httpClient.GetFromJsonAsync<StarWarsPeople>($"people/{peopleId}");
    return Results.Ok(people);
});
 
starwarsGroup.MapGet("species/{speciesId}", async (string speciesId, IHttpClientFactory httpClientFactory) =>
{
    var httpClient = httpClientFactory.CreateClient("starwars");
    var species = await httpClient.GetFromJsonAsync<StarWarsSpecies>($"species/{speciesId}");
    return Results.Ok(species);
});
 
starwarsGroup.MapGet("planets/{planetId}", async (string planetId, IHttpClientFactory httpClientFactory) =>
{
    var httpClient = httpClientFactory.CreateClient("starwars");
    var planet = await httpClient.GetFromJsonAsync<StarWarsPlanet>($"planets/{planetId}");
    return Results.Ok(planet);
});
 
app.Run();

Мы можем не ограничиваться этим, можно добавить HttpMessageHandlers для настройки поведения HTTP-клиента с помощью DelegatingHandler.

Некоторые реализации таких обработчиков — это обработчики повторных запросов, добавляющие ограничитель скорости запросов (rate limiter), добавляющие слой кэширования или автоматический выключатель в HTTP-клиент. К счастью, нам не нужно писать все это вручную, а можно воспользоваться популярным пакетом Polly. Используя Polly, мы можем легко создавать отказоустойчивые HTTP-клиенты.

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient("starwars", (client) => {
        client.BaseAddress = new Uri("https://swapi.dev/api/");
    })
    .AddHttpMessageHandler<MyCustomHttpMessageHandler>() // Используем дополнительный обработчик
    .AddPolicyHandler(GetRetryPolicy()); // используем Polly обработчик

Мы также можем использовать DelegatingHandler для добавления данных к исходящим запросам, например, для добавления заголовков. Более подробную информацию об этом, включая практический пример включения заголовков аутентификации, можно найти в статье Джимми Богард (Securing Web APIs with Azure AD: Connecting External Clients).

Распределение заголовков.

Вместо того чтобы вручную добавлять заголовки в исходящие HTTP-запросы, мы можем автоматически передавать заголовки с помощью пакета Microsoft.AspNetCore.HeaderPropagation.

Так как мы создаем HTTP-клиентов, для каждого конкретного клиента можно настроить передачу заголовков. Это полезно, поскольку мы не хотим передавать заголовки всем клиентам, а только тем, которым это необходимо и которые принадлежат нам. В противном случае мы можем передать конфиденциальную информацию сторонним сервисам.

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient("starwars", (client) => {
        client.BaseAddress = new Uri("https://swapi.dev/api/");
    })
    .AddHeaderPropagation();

Более подробно можно ознакомиться в документации.

Типизированные HttpClient`ы

Наш код уже выглядит намного лучше, чем исходное решение, но мы все еще можем его улучшить. Последний шаг - рефакторинг именованного клиента в типизированный.

Причиной этого рефакторинга, как и ранее, является улучшение сопровождаемости кода. В данном случае мы хотим повторно использовать вызов и логику эндпоинта API. Кроме того, мы можем хранить ссылки на все места в приложении, где используется конечная точка.

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

Версия типизированного клиента добавляет слой абстракции поверх HTTP-клиента и может быть сравнима с еще одним сервисом. Мне нравится писать сервис так, чтобы каждая конечная точка была обернута в метод. Это удобно, поскольку в этом случае мы можем использовать функцию "найти все ссылки".

Чтобы использовать типизированный клиент, сначала оберните HTTP-клиент в класс. В конструкторе класс получает экземпляр HttpClient, который внедряется из DI-контейнера. Внутри конструктора мы можем сконфигурировать клиента.

В приведенном ниже примере StarWarsHttpClient выступает в качестве обертки для API "Звездных войн".

public class StarWarsHttpClient : IStarWarsService
{
    private readonly HttpClient _httpClient;
 
    public StarWarsHttpClient(HttpClient httpClient)
    {
        _httpClient = httpClient;
        _httpClient.BaseAddress = new Uri("https://swapi.dev/api/");
    }
 
    public async ValueTask<StarWarsPeople> GetPeople(string peopleId)
    {
        return await _httpClient.GetFromJsonAsync<StarWarsPeople>($"people/{peopleId}");
    }
 
    public async ValueTask<StarWarsPlanet> GetPlanet(string planetId)
    {
        return await _httpClient.GetFromJsonAsync<StarWarsPlanet>($"planets/{planetId}");
    }
 
    public async ValueTask<StarWarsSpecies> GetSpecies(string speciesId)
    {
        return await _httpClient.GetFromJsonAsync<StarWarsSpecies>($"species/{speciesId}");
    }
}
 
public interface IStarWarsService
{
    ValueTask<StarWarsPeople> GetPeople(string peopleId);
    ValueTask<StarWarsPlanet> GetPlanet(string planetId);
    ValueTask<StarWarsSpecies> GetSpecies(string speciesId);
}

Затем обновите метод AddHttpClient для использования типизированного клиента. Наконец, внедрите клиента в потребителя вместо использования IHttpClientFactory. Потребителю больше не нужно знать о конечных точках, и он может просто использовать методы типизированного клиента.

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient<IStarWarsService, StarWarsHttpClient>();
 
var app = builder.Build();
 
var starwarsGroup = app.MapGroup("starwars");
starwarsGroup.MapGet("people/{peopleId}", async (string peopleId, IStarWarsService starwarsService) =>
{
    var people = await starwarsService.GetPeople(peopleId);
    return Results.Ok(people);
});
 
starwarsGroup.MapGet("species/{speciesId}", async (string speciesId, IStarWarsService starwarsService) =>
{
    var species = await starwarsService.GetSpecies(speciesId);
    return Results.Ok(species);
});
 
starwarsGroup.MapGet("planets/{planetId}", async (string planetId, IStarWarsService starwarsService) =>
{
    var planet = await starwarsService.GetPlanet(planetId);
    return Results.Ok(planet);
});
 
app.Run();

Заключение.

Мы рассмотрели несколько примеров, давайте сравним исходное решение с окончательно переработанным.

var builder = WebApplication.CreateBuilder(args);
 
var app = builder.Build();
 
var starwarsGroup = app.MapGroup("starwars");
starwarsGroup.MapGet("people/{peopleId}", async (string peopleId) =>
{
    var httpClient = new HttpClient();
    var people = await httpClient.GetFromJsonAsync<StarWarsPeople>($"https://swapi.dev/api/people/{peopleId}");
    return Results.Ok(people);
});
 
starwarsGroup.MapGet("species/{speciesId}", async (string speciesId) =>
{
    var httpClient = new HttpClient();
    var species = await httpClient.GetFromJsonAsync<StarWarsSpecies>($"https://swapi.dev/api/species/{speciesId}");
    return Results.Ok(species);
});
 
starwarsGroup.MapGet("planets/{planetId}", async (string planetId) =>
{
    var httpClient = new HttpClient();
    var planet = await httpClient.GetFromJsonAsync<StarWarsPlanet>($"https://swapi.dev/api/planets/{planetId}");
    return Results.Ok(planet);
});
 
app.Run();

Был такой Program.cs

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient<IStarWarsService, StarWarsHttpClient>()
    .AddHttpMessageHandler<MyCustomHttpMessageHandler>() 
    .AddPolicyHandler(GetRetryPolicy());
 
var app = builder.Build();
 
var starwarsGroup = app.MapGroup("starwars");
starwarsGroup.MapGet("people/{peopleId}", async (string peopleId, IStarWarsService starwarsService) =>
{
    var people = await starwarsService.GetPeople(peopleId);
    return Results.Ok(people);
});
 
starwarsGroup.MapGet("species/{speciesId}", async (string speciesId, IStarWarsService starwarsService) =>
{
    var species = await starwarsService.GetSpecies(speciesId);
    return Results.Ok(species);
});
 
starwarsGroup.MapGet("planets/{planetId}", async (string planetId, IStarWarsService starwarsService) =>
{
    var planet = await starwarsService.GetPlanet(planetId);
    return Results.Ok(planet);
});
 
app.Run();

Стал такой Program.cs

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

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

Наконец, поведение клиента можно настраивать, добавляя собственные обработчики или используя сторонние инструменты, такие как Polly.

Помимо технических преимуществ рефакторинга, мы также решаем инфраструктурные проблемы. Поскольку время жизни клиента управляется фабрикой IHttpClientFactory, мы избегаем исчерпания портов и проблем с DNS.

Что еще почитать по теме?