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("//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」。

Picture1
圖1 頻繁創建HttpClient導致的異常

[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("//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("//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("//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("//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("//www.foo.com"));
services.AddHttpClient("bar", httpClient => httpClient.BaseAddress = new Uri("//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("//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("//www.foo.com"));
services.AddHttpClient<BarClient>("bar", httpClient=> httpClient.BaseAddress = new Uri("//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, "//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次重試足以確保最終的調用是成功的,我們提供的調試斷言證實了這一點。