HTTP库的比较

目录

初始设置

性能比较

基址

请求的常见处理

请求取消

请求超时

Polly支持

请求重新发送

结论


.NET应用程序中,我们经常需要进行HTTP调用。在这些情况下,我们可以使用标准的HttpClient类或其他一些库。例如,我已经使用过RefitRestSharp。但我从未决定使用哪一个。在我参与的项目中,该库始终已经被使用。因此,我决定比较这些库以形成我自己有意义的意见,哪一个更好,为什么。这就是我将在本文中要做的。

.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请求。结果如下:

方法

平均数

错误

标准开发

最小

最大

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>();

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

方法

平均数

错误

标准开发

中位数

最小

最大

HttpClient

190.2 us

3.79 us

10.61 us

190.8 us

163.1 us

214.5 我们

改装

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,因此我们可以使用一个客户端与它们进行通信。但它们有不同的基址。这些地址在我们系统开始时是未知的。

对于HttpClientRestSharp,这不是问题。下面是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请求期间执行的所有操作分为两组。第一组包含依赖于特定终结点的操作。Fjr示例,在调用ServiceA期间,我们需要应用一个操作,并在调用ServiceB期间应用其他操作。在本例中,我们只需在以下服务的客户端接口实现中执行以下操作:IServiceAClientIServiceBClient。在使用HttpClientRestSharp的情况下,这种方法没有问题。但是在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("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);

    ...
}

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

请求超时

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

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属性。但在这里我有一些疑问。首先,在实现HTTP客户端接口的类的不同方法中使用相同的HttpClient实例。在每种方法中,超时预期可能不同。很容易忘记某些东西,并且一种方法的超时会泄漏到另一种方法。这个问题可以借助包装器来克服,包装器将在每个方法的开头设置超时,并在结束时将其返回到其原始值。如果客户端未在多线程模式下使用,则此方法将起作用。

但是,此外,我对使用依赖容器中的不同类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("http://localhost:5001/data/long", tokenSource.Token);

        response.EnsureSuccessStatusCode();

        return await response.Content.ReadAsStringAsync();
    }
    catch (TaskCanceledException)
    {
        return "Timeout";
    }
}

同样的方法也适用于改装:

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请求的事实上的标准附加组件。让我们看看该库如何与HttpClientRestSharpRefit配合使用。

在这里,与常见的处理逻辑一样,可能有几种变体。首先,对于我们客户端界面的不同方法,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);

对于从依赖项容器使用HttpClientRestSharp,没有区别。

Refit也很容易支持这种情况:

services
    .AddRefitClient<IServiceClient>()
    .ConfigureHttpClient(c =>
    {
        c.BaseAddress = new Uri("http://localhost: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, "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();
}

在这里,我们使用RestRequest而不是HttpRequestMessage。这一次一切都很好。RestSharp不介意多次发送同一个RestRequest对象。

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

结论

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

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

我希望这个比较对你有所帮助。请在评论中写下您对这些库的体验。或者,也许您对HTTP请求使用其他东西?

P.S. 本文的代码可以在 GitHub 上找到。

https://www.codeproject.com/Articles/5371998/Comparison-of-HTTP-libraries

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值