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


隨着純單體項目的逐漸減少,遠程服務調用失敗變得十分常見。由於 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 則可以進行更精確的控制)。

在實踐中,各廠商的策略也千差萬別:

微信篤信自己的服務器不會掛,所有非 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() 無法按預期工作,參考

明明是設計與實現問題,官方卻解釋到 ”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" 可見一二:

簡單地說,HttpClient 和 DbConnection 一樣都從 IDispose 繼承,然而其工作方式大不一樣:后者將連接釋放回連接池,前者卻需要4分鍾關閉 TCP 連接,這導致高負載的站點可能用盡資源。

然而網上解決辦法都建議靜態或單例化 HttpClient 實例,如博客園站長 dudu 的C#中HttpClient使用注意:預熱與長連接9102年了,匯總下HttpClient問題,封印一個 ,這些做法會引入了其他問題:

但事實證明,有一個更嚴重的問題:HttpClient 不遵循 DNS 變化,它會(通過 HttpClientHandler)獨占連接,直到套接字關閉。沒有時間限制!

在實際開發中 DNS 變化可能不是很大問題,雖然 HttpClient 是線程安全的,但是唯一的 HttpClient 不能滿足差異化的 Http 請求,比如有時候需要自定義頭部,有時候需要使用證書發起請求,靜態或單例化的 HttpClient 不能很好地滿足需要。

HttpClientFactory

為了克服以上問題,微軟在 .Net core 2.1 版本引入了 HttpClientFactory,基礎使用方法簡單,請自行閱讀不再詳細陳述。

IHttpClientFactory 內部引用了 Policy,建議非常謹慎地使用重試策略,討論不在本篇展開。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM