65.9K
CodeProject 正在变化。 阅读更多。
Home

HTTP 库的比较

starIconstarIconstarIconstarIconstarIcon

5.00/5 (9投票s)

2023年11月2日

CPOL

10分钟阅读

viewsIcon

10214

本文比较了纯 HttpClient 与 RestSharp 和 Refit。

在 .NET 应用程序中,我们经常需要进行 HTTP 调用。在这种情况下,我们可以使用标准的 HttpClient 类或一些其他库。例如,我之前使用过 RefitRestSharp。但我从未决定使用哪一个。通常,我正在从事的项目中已经使用了某个库。因此,我决定比较这些库,以形成自己有意义的观点,了解哪个更好以及为什么。这正是我将在本文中要做的。

但是,我应该如何比较这些库呢?我毫不怀疑它们都能发送 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("https://: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("https://: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("https://:5001");
    });

之后,使用这些客户端没有任何问题。

性能对比

首先,让我们比较这些库的性能。我们将使用 Benchmark.Net 测量简单的 GET 请求。结果如下:

方法 平均 Error(错误) 标准差 最小值 最大值
HttpClient 187.1 微秒 4.31 微秒 12.72 微秒 127.0 微秒 211.8 微秒
Refit 207.3 微秒 4.47 微秒 13.12 微秒 138.4 微秒 226.7 微秒
RestSharp 724.5 微秒 14.36 微秒 36.03 微秒 657.6 微秒 902.7 微秒

显然,RestSharp 执行请求需要更长时间。让我们了解原因。

这是我们的 RestSharp 客户端代码:

public async Task<string?> GetHello()
{
    var client = new RestClient();

    var request = new RestRequest("https://: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("https://:5001/data/hello");

        return await client.GetAsync<string>(request);
    }
}

初始化也应该改变:

var services = new ServiceCollection();

services.AddHttpClient<IServiceClient, ServiceClient>();

现在性能比较结果看起来更一致了:

方法 平均 Error(错误) 标准差 中位数 最小值 最大值
HttpClient 190.2 微秒 3.79 微秒 10.61 微秒 190.8 微秒 163.1 微秒 214.5 微秒
Refit 180.8 微秒 12.20 微秒 35.96 微秒 205.2 微秒 122.5 微秒 229.3 微秒
RestSharp 242.8 微秒 7.45 微秒 21.73 微秒 248.5 微秒 160.4 微秒 278.5 微秒

基地址

有时我们需要在应用程序执行期间更改请求的基地址。例如,我们的系统与多个 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("https://: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>("https://:5001");

var response = await client.GetHello();

请求的通用处理

我们可以将 HTTP 请求期间执行的所有操作分为两组。第一组包含依赖于特定端点的操作。例如,在调用 ServiceA 时,我们需要执行一组操作,而在调用 ServiceB 时,需要执行另一组操作。在这种情况下,我们只需在这些服务的客户端接口实现内部执行这些操作:IServiceAClientIServiceBClient。在使用 HttpClient 和 RestSharp 的情况下,这种方法没有问题。但在 Refit 的情况下,我们实际上没有客户端接口实现。在这种情况下,我们可以使用普通的装饰器(例如,来自 Scrutor 库)。

第二组包含必须为每个 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 的包装器。

对于 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);
    }
}

请求取消

有时我们需要取消请求。例如,用户厌倦了等待服务器响应并离开了某个 UI 页面。现在不再需要请求结果,我们应该取消请求。我们该怎么做?

ASP.NET Core 允许我们借助 CancellationToken 类了解客户端已取消请求。当然,如果我们的库支持此类别,那将很有用。

使用 HttpClient 运行良好:

public async Task<string> GetLong(CancellationToken cancellationToken)
{
    var response = await _httpClient.GetAsync("https://: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("https://:5001/data/long") {  };

    return await client.GetAsync<string>(request, cancellationToken);
}

Refit 也支持 CancellationToken

public interface IServiceClient
{
    [Get("/data/long")]
    Task<string> GetLong(CancellationToken cancellationToken);

    ...
}

如您所见,请求取消没有任何问题。

请求超时

除了能够取消请求外,能够限制请求的持续时间也很好。这里的情况与通用处理逻辑的情况相反。在配置中为任何请求设置通用请求超时很容易。但能够为每个特定请求指定此超时很有用。事实上,即使在同一服务器上,不同的端点也会处理不同数量的信息。这导致不同的请求处理时间。因此,最好能够为不同的端点设置不同的超时。

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("https://:5001/data/long");

        return await client.GetAsync<string>(request, cancellationToken);
    }
    catch (TimeoutException)
    {
        return "Timeout";
    }
}

对于 HttpClient,我们已经遇到了一些问题。一方面,HttpClient 具有可以使用的 Timeout 属性。但我对此有些疑问。首先,HttpClient 的同一实例在实现 HTTP 客户端接口的类的不同方法中使用。在每个方法中,超时预期可能不同。很容易忘记一些事情,一个方法中的超时会泄漏到另一个方法中。这个问题可以通过一个包装器来克服,该包装器将在每个方法开始时设置超时,并在结束时将其恢复为原始值。如果客户端不以多线程模式使用,这种方法将有效。

但是,此外,我对使用来自依赖容器的 HttpClient 类的不同实例存在一些不确定性。根据文档,每次需要发送 HTTP 请求时都创建 HttpClient 类的新实例是一个坏主意。系统内部支持可重用连接池,检查各种条件等。换句话说,有很多“魔法”。这就是为什么我担心 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("https://: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);

对于使用依赖容器中的 HttpClient 的 RestSharp,没有区别。

Refit 也相当容易地支持此场景:

services
    .AddRefitClient<IServiceClient>()
    .ConfigureHttpClient(c =>
    {
        c.BaseAddress = new Uri("https://:5001");
    })
    .AddPolicyHandler(policy);

有趣的是要考虑以下问题。如果我们的接口几乎所有方法都想要一个 Polly 策略,但其中一个方法想要一个完全不同的策略怎么办?在这里,我认为我们应该查看策略注册表和策略选择器。本文描述了如何根据特定请求选择策略。

请求重发

还有一个与 Polly 相关的话题。有时我们需要更复杂的请求准备。例如,我们可能需要生成某些标头。为了做到这一点,HttpClient 类有一个接受 HttpRequestMessage 参数的 Send 方法。

然而,在发送请求过程中可能会出现各种问题。其中一些可以通过使用例如相同的 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, "https://: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("https://: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();
}

这里我们使用 RestRequest 代替 HttpRequestMessage。这次一切正常。RestSharp 不介意多次发送相同的 RestRequest 对象。

对于 Refit,这个问题不适用。据我所知,它没有任何“请求对象”的类似物。所有参数每次都通过 Refit 接口方法的参数传递。

结论

是时候得出一些结论了。我个人认为 RestSharp 是最好的选择,尽管它与纯 HttpClient 的区别很小。RestSharp 使用 HttpClient 对象并可以访问其所有配置选项。只有稍微改进的操作超时设置和重发同一请求对象的能力使 RestSharp 成为最佳选择。尽管应该说 RestSharp 请求稍微慢一些。对于某些人来说,这可能非常重要。

在我看来,Refit 有些落后。一方面,它看起来很有吸引力,因为它不需要编写客户端代码。另一方面,某些场景需要付出太多努力才能实现。

我希望这次比较对您有所帮助。请在评论中写下您使用这些库的经验。或者您是否使用其他东西进行 HTTP 请求?

祝您好运!

附:本文的代码可在 GitHub 上找到。

© . All rights reserved.