[Перевод] Сравнение HTTP-библиотек

a1665c74ebb30da5021a29a99d139d50

В .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.

© Habrahabr.ru