小心.NET HttpClient

小心.NET HttpClient

背景

在過去.NET4.5之前),向HTTP服務器發送請求可以通過使用WebClient或在較低級別通過HttpWebRequest在2009年,作為REST入門套件(RSK)一部分,出現了一種新的抽象概念,稱為HttpClient直到.NET 4.5發布才開始,因為它可供更廣泛的觀眾使用。

此抽象提供了更簡單的方法,可以在完全異步中HTTP服務器通信,並允許為每個請求設置默認標頭。這一切都很棒,但是關於這個課程有一些黑暗的秘密,如果沒有解決這個問題可能會導致嚴重的性能,有時甚至會出現令人難以置信的錯誤!在本文中,我們將探討在使用該類時需要注意問題 細微之處HttpClient

那有什麼不對?

HttpClient類實現IDisposable提示此類型的任何對象必須加以處置使用後; 考慮到這一點,讓我們看看如何使用這個類假設我不知道這個問題:

var endpoint = new Uri("http://localhost:1234/");  
for (int i = 0; i < 10; i++)  
{
    using (var client = new HttpClient())
    {
        var response = await client.GetStringAsync(endpoint);        
        Console.WriteLine(response);
    }
}

所以這裡我們按順序向端點發送10個請求,並假設有一個監聽器在端口1234或您選擇命中的任何其他端點上提供請求,您將看到10個響應寫入Console一切順利,對吧?

讓我們運行命令:netstat -abn應該返回的CMD上(取決於您點擊的端點):

...
TCP    [::1]:40968            [::1]:1234             TIME_WAIT  
TCP    [::1]:40969            [::1]:1234             TIME_WAIT  
TCP    [::1]:40970            [::1]:1234             TIME_WAIT  
TCP    [::1]:40971            [::1]:1234             TIME_WAIT  
TCP    [::1]:40972            [::1]:1234             TIME_WAIT  
TCP    [::1]:40973            [::1]:1234             TIME_WAIT  
TCP    [::1]:40975            [::1]:1234             TIME_WAIT  
TCP    [::1]:40976            [::1]:1234             TIME_WAIT  
TCP    [::1]:40977            [::1]:1234             TIME_WAIT  
TCP    [::1]:40978            [::1]:1234             TIME_WAIT  
...

你問的是什麼?好吧,這向我們展示了我們的應用程序向服務器開放了10個套接字,因此每個請求都有一個但更重要的是,即使我們的應用程序現已結束,操作系統仍有10個套接字仍處於佔用狀態且處於TIME__WAIT狀態。

這是由於TCP / IP被設計為工作的方式,因為連接沒有立即關閉以允許數據包在連接關閉後無序到達或重新傳輸。TIME WAIT表示本地端點(我們這邊的端點)已關閉連接,但保持連接,以便可以正確處理任何延遲的數據包。一旦發生這種情況,連接將在4分鐘的超時時間後被刪除請記住,我們向同一個端點發送了10個請求,但我們仍有10個單獨的插槽仍在忙碌至少4分鐘!

上面的例子是一個過於簡化的例子,但看看這個:

public class ProductController : ApiController  
{
    public async Task<Product> GetProductAsync(string id)
    {
        using (var httpClient = new HttpClient())
        {
            var result = await httpClient.GetStringAsync("http://somewhere/api/...");
            return new Product { Name = result };
        }
    }
}

每次傳入請求這樣做最終會導致SocketException,不相信我?只需運行負載測試,坐下來觀察在套接字用完之前可以提供多少請求!

我們能做什麼?

好吧,首先想到的是重新使用我們的客戶端而不是為每個請求創建一個新客戶端,但正如您將在後面看到的那樣,這可能會導致另一個問題在我們談到這一點之前,讓我們先了解一下我們是否可以重複使用單個實例。HTTPClient線程安全的?答案是肯定的,至少以下方法已被證明是線程安全的:

CancelPendingRequests  
DeleteAsync  
GetAsync  
GetByteArrayAsync  
GetStreamAsync  
GetStringAsync  
PostAsync  
PutAsync  
SendAsync  

但是,以下內容不是線程安全的,並且在第一次請求完成後無法更改:

BaseAddress,  
Timeout,  
MaxResponseContentBufferSize  

事實上,在同一文檔頁面的備註部分,它解釋了:

HttpClient旨在實例化一次,並在應用程序的整個生命週期中重複使用。為每個請求實例化一個HttpClient類將耗盡重負載下可用的套接字數量。這將導致SocketException錯誤。

Darrel Miller自己也重申了這一點:

... HTTPClient確實實現了IDisposable,但是,我不建議在Using塊中創建一個HttpClient來發出單個請求。當HttpClient被釋放時,它也會導致底層連接被關閉。這意味著下一個請求必須重新打開該連接。你應該嘗試重用你的HttpClient實例....

好的,是嗎?創建並重用我們客戶的單個實例和快樂的日子?好吧,您可能面臨另一個非常微妙但嚴重的問題。

Singleton HttpClient不尊重DNS更改!

