在一個采用依賴注入框架的應用中,我們一般不太推薦利用手工創建的HttpClient對象來進行HTTP調用,使用的HttpClient對象最好利用注入的IHttpClientFactory工廠來創建。前者引起的問題,以及后者帶來的好處,將通過如下這幾個演示程序展現出來。IHttpClientFactory類型由“Microsoft.Extensions.Http”這個NuGet包提供,“Microsoft.NET.Sdk.Web”SDK具有該包的默認引用。如果采用“Microsoft.NET.Sdk”這個SDK,需要添加該包的引用。(本篇提供的實例已經匯總到《ASP.NET Core 6框架揭秘-實例演示版》)
[S1201]頻繁創建HttpClient對象調用API(源代碼)
[S1202]以單例方式使用HttpClient(源代碼)
[S1203]利用IHttpClientFactory工廠創建HttpClient對象(源代碼)
[S1204]直接注入HttpClient對象(源代碼)
[S1205]定制HttpClient對象(源代碼)
[S1206]強類型客戶端(源代碼)
[S1207]基於Polly的失敗重試(源代碼)
[S1201]頻繁創建HttpClient對象調用API
HttpClient類型實現了IDisposable接口,如果采用在每次調用時創建新的對象,那么按照我們理解的編程規范,調用結束之后就應該主動調用Dispose方法及時地將其釋放。如下的演示程序就采用了這種編程方式,我們啟動了一個ASP.NET應用,它提供了一個返回“Hello World”的終結點。
using System.Diagnostics; var app = WebApplication.Create(args); app.MapGet("/", () => "Hello World!"); await app.StartAsync(); while (true) { using (var httpClient = new HttpClient()) { try { var reply = await httpClient.GetStringAsync("http://localhost:5000"); Debug.Assert(reply == "Hello World!"); } catch (Exception ex) { Console.WriteLine(ex.Message); } } }
ASP.NET應用啟動之后,我們在一個無限循環中對它發起調用。每次迭代的創建的HttpClient對象會在完成調用之后被釋放。當我們的程序運行之后,初始階段都沒有問題。當調用次數累積到一定規模之后,程序會大量地拋出HttpRequestExcetion異常,並提示“Only one usage of each socket address (protocol/network address/port) is normally permitted”。
[S1202]以單例方式使用HttpClient
這個演示實例表明頻繁創建HttpClient對象是不可取的。如果我們需要自行創建HttpClient對象並頻繁地使用它們,應該盡可能地復用這個對象。如果將演示程序改寫成如下的形式使用單例的HttpClient對象就不會拋出上面這個異常,但是這又會帶來一些額外的問題。HttpRequestExcetion異常在前面的實例中為何會出現,后面的實例究竟又有哪些問題,我們將在后面回答這個問題。
using System.Diagnostics; var app = WebApplication.Create(args); app.MapGet("/", () => "Hello World!"); await app.StartAsync(); var httpClient = new HttpClient(); while (true) { try { var reply = await httpClient.GetStringAsync("http://localhost:5000"); Debug.Assert(reply == "Hello World!"); } catch (Exception ex) { Console.WriteLine(ex.Message); } }
[S1203]利用IHttpClientFactory工廠創建HttpClient對象
引入IHttpClientFactory工廠將會使一切變得簡單,我們只需要在需要進行HTTP調用的時候利用這個工廠創建出對應的HttpClient對象就可以了。雖然HttpClient類型實現了IDisposable接口,我們在完成了調用之后根本不需要去調用它的Dispose方法。在下面的演示程序中,我們調用ServiceCollection對象的AddHttpClient擴展方法對IHttpClientFactory工廠進行了注冊,並利用構建出來的IServiceProvider對象得到了這個對象。在每次進行HTTP調用的時候,我們利用這個IHttpClientFactory工廠實時地將HttpClient對象創建出來。
using System.Diagnostics; var app = WebApplication.Create(args); app.MapGet("/", () => "Hello World!"); await app.StartAsync(); var httpClientFactory = new ServiceCollection() .AddHttpClient() .BuildServiceProvider() .GetRequiredService<IHttpClientFactory>(); while (true) { try { var reply = await httpClientFactory.CreateClient().GetStringAsync("http://localhost:5000"); Debug.Assert(reply == "Hello World!"); } catch (Exception ex) { Console.WriteLine(ex.Message); } }
[S1204]直接注入HttpClient對象
上面介紹的CreateClient擴展方法還注冊加針對HttpClient類型的服務,所以HttpClient對象可以直接作為注入的服務來使用。在如下所示的演示程序中,我們直接利用IServiceProvider對象來創提供HttpClient對象,它與上面演示的程序是等效的(S1204)。
using System.Diagnostics; var app = WebApplication.Create(args); app.MapGet("/", () => "Hello World!"); await app.StartAsync(); var serviceProvider = new ServiceCollection() .AddHttpClient() .BuildServiceProvider(); while (true) { try { var reply = await serviceProvider.GetRequiredService<HttpClient>().GetStringAsync("http://localhost:5000"); Debug.Assert(reply == "Hello World!"); } catch (Exception ex) { Console.WriteLine(ex.Message); } }
[S1205]定制HttpClient對象
調用IServiceCollection接口的AddHttpClient擴展方法進行服務注冊的時候可以對HttpClient作相應的定制,比如可以設置超時時間、默認請求報頭和網絡代理等。如果應用會涉及針對眾多不同類型API的調用,調用不同的API可能需要采用不同的設置,比如局域網內部調用就比外部調用需要更小的超時設置。為了解決這個問題,我們對提供的設置賦予一個唯一的名稱,在使用的時候針對這個標識提取對應的設置來創建HttpClient對象,為了方便描述,我們將這個唯一標識HttpClient設置的名稱就稱為HttpClient的名稱。在接下來演示的實例中,我們將設置兩個HttpClient來調用指向“www.foo.com”和“www.bar.com”這兩個域名的API。為此我們需要在host文件中添加了如下的映射關系
127.0.0.1 www.foo.com 127.0.0.1 www.bar.com
在如下所示的演示實例中,我們為ASP.NET應用注冊的終結點會返回包含請求的域名和路徑。我們調用IServiceCollection接口的AddHttpClient方法注冊了兩個名稱分別為“foo”和“bar”的HttpClient,並對它們的基礎地址進行針對性的設置(S1205)。
using System.Diagnostics; var app = WebApplication.Create(args); app.Urls.Add("http://0.0.0.0:80"); app.MapGet("/{path}" , (HttpRequest resquest, HttpResponse response) =>response.WriteAsync($"{resquest.Host}{resquest.Path}")); await app.StartAsync(); var services = new ServiceCollection(); services.AddHttpClient("foo", httpClient => httpClient.BaseAddress = new Uri("http://www.foo.com")); services.AddHttpClient("bar", httpClient => httpClient.BaseAddress = new Uri("http://www.bar.com")); var httpClientFactory = services .BuildServiceProvider() .GetRequiredService<IHttpClientFactory>(); var reply = await httpClientFactory.CreateClient("foo").GetStringAsync("abc"); Debug.Assert(reply == "www.foo.com/abc"); reply = await httpClientFactory.CreateClient("bar").GetStringAsync("xyz"); Debug.Assert(reply == "www.bar.com/xyz");
我們將HttpClient的注冊名稱作為參數調用IHttpClientFactory工廠的Create方法得到對應的HttpClient對象。由於基礎地址已經設置好了,所以在進行HTTP調用時只需要指定相對地址(“abc”和“xyz”)就可以了。
[S1206]強類型客戶端
所謂“強類型客戶端”指的針對具體場景自定義的用於調用指定API的類型,強類型客戶端直接使用注入的HttpClient進行HTTP調用。對於上一個實例的應用場景,我們就可以定義如下兩個客戶端類型FooClient和BarClient,並使用它們分別調用指向不同域名的API。如代碼片段所示,我們直接在其構造函數中注入了HttpClient對象,並在GetStringAsync方法中使用它來完成最終的HTTP調用。
public class FooClient { private readonly HttpClient _httpClient; public FooClient(HttpClient httpClient) => _httpClient = httpClient; public Task<string> GetStringAsync(string path) => _httpClient.GetStringAsync(path); } public class BarClient { private readonly HttpClient _httpClient; public BarClient(HttpClient httpClient) => _httpClient = httpClient; public Task<string> GetStringAsync(string path) => _httpClient.GetStringAsync(path); }
由於FooClient和BarClient對使用的HttpClient具有不同的要求,所以我們采用如下的方式調用IServiceCollection接口的AddHttpClient<TClient>針對客戶端類型對HttpClient進行針對設置,具體設置的依然是基礎地址。由於AddHttpClient<TClient>擴展方法會將作為泛型參數的TClient類型注冊為服務,所以我們可以直接利用IServiceProvider對象提取對應的客戶端實例(S1206)。
using App; using System.Diagnostics; var app = WebApplication.Create(args); app.Urls.Add("http://0.0.0.0:80"); app.MapGet("/{path}", (HttpRequest resquest, HttpResponse response)=> response.WriteAsync($"{resquest.Host}{resquest.Path}")); await app.StartAsync(); var services = new ServiceCollection(); services.AddHttpClient<FooClient>("foo", httpClient=> httpClient.BaseAddress = new Uri("http://www.foo.com")); services.AddHttpClient<BarClient>("bar", httpClient=> httpClient.BaseAddress = new Uri("http://www.bar.com")); var serviceProvider = services.BuildServiceProvider(); var foo = serviceProvider.GetRequiredService<FooClient>(); var bar = serviceProvider.GetRequiredService<BarClient>(); var reply = await foo.GetStringAsync("abc"); Debug.Assert(reply == "www.foo.com/abc"); reply = await bar.GetStringAsync("xyz"); Debug.Assert(reply == "www.bar.com/xyz");
[S1207]基於Polly的失敗重試
在任何環境下都不可能確保次HTTP調用都能成功,所以在失敗重試是很有必要的。失敗重試是要講究策略的,返回何種響應狀態才需要重試?重試多少次?時間間隔多長?一提到策略化自動重試,大多數人會想到Polly這個開源框架,“Microsoft.Extensions.Http.Polly”這個NuGet包提供了IHttpClientFactory工廠和Polly的整合。在添加了這個包引用之后,我們將演示程序做了如下的修改。如代碼片段所示,我們注冊的終結點接收到的每三個請求只有一個會返回狀態碼為200的響應,其余兩個響應碼均為500。如果客戶端能夠確保失敗后至少進行兩次重試,那么就能保證客戶端調用100%成功。
using Polly; using Polly.Extensions.Http; using System.Diagnostics; var app = WebApplication.Create(args); var counter = 0; app.MapGet("/", (HttpResponse response) => response.StatusCode = counter++ % 3 == 0 ? 200 : 500); await app.StartAsync(); var services = new ServiceCollection(); services .AddHttpClient(string.Empty) .AddPolicyHandler(HttpPolicyExtensions.HandleTransientHttpError().WaitAndRetryAsync(2, _ => TimeSpan.FromSeconds(1))); var httpClientFactory = services .BuildServiceProvider() .GetRequiredService<IHttpClientFactory>(); while (true) { var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost:5000"); var response = await httpClientFactory.CreateClient().SendAsync(request); Debug.Assert(response.IsSuccessStatusCode); }
如上面的代碼片段所示,調用AddHttpClient擴展方法注冊了一個默認匿名HttpClient(名稱采用空字符串)之后,我們接着調用返回的IHttpClientBuilder對象的AddPolicyHandler擴展方法設置了失敗重試策略。AddPolicyHandler方法的參數類型為IAsyncPolicy<HttpResponseMessage>的參數,我們利用HttpPolicyExtensions類型的HandleTransientHttpError靜態方法創建一個用來處理偶發錯誤(比如HttpRequestException異常和5XX/408響應)的PolicyBuilder<HttpResponseMessage>對象。我們最終調用該對象的WaitAndRetryAsync方法返回所需的IAsyncPolicy<HttpResponseMessage>對象,並通過參數設置了重試次數(兩次)和每次重試時間間隔(1秒)。
在利用代表依賴注入容器的IServiceProvider對象得到IHttpClientFactory之后,我們在一個無限循環中利用它創建的HttpClient對本地承載的API發起調用,雖然服務端每三次調用只有一次是成功的,但是2次重試足以確保最終的調用是成功的,我們提供的調試斷言證實了這一點。