遠端服務異常處理的實踐之一:客戶端
目錄
- HTTP 狀態碼
- 服務端各不相同
- 客戶端差異巨大
- WebClient
- HttpWebRequest
- RestSharp
- HttpClient
- HttpClientFactory
隨著純單體專案的逐漸減少,遠端服務呼叫失敗變得十分常見。由於 HTTP 協議的開放性,遠端服務呼叫異常的複雜度在增長。
HTTP 狀態碼
HTTP 狀態碼是描述響應的重要資訊,參考 List of HTTP status codes。
- 1XX 未被定義在 HTTP/1.0 協議中;
- 2XX 表示請求已成功被伺服器接收、理解、並接受;
- 3XX 表示需要客戶端採取進一步的操作才能完成請求;
- 4XX 表示客戶端看起來可能發生了錯誤,妨礙了伺服器的處理;
- 5XX 表示伺服器在處理請求的過程中有錯誤或者異常狀態發生;
3XX 響應不在本文討論之列
服務端各不相同
HTTP 狀態碼目前集中於 1XX 到 5XX 區間,這形成以下事實:
REST 風格介面往往使用 200、400、500 描述響應,部分版本的 ASPNET Core 中將暴露的路由所在方式定義為 void 可以觀察到 204 狀態碼(使用 IActionResult 則可以進行更精確的控制)。
在實踐中,各廠商的策略也千差萬別:
- 友盟和又拍雲介面的 HTTP 狀態碼集中在 200 和 400 上,配合四位業務意義上的狀態碼錶達響應,參考
- 友盟 - U-Push API整合文件 - 附錄I 介面呼叫錯誤碼
- 又拍雲 - 圖片處理 - 狀態碼錶
- 微信支付相關介面文件未宣告 HTTP 狀態碼的意義,相反它定義了以"SUCCESS/FAIL"描述的狀態碼,以及具體介面的錯誤碼,參考 微信支付開發文件 - 統一下單 文未部分。
微信篤信自己的伺服器不會掛,所有非 200 響應均可認為服務出了問題,但這做法並不另類
客戶端差異巨大
大多數部分客戶端認為 4XX 和 5XX 為異常響應,但各語言整合的 HTTP 客戶端或者第三方以及各版本存在部分差異。以 .net 中的 WebClient、HttpWebRequest 來說, 遇到 4XX 和 5XX 直接丟擲異常,這使得即便接收到 HTTP 響應,獲取響應狀態碼及正文卻需要在 catch 語句中進行,使用起來極為醜陋。
WebClient
WebClient API 看起來簡單,但建議避免使用 WebClient ,理由如下:
- WebClient 會捕獲請求的執行緒上下文,有造成死鎖的可能;
- WebClient 基於 HttpWebRequest,不但歷史包袱嚴重,混雜了 EAP模式(Event-based Asynchronous Pattern)與 Task 模式,而且缺失基本的超時設定;
HttpWebRequest
HttpWebRequest 必須在異常捕獲邏輯中處理伺服器的非 2xx 響應,同步版本支援超時設定,請求示例:
var url = "http://localhost:4908/api/test/2";
//url = "http://www.google.com";
var client = HttpWebRequest.CreateHttp(url);
client.Method = HttpMethod.Get.Method;
client.Timeout = 3000;
try {
var resp = client.GetResponse(); //超時生效
//var resp = await client.GetResponseAsync() as HttpWebResponse; //超時不生效
using (var stream = resp.GetResponseStream())
using (var reader = new StreamReader(stream)) {
var respText = await reader.ReadToEndAsync();
Console.WriteLine(respText);
}
}
catch (WebException ex) {
//開始處理失敗請求
var resp = ex.Response as HttpWebResponse;
if (resp != null) {
Console.WriteLine("request failed: {0}, statusCode: {1}", resp.StatusDescription, resp.StatusCode);
using (var stream = ex.Response.GetResponseStream())
using (var reader = new StreamReader(stream)) {
var respText = await reader.ReadToEndAsync();
Console.WriteLine(respText);
}
}
//伺服器無法響應,比如 DNS 查詢失敗
else {
throw ex.InnerException ?? ex;
}
}
HttpWebRequest 的缺陷
HttpWebRequest 存在著設計和實現缺陷,都與超時相關。在開始之前必須指出:.net core 不同版本存在差異,.net framework 不同版本存在差異,.net framework 與 .net core 存在差異
首先是DNS 查詢成本不計入超時時長,在 .net framework 上能夠復現,在 .net core 版本上可能得到了修正。
呼叫結果顯示,設定了1秒的超時時間,.net framework 版本耗時 2.261 秒,差異不容忽略,.net core 版本耗時 1.137 秒,滿足預期。
接著是非同步版本不支援超時,即設定了超時時長的 await HttpWebRequest.GetResponseAsync()
無法按預期工作,參考
- Timeout behaviour in HttpWebRequest.GetResponse() vs GetResponseAsync()
- HttpWebRequest.Timeout Property
明明是設計與實現問題,官方卻解釋到 ”The Timeout property has no effect on asynchronous requests made with the BeginGetResponse or BeginGetRequestStream method“ 云云。
為什麼這麼說?因為 .net core 版本修復了這個問題,請繼續閱讀。
http://localhost:13340/api/trial/11 是一個 webapi 介面,內部使用 Thread.Sleep(10000)
掛起10秒,問題在 .net framework 上能夠復現,在 .net core 版本按預期工作。
這意味著我們必須做更多的工作。超時模式本可以解決這個問題,需要先借助 TaskFactory.FromAsync()
將 APM 模式(Asynchronous Programming Model)轉換成 TPL 模式,即基於 Task 的非同步模式
async Task Main() {
var url = "http://localhost:13340/api/trial/11";
var client = HttpWebRequest.CreateHttp(url);
//避免干擾,沒有對 HttpWebRequest.Timeout 賦值
var timeout = TimeSpan.FromSeconds(5);
var start = DateTime.UtcNow;
Console.WriteLine(Environment.Version);
Console.WriteLine("Start {0}", DateTime.Now);
try {
//await client.GetResponseAsync();
var resp = await Task.Factory.FromAsync(client.BeginGetResponse, client.EndGetResponse, null)
.SetTimeout(timeout);
}
catch (OperationCanceledException) {
Console.WriteLine("Request timeout");
}
catch (WebException ex) {
Console.WriteLine(ex.InnerException ?? ex);
}
finally {
Console.WriteLine("Finish {0}", DateTime.UtcNow.Subtract(start));
}
}
public static class TaskExension {
[System.Diagnostics.DebuggerStepThrough]
public static async Task<T> SetTimeout<T>(this Task<T> task, TimeSpan timeout) {
using (var cts = new CancellationTokenSource(timeout)) {
var tsc = new TaskCompletionSource<T>();
using (cts.Token.Register(state => tsc.TrySetCanceled(), tsc)) {
if (task != await Task.WhenAny(task, tsc.Task)) {
throw new OperationCanceledException(cts.Token);
}
}
return await task;
}
}
}
.net core 版本同樣工作完好,在此忽略,至此 HttpWebRequest 的坑點已經數的差不多了。
RestSharp
Github 上的接近 7000 星專案 restsharp/RestSharp 使用 HttpWebRequest 完成實現,關鍵程式碼見 Http.Sync.cs,它支援以下模式:
- 基於同步:IRestClient.Get/Post -> Execute() -> RestClient.DoExecuteAsXXXX() -> Http.AsXXXX() -> Http.XXXXInternal(), ConfigureWebRequest() 返回 HttpWebRequest
- 基於回撥:IRestClient.GetAsync/PostAsync() -> RestClient.ExecuteAsync() -> DoAsXXXXAsync() 返回 HttpWebRequest
- 基於 Task:IRestClient.GetAsync/PostAsync() -> RestClient.ExecuteXXXXTaskAsync() -> ExecuteTaskAsync() -> ExecuteAsync() 進入基於回撥的實現
專案 HttpWebRequest 完成實現,非同步請求的版在回撥版本基礎上藉助 TaskCompletionSource 完成實現,繞開了 await HttpWebRequest.GetResponseAsync()
的超時缺陷。但 HttpWebRequest 固有的 DNS 問題無法避免,故專案在 Note about error handling 中特別備註到:
Note about error handling
If there is a network transport error (network is down, failed DNS lookup, etc),
HttpClient
HttpClient 的出現使得情況些許改觀,不考慮超時,使用4行程式碼即可讀取返回非 2XX 狀態碼的響應正文:
var client = new HttpClient();
var url = "http://localhost:4908/api/test/2";
var resp = await client.GetAsync(url);
//遇到4XX、5XX 也不會丟擲異常
var respText = await resp.Content.ReadAsStringAsync();
Console.WriteLine(respText);
可以使用
HttpResponseMessage.EnsureSuccessStatusCode()
進行成功請求斷言
新增異常處理與超時機制,程式碼在 20 行左右,是 HttpWebRequest 規模的 1/3 左右。
var url = "http://localhost:4908/api/test/1";
//url = "http://www.google.com";
var client = new HttpClient();
client.Timeout = TimeSpan.FromSeconds(5);
HttpResponseMessage resp = null;
try {
resp = await client.GetAsync(url);
}
catch (TaskCanceledException) {
//開始處理請求超時
Console.WriteLine("Request timeout");
throw new TimeoutException();
}
catch (HttpRequestException ex) {
//伺服器無法響應,比如未開機,DNS
if (ex.InnerException is WebException ex2) {
throw ex2.InnerException ?? ex2;
}
throw ex;
}
//已獲取到響應
if (resp.IsSuccessStatusCode) {
//安全地讀取 resp.Content,進行反序列化等,
//也可以直接使用 EnsureSuccessStatusCode() 斷言
}
else {
//開始處理失敗請求
Console.WriteLine("Request failed: {0}, statusCode: {1}", resp.ReasonPhrase, resp.StatusCode);
//直接讀取不會丟擲異常
var respText = await resp.Content.ReadAsStringAsync();
Console.WriteLine(respText);
}
可見基於 HttpClient 易於使用,然而 HttpClient 有自己的問題,雖然偏離主題,但不得不拿出篇幅來陳述。
HttpClient 的缺陷
搜尋 "HttpClient dispose" 可見一二:
- YOU'RE USING HTTPCLIENT WRONG AND IT IS DESTABILIZING YOUR SOFTWARE
簡單地說,HttpClient 和 DbConnection 一樣都從 IDispose 繼承,然而其工作方式大不一樣:後者將連線釋放回連線池,前者卻需要4分鐘關閉 TCP 連線,這導致高負載的站點可能用盡資源。
然而網上解決辦法都建議靜態或單例化 HttpClient 例項,如部落格園站長 dudu 的C#中HttpClient使用注意:預熱與長連線,9102年了,彙總下HttpClient問題,封印一個 ,這些做法會引入了其他問題:
但事實證明,有一個更嚴重的問題:HttpClient 不遵循 DNS 變化,它會(通過 HttpClientHandler)獨佔連線,直到套接字關閉。沒有時間限制!
- .NET HttpClient的缺陷和文件錯誤讓開發人員倍感沮喪
- Bugs and Documentation Errors in .NET's HttpClient Frustrate Developers
在實際開發中 DNS 變化可能不是很大問題,雖然 HttpClient 是執行緒安全的,但是唯一的 HttpClient 不能滿足差異化的 Http 請求,比如有時候需要自定義頭部,有時候需要使用證書發起請求,靜態或單例化的 HttpClient 不能很好地滿足需要。
HttpClientFactory
為了克服以上問題,微軟在 .Net core 2.1 版本引入了 HttpClientFactory,基礎使用方法簡單,請自行閱讀不再詳細陳述。
- Use HttpClientFactory to implement resilient HTTP requests
- 3 ways to use HTTPClientFactory in ASP.NET Core 2.1
IHttpClientFactory 內部引用了 Policy,建議非常謹慎地使用重試策略,討論不在本篇展開。