ASP.NET Core 3.1 MVC アプリから HttpClient を利用して他のサイトの ASP.NET Web API にアクセスして情報を取得する方法を書きます。
HttpClient のインスタンスを生成すると、そのたびにソケットも生成されます。しかし、HttpClient のインスタンスを Dispose してもソケットはクローズされないので(下記注参照)、何度も繰り返すとソケットの枯渇につながるという問題があり、それを避けるため、HttpClient のインスタンスはシングルトンにしてアプリで使いまわすということを行うそうです。ただし、そうすると DNS の変更が反映されないという別の問題があるそうですが。
注: Microsoft のドキュメント「ASP.NET Core パフォーマンスのベストプラクティス」によると "Closed HttpClient instances leave sockets open in the TIME_WAIT state for a short period of time" とのことです。別の記事「開発者を苦しめる.NETのHttpClientのバグと紛らわしいドキュメント」にはデフォルトは 4 分と書いてあります。
.NET Framework 版の ASP.NET Web アプリでの対処方法は Microsoft のドキュメント「Improper Instantiation antipattern」の How to fix the problem というセクションに書かれているのを見つけました。
そのドキュメントには、コントローラーに、
private static readonly HttpClient httpClient;
という static フィールドを設けて、コントローラーのコンストラクタで、
httpClient = new HttpClient();
とすると書いてあります。しかし、コントローラーのコンストラクタはクライアントから要求を受けるたびに呼び出されるので、要求を受けるたびに HttpClient のインスタンスを新たに作るということになってしまうと思うのですが・・・ 無知な自分には何故それが問題ないのか理解し難いです。
でも、まぁ、Microsoft のドキュメントですし、検証したようですし、.NET Framework 版の ASP.NET アプリでは他に適当な手はなさそうですし、もし問題が起きたら Microsoft のせいにできるので(笑)、その方法を使ってみるのが良いかもしれません。
しかし、Core 2.1 以降の ASP.NET Web アプリでは話が違ってくるようで、Microsoft の以下のドキュメントに書いてある IHttpClientFactory を利用する手段があるそうです。
詳しい仕組みの理解はちょっと置いといて、要するに上の一番目の記事の IHttpClientFactory の代替手段のセクションに書いてある以下の点を信じればよさそうです。(翻訳がイマイチなので英語版)
Using IHttpClientFactory in a DI-enabled app avoids:
-
Resource exhaustion problems by pooling HttpMessageHandler instances.
-
Stale DNS problems by cycling HttpMessageHandler instances at regular intervals.
上の 2 つの問題の前者は HttpClient のインスタンスの生成・廃棄を繰り返すことによるソケットの枯渇、後者はそれに対処するためシングルトンにして長期に使いまわすと DNS の変更が反映されないことを言っており、Core に備わっている DI 機能を使って IHttpClientFactory オブジェクトを注入する方法でそれらの問題を回避できるということのようです。
というわけで、詳しい仕組みは理解できてませんが、とりあえず上の一番目の記事の「基本的な使用方法」のセクションに従って実装してみました。
Startup.cs
namespace MvcCoreApp
{
public class Startup
{
// ・・・中略・・・
public void ConfigureServices(IServiceCollection services)
{
// 以下を追加。これにより IHttpClientFactory を DI できる
services.AddHttpClient();
// ・・・中略・・・
}
Controller / Action Method
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using System.Net.Http;
using System.IO;
using System.Text.Json;
using System.Text.Encodings.Web;
using System.Text.Unicode;
namespace MvcCoreApp.Controllers
{
public class IHttpClientFactoryController : Controller
{
private readonly IHttpClientFactory _clientFactory;
public IHttpClientFactoryController(
IHttpClientFactory clientFactory)
{
_clientFactory = clientFactory;
}
public async Task<IActionResult> Index()
{
var request = new HttpRequestMessage(HttpMethod.Get,
"https://localhost:44365/values");
HttpClient client = _clientFactory.CreateClient();
HttpResponseMessage response = await client.SendAsync(request);
List<Hero> list = null;
if (response.IsSuccessStatusCode)
{
using (Stream responseStream =
await response.Content.ReadAsStreamAsync())
{
list = await System.Text.Json.JsonSerializer.
DeserializeAsync<List<Hero>>(responseStream);
}
}
// JSON 文字列のエスケープ回避&インデント設定
return Json(list, new JsonSerializerOptions
{
Encoder = JavaScriptEncoder.Create(UnicodeRanges.All),
WriteIndented = true,
});
}
}
public class Hero
{
public int id { get; set; }
public string name { get; set; }
}
}
上のコントローラーの Index アクションメソッドを呼び出した結果がこの記事の上の方にある画像です。一応動くということを確認しただけで、ソケットの枯渇とか DNS の変更に対応できているかは分かりませんが。 (汗)
最後にもう一つ。ASP.NET Core 3.1 Web API が返す JSON 文字列のキーの最初の文字が小文字になってしまうことに注意してください。Web API でも同様で、デフォルトで camel casing になるということだそうです。なので、上の Hero クラスのプロパティの最初の文字を小文字にしておかないとデシリアライズに失敗します。camel casing になるのを回避する方法はあります。詳しくは別の記事「JsonSerializer の Camel Casing」を見てください。