重新使用HttpClient它持有套接字的實例直到它被關閉,所以如果你在服務器上發生DNS記錄更新,客戶端將永遠不會知道,直到該套接字關閉並讓我告訴你DNS記錄變化為不同原因一直是,例如故障轉移場景只是其中之一(雖然在這種情況下連接/套接字會出現故障和關閉)或者在交換不同實例時進行Azure部署,例如生產/暫存,在這種情況下是您的客戶端仍然會擊中舊的實例!事實上,dotnet / corefx repo中存在關於此行為的問題。

HTTPClient(出於正當理由)在連接打開時不檢查DNS記錄,那麼我們如何解決這個問題呢?一個天真的 簡單解決方法是將keep-alive標頭設置為false每次請求後套接字將被關閉,這顯然會導致性能不佳,但如果你不關心,那麼就有你的答案; 但是,我認為我們可以做得更好。

有一個鮮為人知的ServicePoint類,它解決了我們的問題。此類負責管理TCP連接的不同屬性,其中一個屬性是ConnectionLeaseTimeout顧名思義,這個人指定TCP套接字可以保持打開的時間長度(以毫秒為單位默認情況下,此屬性的值設置為-1,導致套接字無限期地保持打開(相對而言),因此我們所要做的就是將其設置為更實際的值:

ServicePointManager.FindServicePoint(endpoint)  
    .ConnectionLeaseTimeout = (int)TimeSpan.FromMinutes(1).TotalMilliseconds;

上述覆蓋需要每個端點應用一次請注意,該方法僅關注主機架構端口,其他一切都被忽略。

差不多了...

到目前為止,我們已經註意在一段時間後強行關閉連接但這只是第一部分。如果我們的單例客戶端打開另一個連接,它可能仍然指向舊服務器,為什麼你問?以及所有DNS條目都被緩存,默認情況下不會刷新2分鐘。所以我們還需要通過DnsRefreshTimeoutServicePointManager設置這樣來減少我們可以做的緩存超時

ServicePointManager.DnsRefreshTimeout = (int)1.Minutes().TotalMilliseconds;  

我希望有一個更好的抽象,而不必記住在每個請求上都做這一切,我還希望抽象實現一個接口,用於我的服務之間的依賴注入。

RESTClient實現

RestClient是一個線程安全的包裝器HttpClient,內部保留已發送請求的端點緩存,如果要求它向其緩存中沒有的端點發送請求,則更新該ConnectionLeaseTimeout端點。這是一個簡單的用法示例:

// This is to show that IRestClient implements IDisposable
// just like HttpClient, you should not dispose it per request.
using (IRestClient client = new RestClient())  
{
    client.SendAsync(new HttpRequestMessage(HttpMethod.Get, new Uri("http://localhost/api")));        
}

現在,您可以安全地抓住客戶端和/或使用您最喜歡的IOC容器進行註冊,並將其註入您需要的任何位置。

該類支持相同的構造函數,HttpClient並且還提供了一種設置其默認屬性的安全方法:

var defaultHeaders = new Dictionary<string, string>  
{
    {"Accept", "application/json"},
    {"User-Agent", "foo-bar"}
};

using (IRestClient client = new RestClient(defaultHeaders, timeout: 15.Seconds(), maxResponseContentBufferSize: 10))  
{
    client.DefaultRequestHeaders.Count.ShouldBe(defaultHeaders.Count);
    client.DefaultRequestHeaders["Accept"].ShouldBe("application/json");
    client.DefaultRequestHeaders["UserAgent"].ShouldBe("foo-bar");

    client.Endpoints.ShouldBeEmpty();
    client.MaxResponseContentBufferSize.ShouldBe((uint)10);
    client.Timeout.ShouldBe(15.Seconds());
}

代碼在GitHub上,可以在NuGet 獲得,作為我在其他項目中使用Easy.Common庫的一部分

更新2019年

.NET Core 2.1開始Microsoft通過提供解決了本文中涉及的一些問題HttpClientFactory儘管本課程提供了各種功能,但在我看來,使用此類型還涉及太多的儀式,我們仍然需要處理自己設置DNS刷新超時; 因此,我仍然喜歡RestClient在我的項目中使用

HttpClient.NET Core 2.1中也進行了大修,重寫了一個HttpMessageHandler調用SocketsHttpHandler,這導致了顯著的性能改進,它還引入了PooledConnectionLifetime允許我們設置連接超時屬性,而無需ConnectionLeaseTimeout為每個端點設置

由於版本Easy.Common 3.0.0中,RestClient不再需要設置ConnectionLeaseTimeout上運行時.NET核心2.1或更高版本。

玩得開心和快樂REST ING。

免責聲明
這篇文章的靈感來自於你使用HttpClient的錯誤,這是不穩定的軟件西蒙·蒂姆斯辛格爾頓的HttpClient通過阿里Ostad以及通過各種偉大的職位達雷爾-米勒

轉自:http://www.nimaara.com/2016/11/01/beware-of-the-net-httpclient/

PS5