[Перевод] Сравнение HTTP-библиотек
В .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 GetHello();
}
Его реализация с помощью HttpClient выглядит следующим образом:
public class ServiceClient : IServiceClient
{
private readonly HttpClient _httpClient;
public ServiceClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task GetHello()
{
var response = await _httpClient.GetAsync("http://localhost:5001/data/hello");
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
}
Теперь нужно подготовить контейнер зависимостей:
var services = new ServiceCollection();
services.AddHttpClient();
В случае RestSharp реализация принимает вид:
public class ServiceClient : IServiceClient
{
public async Task GetHello()
{
var client = new RestClient();
var request = new RestRequest("http://localhost:5001/data/hello");
return await client.GetAsync(request);
}
}
Контейнер зависимостей для этого сервиса готовится так же просто:
var services = new ServiceCollection();
services.AddTransient();
Для Refit нам нужно просто определить собственный интерфейс:
public interface IServiceClient
{
[Get("/data/hello")]
Task GetHello();
}
Его регистрация имеет вид:
var services = new ServiceCollection();
services
.AddRefitClient()
.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 GetHello()
{
var client = new RestClient();
var request = new RestRequest("http://localhost:5001/data/hello");
return await client.GetAsync(request);
}
Как видите, на каждый запрос мы создаём новый объект RestClient
. А он внутри себя производит создание и инициализацию объекта HttpClient
. Именно на это и уходит время. Но RestSharp позволяет использовать и уже готовый HttpClient
. Давайте немного изменим наш код клиента:
public class ServiceClient : IServiceClient
{
private readonly HttpClient _httpClient;
public ServiceClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task GetHello()
{
var client = new RestClient(_httpClient);
var request = new RestRequest("http://localhost:5001/data/hello");
return await client.GetAsync(request);
}
}
и, соответственно, инициализации:
var services = new ServiceCollection();
services.AddHttpClient();
Теперь результаты измерения производительности выглядят более ровно:
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 GetHelloFrom(string baseAddress)
{
var response = await _httpClient.GetAsync($"{baseAddress.TrimEnd('/')}/data/hello");
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
а вот для RestSharp:
public async Task GetHelloFrom(string baseAddress)
{
var client = new RestClient(_httpClient);
var request = new RestRequest($"{baseAddress.TrimEnd('/')}/data/hello");
return await client.GetAsync(request);
}
А вот с Refit всё несколько сложнее. Базовый адрес мы задавали при конфигурации приложения:
services
.AddRefitClient()
.ConfigureHttpClient(c =>
{
c.BaseAddress = new Uri("http://localhost:5001");
});
Теперь мы так делать не можем. У нас есть только интерфейс, но не его реализация. К счастью Refit предоставляет нам возможность создавать экземпляры этого интерфейса самостоятельно, указывая при этом базовый адрес. Для этого создадим фабрику наших интерфейсов:
internal class RefitClientFactory
{
public T GetClientFor(string baseUrl)
{
RefitSettings settings = new RefitSettings();
return RestService.For(baseUrl, settings);
}
}
зарегистрируем её в нашем контейнере зависимостей:
services.AddScoped();
и в дальнейшем будем использовать, когда нам требуется явно указать базовый адрес:
var factory = provider.GetRequiredService();
var client = factory.GetClientFor("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 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();
services.ConfigureAll(options =>
{
options.HttpMessageHandlerBuilderActions.Add(builder =>
{
builder.AdditionalHandlers.Add(builder.Services.GetRequiredService());
});
});
После этого логирование будет автоматически осуществляться для всех вызовов через HttpClient
. Это же прекрасно работает и с RestSharp, поскольку мы используем его как обёртку над HttpClient
.
C Refit всё несколько сложнее. Описанный выше подход прекрасно работает и для Refit до тех пор, пока мы не начинаем использовать нашу фабрику для замены базового адреса. По-видимому, вызов RestService.For
не использует настройки HttpClient
, поэтому нам придётся здесь вручную добавлять наш обработчик запросов:
internal class RefitClientFactory
{
public T GetClientFor(string baseUrl)
{
RefitSettings settings = new RefitSettings();
settings.HttpMessageHandlerFactory = () => new LoggingHandler
{
InnerHandler = new HttpClientHandler()
};
return RestService.For(baseUrl, settings);
}
}
Отмена запросов
Иногда запрос нужно отменить. Например, пользователь устал ждать получения результатов запроса и ушёл со страницы. Теперь результаты запроса никому не нужны, и следует отменить выполняющиеся запросы. Как это сделать?
ASP.NET Core предоставляет возможность узнать о том, что клиент отменил запрос к серверу, через CancellationToken
. Естественно, полезно было бы, чтобы и наши библиотеки поддерживали работу с экземпляром этого класса.
С HttpClient всё в порядке:
public async Task 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 GetLong(CancellationToken cancellationToken)
{
var client = new RestClient(_httpClient);
var request = new RestRequest("http://localhost:5001/data/long") { };
return await client.GetAsync(request, cancellationToken);
}
Refit так же нативно поддерживает CancellationToken
:
public interface IServiceClient
{
[Get("/data/long")]
Task GetLong(CancellationToken cancellationToken);
...
}
Как видите, с отменой запросов никаких проблем не возникает.
Максимальное время ожидания ответа
Кроме непосредственной возможности отменить запрос, хотелось бы так же быть способным ограничить максимальное время его выполнения. Здесь ситуация в определённом смысле противоположна той, которую мы имели для общей обработки запросов. В общих настройках легко задать максимальное время ожидания ответа для любых запросов. Но на самом деле более полезно иметь возможность настраивать это время для конкретного запроса. Ведь даже в пределах одного сервиса запросы на разные конечные точки (endpoint) приводят к проработке разного количества информации, т. е. к разному времени выполнения запроса. Именно по этой причине лучше иметь возможность задавать время ожидания дискретно.
У RestSharp с этим всё в порядке:
public async Task 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(request, cancellationToken);
}
catch (TimeoutException)
{
return "Timeout";
}
}
С HttpClient уже возникают некоторые проблемы. С одной стороны у класса HttpClient
есть свойство Timeout
, которым вроде бы можно воспользоваться. Но здесь возникает ряд сомнений. Во-первых, экземпляр HttpClient
используется в разных методах класса, реализующего наш интерфейс Http-клиента. В каждом из них время ожидания может быть разным. Легко упустить что-то, и установленное в одном методе время ожидания пролезет в другой метод. В принципе эту проблему можно обойти, написав обёртку, которая будет в начале каждого метода устанавливать время ожидания, а в конце возвращать его к тому значению, которое было до этого. Если клиент не используется в многопоточном режиме, этот подход будет работать.
Но кроме того, лично у меня есть некая неясность, как именно используются экземпляры HttpClient
, которые выдаёт нам контейнер зависимостей. Согласно имеющейся документации, создавать новые экземпляры HttpClient
каждый раз, когда нам нужно сделать HTTP-запрос — плохая идея. Система внутри себя поддерживает переиспользуемый пул соединений, следит за разными вещами, в общем происходит много неочевидной магии. Отсюда у меня возникает опасение, а не может ли так случиться, что один и тот же экземпляр HttpClient
будет со временем передан разным сервисам. И время ожидания, установленное в одном из них, перетечёт таким образом в другой. Мне не удалось воспроизвести эту ситуацию, но, возможно, я чего-то просто не учёл.
Говоря кратко, хотелось бы быть уверенным, что моё время ожидания будет относиться только к одному конкретному запросу. И в принципе этого можно добиться через использование того же CancellationToken
:
public async Task 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();
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 WithTimeout(TimeSpan timeout, CancellationToken cancellationToken, Func> 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()
.AddPolicyHandler(policy);
Для RestSharp, который использует HttpClient
из контейнера зависимостей, никакой разницы нет.
Refit так же предоставляет простую поддержку такого сценария:
services
.AddRefitClient()
.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> GetRandom()
{
var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost:5001/data/rnd");
var returnCodes = new LinkedList();
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> GetRandom()
{
var client = new RestClient(_httpClient);
var request = new RestRequest("http://localhost:5001/data/rnd");
var returnCodes = new LinkedList();
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.