ASP.NET Core 6框架揭祕例項演示[17]:利用IHttpClientFactory工廠來建立HttpClient
在一個採用依賴注入框架的應用中,我們一般不太推薦利用手工建立的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%成功(S1207)。
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次重試足以確保最終的呼叫是成功的,我們提供的除錯斷言證實了這一點。