В .NET приложениях часто приходится обращаться к внешним HTTP-сервисам. Для этого можно воспользоваться стандартным HttpClient, или какой-нибудь сторонней библиотекой. Мне приходилось сталкиваться с Refit и RestSharp. Но никогда мне не приходилось принимать решение о том, что именно применять. Всегда я уже приходил в проект, который использовал ту или иную библиотеку. И мне пришло в голову как-то сравнить эти библиотеки, чтобы в случае необходимости осмысленно принимать решение об их использовании. Этим я и займусь в данной статье.
Но как конкретно сравнивать эти библиотеки? Я нисколько не сомневаюсь в том, что все они способны совершать HTTP-запросы и получать ответы. В конце концов, вряд ли они стали бы настолько популярны, если бы не могли делать это. Меня больше интересуют дополнительные возможности, которые бывают весьма полезны в крупных корпоративных приложениях.
Давайте приступим.
Начальная настройка
В качестве сервиса, с которым будут общаться тестируемые библиотеки, будет выступать простенький Web API:
[ApiController]
[Route("[controller]")]
public class DataController : ControllerBase
{
[HttpGet("hello")]
public IActionResult GetHello()
{
return Ok("Hello");
}
}
Теперь создадим клиенты для этого сервиса с помощью наших 3-х библиотек.
Создадим интерфейс:
public interface IServiceClient
{
Task<string> GetHello();
}
Его реализация с помощью HttpClient выглядит следующим образом:
public class ServiceClient : IServiceClient
{
private readonly HttpClient _httpClient;
public ServiceClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<string> GetHello()
{
var response = await _httpClient.GetAsync("http://localhost:5001/data/hello");
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
}
Теперь нужно подготовить контейнер зависимостей:
var services = new ServiceCollection();
services.AddHttpClient<IServiceClient, ServiceClient>();
В случае RestSharp реализация принимает вид:
public class ServiceClient : IServiceClient
{
public async Task<string?> GetHello()
{
var client = new RestClient();
var request = new RestRequest("http://localhost:5001/data/hello");
return await client.GetAsync<string>(request);
}
}
Контейнер зависимостей для этого сервиса готовится так же просто:
var services = new ServiceCollection();
services.AddTransient<IServiceClient, ServiceClient>();
Для Refit нам нужно просто определить собственный интерфейс:
public interface IServiceClient
{
[Get("/data/hello")]
Task<string> GetHello();
}
Его регистрация имеет вид:
var services = new ServiceCollection();
services
.AddRefitClient<IServiceClient>()
.ConfigureHttpClient(c =>
{
c.BaseAddress = new Uri("http://localhost:5001");
});
После этого использование созданных нами интерфейсов не представляет никаких проблем.
Сравнение быстродействия
Для начала сопоставим быстродействие наших библиотек. С помощью Benchmark.Net сравним время выполнения простого Get-запроса с сервису. Вот какие результаты получаются:
Method |
Mean |
Error |
StdDev |
Min |
Max |
---|---|---|---|---|---|
HttpClient |
187.1 us |
4.31 us |
12.72 us |
127.0 us |
211.8 us |
Refit |
207.3 us |
4.47 us |
13.12 us |
138.4 us |
226.7 us |
RestSharp |
724.5 us |
14.36 us |
36.03 us |
657.6 us |
902.7 us |
Бросается в глаза, что использование RestSharp приводит к существенно большему времени выполнения запроса. Давайте разберёмся, в чём дело.
Вот наш код клиента для RestSharp:
public async Task<string?> GetHello()
{
var client = new RestClient();
var request = new RestRequest("http://localhost:5001/data/hello");
return await client.GetAsync<string>(request);
}
Как видите, на каждый запрос мы создаём новый объект RestClient
. А он внутри себя производит создание и инициализацию объекта HttpClient
. Именно на это и уходит время. Но RestSharp позволяет использовать и уже готовый HttpClient
. Давайте немного изменим наш код клиента:
public class ServiceClient : IServiceClient
{
private readonly HttpClient _httpClient;
public ServiceClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<string?> GetHello()
{
var client = new RestClient(_httpClient);
var request = new RestRequest("http://localhost:5001/data/hello");
return await client.GetAsync<string>(request);
}
}
и, соответственно, инициализации:
var services = new ServiceCollection();
services.AddHttpClient<IServiceClient, ServiceClient>();
Теперь результаты измерения производительности выглядят более ровно:
Method |
Mean |
Error |
StdDev |
Median |
Min |
Max |
---|---|---|---|---|---|---|
HttpClient |
190.2 us |
3.79 us |
10.61 us |
190.8 us |
163.1 us |
214.5 us |
Refit |
180.8 us |
12.20 us |
35.96 us |
205.2 us |
122.5 us |
229.3 us |
RestSharp |
242.8 us |
7.45 us |
21.73 us |
248.5 us |
160.4 us |
278.5 us |
Базовый адрес
Иногда требуется менять базовый адрес для запроса во время выполнения приложения. Например, наша система работает с несколькими торговыми серверами MT4. Имеется возможность подключать новые сервера и отключать старые прямо во время работы нашей программы. Поскольку все эти торговые сервера имеют одно и то же API, можно использовать один и тот же интерфейс для общения со всеми ними. Но они отличаются своими базовыми адресами. И адреса эти не известны в момент запуска нашей программы.
Для HttpClient и RestSharp это не представляет никакой проблемы. Вот соответствующий код для HttpClient:
public async Task<string> GetHelloFrom(string baseAddress)
{
var response = await _httpClient.GetAsync($"{baseAddress.TrimEnd('/')}/data/hello");
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
а вот для RestSharp:
public async Task<string?> GetHelloFrom(string baseAddress)
{
var client = new RestClient(_httpClient);
var request = new RestRequest($"{baseAddress.TrimEnd('/')}/data/hello");
return await client.GetAsync<string>(request);
}
А вот с Refit всё несколько сложнее. Базовый адрес мы задавали при конфигурации приложения:
services
.AddRefitClient<IServiceClient>()
.ConfigureHttpClient(c =>
{
c.BaseAddress = new Uri("http://localhost:5001");
});
Теперь мы так делать не можем. У нас есть только интерфейс, но не его реализация. К счастью Refit предоставляет нам возможность создавать экземпляры этого интерфейса самостоятельно, указывая при этом базовый адрес. Для этого создадим фабрику наших интерфейсов:
internal class RefitClientFactory
{
public T GetClientFor<T>(string baseUrl)
{
RefitSettings settings = new RefitSettings();
return RestService.For<T>(baseUrl, settings);
}
}
зарегистрируем её в нашем контейнере зависимостей:
services.AddScoped<RefitClientFactory>();
и в дальнейшем будем использовать, когда нам требуется явно указать базовый адрес:
var factory = provider.GetRequiredService<RefitClientFactory>();
var client = factory.GetClientFor<IServiceClient>("http://localhost:5001");
var response = await client.GetHello();
Общая обработка запросов
Действия, выполняемые при HTTP-запросах к внешним серверам, можно условно разделить на две группы. К первой группе относятся действия, зависящие от конкретного сервиса. Например, при обращении к ServiceA нужно выполнить одни действия, а при обращении к ServiceB - другие. В этом случае мы просто выносим эти действия в реализации конкретных интерфейсов клиентов этих сервисов: IServiceAClient
и IServiceBClient
. В случае HttpClient и RestSharp никаких проблем с этим нет. В случае Refit проблема связана с тем, что у нас нет непосредственной реализации нашего интерфейса. Но в этом случае можно воспользоваться обычным декоратором, предоставляемым, например, библиотекой Scrutor.
Ко второй группе относятся действия, которые хочется выполнять при каждом HTTP-запросе вне зависимости от того, к какому сервису он осуществляется. Сюда относится логирование ошибок, отслеживание времени выполнения запроса и т. п. Хотя мы можем так же вносить эту логику в реализацию каждого нашего интерфейса HTTP-клиента, но делать этого не хочется. Слишком много работы, слишком во многих местах придётся вносить изменения в случае чего, слишком легко забыть что-нибудь в случае создания нового клиента. Можно ли задать некий код, который будет выполняться при любом HTTP-запросе?
Оказывается можно. Можно добавить собственный обработчик запроса в цепочку стандартных обработчиков. Рассмотрим следующий пример. Пусть мы хотим логировать информацию о запросах. Для этого нужно создать класс, наследуемый от DelegatingHandler
:
public class LoggingHandler : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
try
{
AnsiConsole.MarkupLine($"[yellow]Sending {request.Method} request to {request.RequestUri}[/]");
return await base.SendAsync(request, cancellationToken);
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[yellow]{request.Method} request to {request.RequestUri} is failed: {ex.Message}[/]");
throw;
}
finally
{
AnsiConsole.MarkupLine($"[yellow]{request.Method} request to {request.RequestUri} is finished[/]");
}
}
protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken)
{
try
{
AnsiConsole.MarkupLine($"[yellow]Sending {request.Method} request to {request.RequestUri}[/]");
return base.Send(request, cancellationToken);
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[yellow]{request.Method} request to {request.RequestUri} is failed: {ex.Message}[/]");
throw;
}
finally
{
AnsiConsole.MarkupLine($"[yellow]{request.Method} request to {request.RequestUri} is finished[/]");
}
}
}
Добавить его в цепочку стандартных обработчиков запросов просто:
services.AddTransient<LoggingHandler>();
services.ConfigureAll<HttpClientFactoryOptions>(options =>
{
options.HttpMessageHandlerBuilderActions.Add(builder =>
{
builder.AdditionalHandlers.Add(builder.Services.GetRequiredService<LoggingHandler>());
});
});
После этого логирование будет автоматически осуществляться для всех вызовов через HttpClient
. Это же прекрасно работает и с RestSharp, поскольку мы используем его как обёртку над HttpClient
.
C Refit всё несколько сложнее. Описанный выше подход прекрасно работает и для Refit до тех пор, пока мы не начинаем использовать нашу фабрику для замены базового адреса. По-видимому, вызов RestService.For
не использует настройки HttpClient
, поэтому нам придётся здесь вручную добавлять наш обработчик запросов:
internal class RefitClientFactory
{
public T GetClientFor<T>(string baseUrl)
{
RefitSettings settings = new RefitSettings();
settings.HttpMessageHandlerFactory = () => new LoggingHandler
{
InnerHandler = new HttpClientHandler()
};
return RestService.For<T>(baseUrl, settings);
}
}
Отмена запросов
Иногда запрос нужно отменить. Например, пользователь устал ждать получения результатов запроса и ушёл со страницы. Теперь результаты запроса никому не нужны, и следует отменить выполняющиеся запросы. Как это сделать?
ASP.NET Core предоставляет возможность узнать о том, что клиент отменил запрос к серверу, через CancellationToken
. Естественно, полезно было бы, чтобы и наши библиотеки поддерживали работу с экземпляром этого класса.
С HttpClient всё в порядке:
public async Task<string> GetLong(CancellationToken cancellationToken)
{
var response = await _httpClient.GetAsync("http://localhost:5001/data/long", cancellationToken);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
Здесь CancellationToken
поддерживается из коробки. С RestSharp то же всё в порядке:
public async Task<string?> GetLong(CancellationToken cancellationToken)
{
var client = new RestClient(_httpClient);
var request = new RestRequest("http://localhost:5001/data/long") { };
return await client.GetAsync<string>(request, cancellationToken);
}
Refit так же нативно поддерживает CancellationToken
:
public interface IServiceClient
{
[Get("/data/long")]
Task<string> GetLong(CancellationToken cancellationToken);
...
}
Как видите, с отменой запросов никаких проблем не возникает.
Максимальное время ожидания ответа
Кроме непосредственной возможности отменить запрос, хотелось бы так же быть способным ограничить максимальное время его выполнения. Здесь ситуация в определённом смысле противоположна той, которую мы имели для общей обработки запросов. В общих настройках легко задать максимальное время ожидания ответа для любых запросов. Но на самом деле более полезно иметь возможность настраивать это время для конкретного запроса. Ведь даже в пределах одного сервиса запросы на разные конечные точки (endpoint) приводят к проработке разного количества информации, т. е. к разному времени выполнения запроса. Именно по этой причине лучше иметь возможность задавать время ожидания дискретно.
У RestSharp с этим всё в порядке:
public async Task<string?> GetLongWithTimeout(TimeSpan timeout, CancellationToken cancellationToken = default)
{
try
{
var client = new RestClient(_httpClient, new RestClientOptions { MaxTimeout = (int)timeout.TotalMilliseconds });
var request = new RestRequest("http://localhost:5001/data/long");
return await client.GetAsync<string>(request, cancellationToken);
}
catch (TimeoutException)
{
return "Timeout";
}
}
С HttpClient уже возникают некоторые проблемы. С одной стороны у класса HttpClient
есть свойство Timeout
, которым вроде бы можно воспользоваться. Но здесь возникает ряд сомнений. Во-первых, экземпляр HttpClient
используется в разных методах класса, реализующего наш интерфейс Http-клиента. В каждом из них время ожидания может быть разным. Легко упустить что-то, и установленное в одном методе время ожидания пролезет в другой метод. В принципе эту проблему можно обойти, написав обёртку, которая будет в начале каждого метода устанавливать время ожидания, а в конце возвращать его к тому значению, которое было до этого. Если клиент не используется в многопоточном режиме, этот подход будет работать.
Но кроме того, лично у меня есть некая неясность, как именно используются экземпляры HttpClient
, которые выдаёт нам контейнер зависимостей. Согласно имеющейся документации, создавать новые экземпляры HttpClient
каждый раз, когда нам нужно сделать HTTP-запрос - плохая идея. Система внутри себя поддерживает переиспользуемый пул соединений, следит за разными вещами, в общем происходит много неочевидной магии. Отсюда у меня возникает опасение, а не может ли так случиться, что один и тот же экземпляр HttpClient
будет со временем передан разным сервисам. И время ожидания, установленное в одном из них, перетечёт таким образом в другой. Мне не удалось воспроизвести эту ситуацию, но, возможно, я чего-то просто не учёл.
Говоря кратко, хотелось бы быть уверенным, что моё время ожидания будет относиться только к одному конкретному запросу. И в принципе этого можно добиться через использование того же CancellationToken
:
public async Task<string> GetLongWithTimeout(TimeSpan timeout, CancellationToken cancellationToken = default)
{
try
{
using var tokenSource = new CancellationTokenSource(timeout);
using var registration = cancellationToken.Register(tokenSource.Cancel);
var response = await _httpClient.GetAsync("http://localhost:5001/data/long", tokenSource.Token);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
catch (TaskCanceledException)
{
return "Timeout";
}
}
Эта же методика подходит и для использования с Refit:
var client = provider.GetRequiredService<IServiceClient>();
using var cancellationTokenSource = new CancellationTokenSource();
try
{
var response = await Helper.WithTimeout(
TimeSpan.FromSeconds(5),
cancellationTokenSource.Token,
client.GetLong);
Console.WriteLine(response);
}
catch (TaskCanceledException)
{
Console.WriteLine("Timeout");
}
Здесь класс Helper
имеет вид:
internal class Helper
{
public static async Task<T> WithTimeout<T>(TimeSpan timeout, CancellationToken cancellationToken, Func<CancellationToken, Task<T>> action)
{
using var cancellationTokenSource = new CancellationTokenSource(timeout);
using var registration = cancellationToken.Register(cancellationTokenSource.Cancel);
return await action(cancellationTokenSource.Token);
}
}
В данном случае проблема заключается в том, что нам уже не достаточно самого Refit-интерфейса. Нужно писать обёртку для метода, который нам нужно вызывать с определённым временем ожидания.
Поддержка Polly
Сегодня Polly фактически является стандартным дополнением для реализации HTTP-запросов из корпоративных приложений. Давайте посмотрим, как эта библиотека уживается с HttpClient, RestSharp и Refit.
Здесь, как и в случае с обработкой запросов, могут возникнуть несколько вариантов. Во-первых, политика Polly может отличаться для вызовов различных методов нашего клиентского интерфейса. В этом случае её можно прописывать прямо внутри реализации конкретных методов, а для Refit - через декоратор.
Во-вторых, мы можем хотеть задать политику для всех методов одного клиентского интерфейса. Как нам это сделать?
Для HttpClient всё достаточно просто. Вы создаёте нужную политику:
var policy = HttpPolicyExtensions
.HandleTransientHttpError()
.OrResult(response => (int)response.StatusCode == 418)
.RetryAsync(3, (_, retry) =>
{
AnsiConsole.MarkupLine($"[fuchsia]Retry number {retry}[/]");
});
и назначаете её для указанного интерфейса:
services.AddHttpClient<IServiceClient, ServiceClient>()
.AddPolicyHandler(policy);
Для RestSharp, который использует HttpClient
из контейнера зависимостей, никакой разницы нет.
Refit так же предоставляет простую поддержку такого сценария:
services
.AddRefitClient<IServiceClient>()
.ConfigureHttpClient(c =>
{
c.BaseAddress = new Uri("http://localhost:5001");
})
.AddPolicyHandler(policy);
Интересно рассмотреть так же следующий вопрос. А что если у нас есть клиентский интерфейс, почти все методы которого должны использовать одну политику Polly, а один - совершенно другую? Здесь, по-видимому, нужно смотреть в сторону реестра политик (policy registry) и селектора политик (policy selector). Вот в этой статье описано, как выбирать политику на основе того, какой именно запрос вы делаете.
Переотправка запроса
С использованием политик Polly связана ещё одна интересная тема. Иногда требуется более сложная подготовка сообщения к отправке. Например, может потребоваться сформировать специфические заголовки. Для этого у HttpClient
есть обобщённый метод Send
, принимающий параметр типа HttpRequestMessage
.
Однако, во время отправки сообщения могут возникнуть различного рода проблемы. Часть из них можно решить повторной отправкой сообщения, например, с помощью тех же политик Polly. Но можно ли передать тот же объект HttpRequestMessage
методу Send
ещё раз?
Чтобы проверить это я создам на моём сервере конечную точку, которая будет случайным образом возвращать результат:
[HttpGet("rnd")]
public IActionResult GetRandom()
{
if (Random.Shared.Next(0, 2) == 0)
{
return StatusCode(500);
}
return Ok();
}
Давайте посмотрим на метод клиента, который общается с этой конечной точкой. Я не буду непосредственно задействовать Polly, просто выполню запрос несколько раз:
public async Task<IReadOnlyList<int>> GetRandom()
{
var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost:5001/data/rnd");
var returnCodes = new LinkedList<int>();
for (int i = 0; i < 10; i++)
{
var response = await _httpClient.SendAsync(request);
returnCodes.AddLast((int)response.StatusCode);
}
return returnCodes.ToArray();
}
Как видите, я пытаюсь несколько раз послать один и тот же объект HttpRequestMessage
. И что же?
Unhandled exception. System.InvalidOperationException: The request message was already sent. Cannot send the same request message multiple times.
Т. е. если мне нужны повторы, мне придётся каждый раз формировать новый HttpRequestMessage
.
Теперь рассмотрим RestSharp. Вот повторяющий запрос метод, написанный с его помощью:
public async Task<IReadOnlyList<int>> GetRandom()
{
var client = new RestClient(_httpClient);
var request = new RestRequest("http://localhost:5001/data/rnd");
var returnCodes = new LinkedList<int>();
for (int i = 0; i < 10; i++)
{
var response = await client.ExecuteAsync(request);
returnCodes.AddLast((int)response.StatusCode);
}
return returnCodes.ToArray();
}
Здесь в качестве HttpRequestMessage
используется объект RestRequest
. И на этот раз всё в порядке. RestSharp не возражает против использования одного и того же экземпляра RestRequest
несколько раз.
К Refit эта проблема не применима. Там нет, насколько мне известно, какого-либо аналога "объекта запроса". Все параметры передаются каждый раз через аргументы метода интерфейса Refit.
Итоги
Пришло время подвести некоторый итог. Лично с моей точки зрения RestSharp оказывается на первом месте, хотя его отличие от чистого HttpClient минимально. RestSharp сам использует объекты HttpClient
, поэтому имеет доступ ко всем возможностям их конфигурации. Только несколько лучшая поддержка указания времени ожидания и способность переиспользовать объекты запроса выводят его на первое место. Хотя запросы RestSharp несколько медленнее, для кого-то это может быть критично.
Refit же несколько отстаёт в моих глазах. С одной стороны, он выглядит привлекательно, минимизируя количество необходимого для создания клиента кода. С другой стороны, реализация определённых сценариев работы с клиентами Refit требует слишком больших, на мой взгляд, усилий.
Надеюсь, это сравнение было для вас полезным. Напишите в комментариях, каков ваш опыт использования этих библиотек. А может быть вы предпочитаете что-нибудь иное для взаимодействия по HTTP?
Удачи!
P.S. Код для этой статьи может быть найден на GitHub.
Комментарии (3)
hVostt
03.11.2023 07:40+1Что Refit, что RestSharp -- обёртки над HttpClient. Refit даёт возможность писать простые клиенты декларативно. RestSharp особо ничего не даёт кроме дополнительного ненужного обвеса и лишних объектов в памяти. Всё что нужно, обычно, это использовать существующие методы расширений для HttpClient-а, либо написать несколько своих.
HTTP-запрос это не простой вызов метода, всё несколько сложнее. Во-первых, градация ответов это чуть больше, чем успех/неуспех. Если всё делать по феншую, сервер в случае ошибок должен возвращать ответ типа Problem Details, который нужно в отдельных случаях извлекать и обрабатывать. Polly на ретраях, разумеется. Во-вторых, ещё есть ответы, которые не являются ошибкой, но и классическим успешным ответом тоже не являются, это ответы 3xx, кеширование. В-третьих, передача файлов, потоковая передача данных, это отдельная история, которую нужно специальным образом обрабатывать.
Отсюда, и RestSharp и Refit являются лишними на празднике жизни. Пока есть несколько простых методов, абсолютно чёрно-белых, да/нет, успех/ошибка, а если ошибка то вообще пофиг, валимся с исключением с разбирательством только по логам, Refit ещё как-то вывозит. Но чуть шаг в сторону и начинаются лютые приседания. В общем, я бы настоятельно не рекомендовал использовать Refit, это такая мягкая подстилка над ямой, в которой торчат колья.
Peter1010
По Timeout в HttpClient, если совсем грубо и просто,
то всё просто он по факту идёт через SocketsHttpHandler и он уникален для каждого запроса в рамках одного httpклиента.