HTTP 库的比较





5.00/5 (9投票s)
本文比较了纯 HttpClient 与 RestSharp 和 Refit。
在 .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("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 时,需要执行另一组操作。在这种情况下,我们只需在这些服务的客户端接口实现内部执行这些操作:IServiceAClient
和 IServiceBClient
。在使用 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 上找到。