目录
在.NET应用程序中,我们经常需要进行HTTP调用。在这些情况下,我们可以使用标准的HttpClient类或其他一些库。例如,我已经使用过Refit和RestSharp。但我从未决定使用哪一个。在我参与的项目中,该库始终已经被使用。因此,我决定比较这些库以形成我自己有意义的意见,哪一个更好,为什么。这就是我将在本文中要做的。
在.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,因此我们可以使用一个客户端与它们进行通信。但它们有不同的基址。这些地址在我们系统开始时是未知的。
对于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("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期间应用其他操作。在本例中,我们只需在以下服务的客户端接口实现中执行以下操作: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("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请求的事实上的标准附加组件。让我们看看该库如何与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("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