HttpClient
作为官方推荐的 http
客户端,相比之前的 WebClient
和 WebRequest
好用了很多,但默认无法为每个请求单独设置超时,只能给 HttpClient
设置默认超时,使用起来不太方便。
声明:本文主要是翻译自 THOMAS LEVESQUE’S .NET BLOG
的文章: Better timeout handling with HttpClient
。
由于工作原因,需要用c#,就语法层而言,c#确实比java优秀,一些库接口封装也更方便简洁。特别是 HttpClient
,结合了 task异步模型
,使用起来非常顺手。
本人水平有限,如有问题,还望各位多多海涵,不吝赐教
问题
如果你经常用 HttpClient
去调用 Restfull
接口或传送文件,你可能会对 HttpClient
这个类处理 Request(请求)
超时的方式感到恼火,因为存在这两个问题:
timeout(超时)
只能在 HttpClient
的 class
级别处理。也就是说,一旦设置好了,所有 httpClient
下的请求都会应用同样的超时设置,这显然不灵活,如果能够为每个 request
请求分别指定一个超时时间,将非常方便。
当请求超时时,抛出的异常很不好辨认。你认为请求超时时, httpclient
会抛出 TimeoutException
?,不,其实它会抛出一个 TaskCanceledException
,而单看这个异常,你一时还无法分辨是取消导致的还是真正超时导致的。
幸运的是,得益于 HttpClient
的灵活设计,可以非常容易的弥补此缺陷。
因此,我们将针对这两个问题做出解决方案。让我们回顾一下我们想要的:
可以为每个 request
请求单独设置超时时间
当超时发生时, catch
的异常是 TimeoutException
而不是 TaskCanceledException
。
为每个request设置超时值
怎样将超时时间值和 Request
请求关联起来呢? HttpRequestMessage
这个类有个 Properties
的属性,它是一个 字典(Dictionary)
类型的属性,我们可以放入我们任何自定义需要的内容到这个属性中。我们将使用这个属性存储 请求(request)
的超时时间,为了便于实现此功能,我们给 HttpRequestMessage
创建一个扩展方法:
public static class HttpRequestExtensions
{
private static string TimeoutPropertyKey = "RequestTimeout";
public static void SetTimeout(
this HttpRequestMessage request,
TimeSpan? timeout)
{
if (request == null)
throw new ArgumentNullException(nameof(request));
request.Properties[TimeoutPropertyKey] = timeout;
}
public static TimeSpan? GetTimeout(this HttpRequestMessage request)
{
if (request == null)
throw new ArgumentNullException(nameof(request));
if (request.Properties.TryGetValue(
TimeoutPropertyKey,
out var value)
&& value is TimeSpan timeout)
return timeout;
return null;
}
}
这是一段很普通的代码, timout
参数是可null的 TimeSpan
值,我们现在可以给请求设置超时值,但是目前还没有实际使用到这段代码。
Http Handler
HttpClient
使用 管道体系( pipeline architecture)
结构:每个请求都通过一系列类型为 HttpMessageHandler
的 Handler
处理,并且以相反顺序逐级返回响应。有了这种机制,我们可以非常方便的加入我们自己的 Handler
来具体处理超时问题。如果您想了解更多, 本文
将对此进行更详细的说明。
我们的自己的超时 Handler
将继承 DelegatingHandler
, DelegatingHandler
是一种设计为链式调用其他 Handler
的类(简单提一下: DelegatingHandler
内部有个 InnerHandler
成员变量,我们可以在调用 innerHandler.SendAsync()
前后对 request
、 CancellationToken
和 response
做相应处理)。要实现我们的 Handler
,我们重写 SendAsync
方法。最小的实现如下所示:
class TimeoutHandler : DelegatingHandler
{
protected async override Task SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
return await base.SendAsync(request, cancellationToken);
}
}
上述代码并没有任何用处,因为只是将实际处理丢给了 base.SendAsync
,目前还没有对 TimeoutHandler
进行任何加工处理,我们将逐步对其加强扩充,以达到我们的目的。
给 Request
加上超时处理
首先,让我们给 TimeoutHandler
添加一个 TimeSpan
类型的 DefaultTimeout
属性,这个默认超时时间是给没有特意设置超时时间的请求使用的:
public TimeSpan DefaultTimeout { get; set; } = TimeSpan.FromSeconds(100);
就像 HttpClient.Timeout
一样,我们也设置默认超时时间为100秒。
为了实现我们的超时处理,我们需要从 request
中获取超时时间(如果 request
中没有设置,则应用 DefaultTimeout
的值)。接着,我们创建一个在指定时间(超时时间)后将会被取消的 CancellationToken
,并把这个 CancellationToken
传入到链的下一个 Handler
。这样之后,如果指定超时时间内没有获取到 response
响应,我们刚刚创建的 CancellationToken
就会被 取消(cancel)
。
我们创建一个 CancellationTokenSource
,这个类可以创建和控制 CancellationToken
。它将根据超时时间来创建:
private CancellationTokenSource GetCancellationTokenSource(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
var timeout = request.GetTimeout() ?? DefaultTimeout;
if (timeout == Timeout.InfiniteTimeSpan)
{
// No need to create a CTS if there's no timeout
//不需要创建CTS,因为不处理超时(下面会讲到)
return null;
}
else
{
var cts = CancellationTokenSource
.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(timeout);
return cts;
}
}
这里主要关注两个点:
如果 request
超时值为 Timeout.InfiniteTimeSpan
,程序并不会创建 CancellationTokenSource
,它将不会被取消,因此节省了无用的分配。也就是说在这种情况下,我们将不会处理超时。
以上相反,我们创建了一个在指定 timeout
后被自动取消的 CancellationTokenSource
(因为调用了 CancelAfter
)。请注意,
这个CTS连接了传入参数的 cancellationToken
,这个 cancellationToken
其实来自 SendAsync
方法的实参
。这样做之后,当真正的超时发生,或者参数的 cancellationToken
自身被取消, CTS
都会被取消。如果想要获取跟多 CancellationToken
的内容,请 访问这篇文章
最后,我们修改下 SendAsync
方法,应用刚刚创建的 CancellationTokenSource
。
protected async override Task SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
using (var cts = GetCancellationTokenSource(request, cancellationToken))
{
return await base.SendAsync(
request,
cts?.Token ?? cancellationToken);
}
}
我们创建了 CTS
后,把 CTS
的 token
传入到 base.SendAsync
中,注意,我们使用 cts?.Token
是因为 GetCancellationTokenSource
返回的 cts
可能为 null
,如果 cts
为 null
,则直接使用参数自己的 cancellationToken
,我们就不做任何超时处理。
通过这一步,我们有了自己的超时 Handler
,可以为每个请求指定不同的超时时间。但是,当超时发生时,我们仍然只能捕获到 TaskCanceledException
异常,这个问题很容易修复它。
抛出正确的异常
我们需要捕获 TaskCanceledException
(或者它的基类 OperationCanceledException
),然后检测 cancellationToken
参数是否是被取消的:
如果是,说明这个 cancel
是调用者自身导致的,对此直接将异常上抛不处理
如果不是,这意味着是因为我们的超时导致的 cancel
,因此,我们将抛出一个 TimeoutException
这是最终的 SendAsync
方法:
protected async override Task SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
using (var cts = GetCancellationTokenSource(request, cancellationToken))
{
try
{
return await base.SendAsync(
request,
cts?.Token ?? cancellationToken);
}
catch(OperationCanceledException)
when (!cancellationToken.IsCancellationRequested)
{
throw new TimeoutException();
}
}
}
我们使用了一个 exception filter
,通过这种方式,我们只 cactch
我们符合我们情况需要的异常,然后做相应处理。
至此,我们的超时 Handler
已经完成了,接下来看看怎么使用它
使用 Handler
当创建一个 HttpClient
时,可以指定一个自己的 Handler
作为 管道(pipeline)
的第一个 Handler
。如果没有指定,默认使用的是 HttpClientHandler
,这个 handler
直接发送请求到网络上。为了使用我们自己的 TimeoutHandler
,我们需要先创建它,然后将 timeoutHandler
指定为 httpClient
的 handler
。在 timeoutHandler
中,指定 InnerHandler
为我们自己创建的 HttpClientHandler
,这样实际的网络请求就委托到了 HttpClientHandler
中。
var handler = new TimeoutHandler
{
InnerHandler = new HttpClientHandler()
};
using (var client = new HttpClient(handler))
{
client.Timeout = Timeout.InfiniteTimeSpan;
...
}
通过将 httpclient
的 timeout
设置为 InfiniteTimeSpan
来禁用默认的超时设置,如果不这样做,默认超时会干扰我们自己的超时
现在,我们尝试发送一个设定了5秒超时的请求到需要很久才能响应的服务器
var request = new HttpRequestMessage(HttpMethod.Get, "http://foo/");
request.SetTimeout(TimeSpan.FromSeconds(5));
var respOnse= await client.SendAsync(request);
如果服务器在5秒内响应数据,我们将会捕获到一个 TimeoutException
,而不是 TaskCanceledException
,因此事情似乎按预期进行。
为了检测 cancellation
是否正确运行,我们传入一个在2秒(比超时实际小)后会被取消的 CancellationToken
:
var request = new HttpRequestMessage(HttpMethod.Get, "http://foo/");
request.SetTimeout(TimeSpan.FromSeconds(5));
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
var respOnse= await client.SendAsync(request, cts.Token);
这时,我们可以捕获到 TaskCanceledException
,这正是我们期望的。
总结
通过实现我们自己的 Http Handler
,我们可以用一个智能的 timout handler
来解决开始我们提出的问题。
这篇文章的所有代码 在这