HttpClient 使用準則
System.Net.Http.HttpClient 類用于發送 HTTP 請求以及從 URI 所標識的資源接收 HTTP 響應。 HttpClient 實例是應用于該實例執行的所有請求的設置集合,每個實例使用自身的連接池,該池將其請求與其他請求隔離開來。
從 .NET Core 2.1 開始,SocketsHttpHandler 類提供實現,使行為在所有平臺上保持一致。
準備工作:先執行下面單元,以啟動WebApi及設置全局對象、方法及其它
//初始化:必須先執行一次
#!import ./ini.ipynb
統一使用示例
{ //大括號: 1、作用域隔離 2、方便整體代碼折疊Console.WriteLine(global_api_config.BaseUrl);
}
啟動WebApi
#啟動已發布的WebApi項目
# 使用dotnet命令啟動的程序,進程名均為 dotnet,不好關閉
# Start-Process -FilePath dotnet -ArgumentList ".\Publish\HttpClientStudy.WebApp\HttpClientStudy.WebApp.dll"# 此種,進程名固定
Start-Process -FilePath ".\Publish\HttpClientStudy.WebApp\HttpClientStudy.WebApp.exe"
關閉WebApi
# 關閉項目進程
$WebAppProcName ="HttpClientStudy.WebApp";
$WebAppProc = Get-Process $WebAppProcName -ErrorAction Ignore
if($null -eq $WebAppProc)
{Write-Host "進程沒有找到,可能已經關閉"
}
else {$WebAppProc.Kill();Write-Host "$WebAppProcName 進程已退出"
}
1、DNS 行為
HttpClient 僅在創建連接時解析 DNS。它不跟蹤 DNS 服務器指定的任何生存時間 (TTL)。
如果 DNS 條目定期更改(這可能在某些方案中發生),客戶端將不會遵循這些更新。 要解決此問題,可以通過設置 PooledConnectionLifetime 屬性來限制連接的生存期,以便在替換連接時重復執行 DNS 查找。
using System.Net.Http;
{var handler = new SocketsHttpHandler{// 15分鐘PooledConnectionLifetime = TimeSpan.FromMinutes(15) };var sharedClient = new HttpClient(handler);sharedClient.Display();
}
上述 HttpClient 配置為重復使用連接 15 分鐘。 PooledConnectionLifetime 指定的時間范圍過后,系統會關閉連接,然后創建一個新連接。
2、共用連接(底層自動管理連接池)
HttpClient 的連接池鏈接到其基礎 SocketsHttpHandler。
釋放 HttpClient 實例時,它會釋放池中的所有現有連接。 如果稍后向同一服務器發送請求,則必須重新創建一個新連接。
因此,創建不必要的連接會導致性能損失。
此外,TCP 端口不會在連接關閉后立即釋放。 (有關這一點的詳細信息,請參閱 RFC 9293 中的 TCP TIME-WAIT。)如果請求速率較高,則可用端口的操作系統限制可能會耗盡。
為了避免端口耗盡問題,建議將 HttpClient 實例重用于盡可能多的 HTTP 請求。
什么是連接池
SocketsHttpHandler為每個唯一端點建立連接池,您的應用程序通過HttpClient向該唯一端點發出出站HTTP請求。在對端點的第一個請求上,當不存在現有連接時,將建立一個新的HTTP連接并將其用于該請求。該請求完成后,連接將保持打開狀態并返回到池中。
對同一端點的后續請求將嘗試從池中找到可用的連接。如果沒有可用的連接,并且尚未達到該端點的連接限制,則將建立新的連接。達到連接限制后,請求將保留在隊列中,直到連接可以自由發送它們為止。
如何控制連接池
有三個主要設置可用于控制連接池的行為。
-
PooledConnectionLifetime,定義連接在池中保持活動狀態的時間。此生存期到期后,將不再為將來的請求而合并或發出連接。
-
PooledConnectionIdleTimeout,定義閑置連接在未使用時在池中保留的時間。一旦此生存期到期,空閑連接將被清除并從池中刪除。
-
MaxConnectionsPerServer,定義每個端點將建立的最大出站連接數。每個端點的連接分別池化。例如,如果最大連接數為2,則您的應用程序將請求發送到兩個www.github.com和www.google.com,總共可能最多有4個打開的連接。
默認情況下,從.NET Core 2.1開始,更高級別的HttpClientHandler將SocketsHttpHandler用作內部處理程序。沒有任何自定義配置,將應用連接池的默認設置。
該PooledConnectionLifetime默認是無限的,因此,雖然經常使用的請求,連接可能會無限期地保持打開狀態。該PooledConnectionIdleTimeout默認為2分鐘,如果在連接池中長時間未使用將被清理。MaxConnectionsPerServer默認為int.MaxValue,因此連接基本上不受限制。
如果希望控制這些值中的任何一個,則可以手動創建SocketsHttpHandler實例,并根據需要進行配置。
//手動配置 SocketsHttpHandler
{var socketsHandler = new SocketsHttpHandler{PooledConnectionLifetime = TimeSpan.FromMinutes(10),PooledConnectionIdleTimeout = TimeSpan.FromMinutes(5),MaxConnectionsPerServer = 10};var client = new HttpClient(socketsHandler);client.Display();
}
在前面的示例中,對SocketsHttpHandler進行了配置,以使連接將最多在10分鐘后停止重新發出并關閉。如果閑置5分鐘,則連接將在池的清理過程中被更早地刪除。我們還將最大連接數(每個端點)限制為十個。如果我們需要并行發出更多出站請求,則某些請求可能會排隊等待,直到10個池中的連接可用為止。
要應用處理程序,它將被傳遞到HttpClient的構造函數中。
測試連接壽命
//測試連接壽命
{Console.WriteLine("程序運行大約要10-20秒,請在程序退出后,執行下面命令行查看網絡情況");//自定義行為var socketsHandler = new SocketsHttpHandler{//連接池生命周期為10分鐘:連接在池中保持活動時間為10分鐘PooledConnectionLifetime = TimeSpan.FromMinutes(10),//池化鏈接的空閑超時時間為5分鐘: 5分鐘內連接不被重用,則被釋放后銷毀PooledConnectionIdleTimeout = TimeSpan.FromMinutes(5),//每端點的最大連接數設置為10個MaxConnectionsPerServer = 10};var client = new HttpClient(socketsHandler){BaseAddress = new Uri(global_api_config.BaseUrl)};var displayer = "".Display();for (var i = 0; i < 5; i++){if(i>0){await Task.Delay(TimeSpan.FromSeconds(2));}_ = await client.GetAsync(global_default_page);displayer.Update(($"第{i+1}次請求完成"));await Task.Delay(TimeSpan.FromSeconds(2));}
}
使用自定義設置,依次向同一端點發出5個請求。在每個請求之間,暫停兩秒鐘。輸出從DNS檢索到的網站服務器的IPv4地址。我們可以使用此IP地址來查看通過PowerShell中發出的netstat命令對其打開的連接:
# 若查詢不到,則異常
#!set --value @csharp:global_netstat_filter --name queryFilterWrite-Host "請先執行上面的單元,再執行本單元"
Write-Host "網絡狀態"
netstat -ano | findstr $queryFilter
在這種情況下,到遠程端點的連接只有1個。在每個請求之后,該連接將返回到池中,因此在發出下一個請求時可以重新使用。
如果更改連接的生存期,以使它們在1秒后過期,測試這對行為的影響:
//程序池設置
{ //自定義行為Console.WriteLine("程序運行大約要10-20,請在程序退出后,執行下面命令行查看網絡情況");var socketsHandler2 = new SocketsHttpHandler{PooledConnectionLifetime = TimeSpan.FromSeconds(1),PooledConnectionIdleTimeout = TimeSpan.FromSeconds(1),MaxConnectionsPerServer = 1};var client2 = new HttpClient(socketsHandler2){BaseAddress = new Uri(global_api_config.BaseUrl)};var displayer = "".Display();for (var i = 0; i < 5; i++){if(i>0){await Task.Delay(TimeSpan.FromSeconds(2));}_ = await client2.GetAsync(global_default_page);displayer.Update(($"第{i+1}次請求完成"));await Task.Delay(TimeSpan.FromSeconds(2));}//調用命令行,顯示查看網絡情況string command = $"netstat -ano | findstr {global_netstat_filter}";// 創建一個新的ProcessStartInfo對象ProcessStartInfo startInfo = new ProcessStartInfo("cmd", $"/c {command}"){RedirectStandardOutput = true, // 重定向標準輸出UseShellExecute = false, // 不使用系統外殼程序啟動CreateNoWindow = true // 不創建新窗口};// 啟動進程using (Process process = Process.Start(startInfo)){// 讀取cmd的輸出using (StreamReader reader = process.StandardOutput){string stdoutLine = reader.ReadToEnd();Console.WriteLine(stdoutLine);}}
}
#!set --value @csharp:global_netstat_filter --name queryFilter
netstat -ano | findstr $queryFilter
在這種情況下,我們可以看到使用了五個連接。其中的前四個在1秒后從池中刪除,因此無法在下一個請求中重復使用。結果,每個請求都打開了一個新連接。現在,原始連接處于TIME_WAIT狀態,并且操作系統無法將其重新用于新的出站連接。最終連接顯示為ESTABLISHED,因為我在它過期之前就抓住了它。
測試最大連接數
/*功能:將MaxConnectionsPerServer限制為2。然后啟動200個任務,每個任務都向同一端點發出HTTP請求。這些任務將同時運行。所有請求競爭所花費的時間將寫入控制臺。隨即調用用netstat命令查看連接:則根據定義的限制,我們可以看到兩個已建立的連接。
*/
{Console.WriteLine("開始請求網絡...");var socketsHandler = new SocketsHttpHandler{PooledConnectionLifetime = TimeSpan.FromSeconds(60),PooledConnectionIdleTimeout = TimeSpan.FromMinutes(20),MaxConnectionsPerServer = 2};var client = new HttpClient(socketsHandler){BaseAddress = new Uri(global_api_config.BaseUrl)};var sw = Stopwatch.StartNew();var tasks = Enumerable.Range(0, 200).Select(i => client.GetAsync(global_default_page));await Task.WhenAll(tasks);sw.Stop();Console.WriteLine($"共請求了200次,耗時 {sw.ElapsedMilliseconds} 毫秒");//執行查看網絡狀態方法Console.WriteLine("當前網絡狀態");var message = HttpClientStudy.Core.Utilities.AppUtility.RunCmd($"netstat -ano | findstr {global_netstat_filter}");Console.WriteLine(message);
}
# 重新查詢當前網絡狀態
#!set --value @csharp:global_netstat_filter --name queryFilter
netstat -ano | findstr $queryFilter
如果我們調整此代碼以允許MaxConnectionsPerServer = 10,則可以重新運行該應用程序。耗時將減少大約4倍。
{ //MaxConnectionsPerServer 設置為10:網絡連接將增加到10個,耗時將減少到1/4Console.WriteLine("開始請求網絡...");var socketsHandler = new SocketsHttpHandler{PooledConnectionLifetime = TimeSpan.FromSeconds(60),PooledConnectionIdleTimeout = TimeSpan.FromMinutes(20),MaxConnectionsPerServer = 10};var client = new HttpClient(socketsHandler){BaseAddress = new Uri(global_api_config.BaseUrl)};//client.Display();var sw = Stopwatch.StartNew();var tasks = Enumerable.Range(0, 200).Select(i => client.GetAsync(global_default_page));await Task.WhenAll(tasks);sw.Stop();Console.WriteLine($"共請求了200次,耗時 {sw.ElapsedMilliseconds} 毫秒");//執行查看網絡狀態方法Console.WriteLine("當前網絡狀態");var message = AppUtility.RunCmd($"netstat -ano | findstr {global_netstat_filter}");Console.WriteLine(message);
}
3、推薦使用方式
總則:
一、 應使用長期客戶端(靜態對象、單例等),并設置 PooledConnectionLifetime。這能解決DNS問題和套接字耗盡問題。
二、 使用 IHttpClientFactory 創建的短期客戶端:
-
在 .NET Core 和 .NET 5+ 中:
-
根據預期的 DNS 更改,使用 static 或 singletonHttpClient 實例,并將 PooledConnectionLifetime 設置為所需間隔(例如 2 分鐘)。 這可以解決端口耗盡和 DNS 更改兩個問題,而且不會增加 IHttpClientFactory 的開銷。 如果需要模擬處理程序,可以單獨注冊它。
-
使用 IHttpClientFactory,可以針對不同的用例使用多個以不同方式配置的客戶端。 但請注意,工廠創建的客戶端生存期較短,一旦創建客戶端,工廠就不再可以控制它。
工廠合并 HttpMessageHandler 實例,如果其生存期尚未過期,則當工廠創建新的 HttpClient 實例時,可以從池中重用處理程序。 這種重用避免了任何套接字耗盡問題。
如果需要 IHttpClientFactory 提供的可配置性,我們建議使用類型化客戶端方法。
-
-
在 .NET Framework 中,使用 IHttpClientFactory 管理 HttpClient 實例。 如果不使用工廠,而是改為自行為每個請求創建新的客戶端實例,則可能耗盡可用的端口。
提示
: 如果應用需要 Cookie,請考慮禁用自動 Cookie 處理或避免使用 IHttpClientFactory。 共用 HttpMessageHandler 實例會導致共享 CookieContainer 對象。 意外的 CookieContainer 對象共享通常會導致錯誤的代碼。
{ //不推薦的示例int requestCount =0;//這會建立10個 HttpClient //盡管使用了Using,不過Using只保證應用進程釋放實例;但是http請求是跨操作系統、跨網絡的操作,調用Using的進程管不了操作系統,更管不了網絡。//如果把循環次數加大到 65535 就會一定導致夏套接字耗盡(2000以很可能就會出現)。Parallel.For(0,10,async (a,b)=>{using (var client = new HttpClient()){_ = await client.GetAsync (global_api_config.BaseUrl + global_default_page);} Interlocked.Add(ref requestCount, 1);});
}{ //使用長期客戶端using (var client = new HttpClient()){client.BaseAddress = new Uri(global_api_config.BaseUrl);for(int i=0; i<10; i++){//n次調用,均使用同一個 HttpClient 實例_ = await client.GetAsync(global_default_page);}}// 所有調用完成,才釋放 HttpClient 實例
}
4、靜態客戶端的復原能力
#r "nuget:Polly"
#r "nuget:Microsoft.Extensions.Http.Resilience"
using System;
using System.Net.Http;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Http.Resilience;
using Polly;{var retryPipeline = new ResiliencePipelineBuilder<HttpResponseMessage>().AddRetry(new HttpRetryStrategyOptions{BackoffType = DelayBackoffType.Exponential,MaxRetryAttempts = 3}).Build();var socketHandler = new SocketsHttpHandler{PooledConnectionLifetime = TimeSpan.FromMinutes(15)};#pragma warning disable EXTEXP0001var resilienceHandler = new ResilienceHandler(retryPipeline){InnerHandler = socketHandler,};#pragma warning restore EXTEXP0001var httpClient = new HttpClient(resilienceHandler);httpClient.BaseAddress = new Uri(global_api_config.BaseUrl);var response = await httpClient.GetAsync(global_default_page);var htmlText = await response.Content.ReadAsStringAsync();Console.WriteLine($"共有{htmlText.Length}個字符");
}