1. 程式人生 > >遠端服務異常處理的實踐之一:客戶端

遠端服務異常處理的實踐之一:客戶端

目錄

  • 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,建議非常謹慎地使用重試策略,討論不在本篇展開。