「C Sharpとネットワーク - HttpClient」の版間の差分

提供:MochiuWiki : SUSE, EC, PCB
ナビゲーションに移動 検索に移動
(文字列「</source>」を「</syntaxhighlight>」に置換)
 
(同じ利用者による、間の14版が非表示)
1行目: 1行目:
== 概要 ==
== 概要 ==
HttpClientは、アプリケーションにおいてHTTPリクエストを投げたい時に使用するクラスである。<br>
<code>HttpClient</code>クラスは、HTTPリクエストを投げる場合に使用するクラスである。<br>
.NET Framework 4.5から提供された機能で、それまではHttpWebRequestやWebClientが存在したが、<br>
<br>
簡単にHTTPリクエストを投げられるクラスとして追加された。<br><br>
.NET Framework 4.0以前では、それまでは<code>HttpWebRequest</code>クラス、<code>WebClient</code>が使用されていた。<br>
<code>HttpClient</code>クラスは.NET Framework 4.5以降から提供された機能であり、簡単にHTTPリクエストを投げることができるクラスとして追加された。<br>
<br><br>


== HttpClientの仕様 ==
== HttpClientクラスの仕様 ==
HttpClientをインスタンス生成した時、内部では新しいソケットをオープンしている。<br>
<code>HttpClient</code>クラスのインスタンスを生成する時、内部では新しいソケットを開く。<br>
つまり、メソッドでHttpClientのインスタンスを生成すると、常に新しいソケットをオープンして、リソースを消費することになる。<br>
したがって、メソッド内で<code>HttpClient</code>クラスのインスタンスを生成する場合、常に新しいソケットを開くため、リソースを消費することになる。<br>
HttpClientのインスタンスを破棄した場合、ソケットがクローズされるタイミングは、状態がTIME_WAITに遷移して、暫く時間が経ってから解放される。<br>
<br>
これはリクエストする回数が少ないのであれば問題は無いが、大量にリクエストを行う場合は大きなボトルネックとなる。<br><br>
<code>HttpClient</code>クラスのインスタンスを破棄した場合、ソケットが閉じるタイミングは、状態が<code>TIME_WAIT</code>に遷移して、暫く時間が経つと自動的に解放される。<br>
<br>
これは、リクエストする頻度が少ない場合は問題無いが、大量にリクエストを行う場合は大きなボトルネックとなる。<br>
<br><br>
 
== アンチパターン ==
==== HttpClientクラス ====
<code>HttpClient</code>クラスのインスタンスの生成において、<code>IDisposable</code>インターフェースを実装しているので<code>using</code>ブロックで囲うものがある。<br>
しかし、これは通信を実行するごとにソケットを開くことにより、大量のリソースを消費してリソースが枯渇する場合がある。<br>
<br>
以下の例では、http://aspnetmonsters.com に対して、GETを行う10リクエストを開く。<br>
<syntaxhighlight lang="c#">
// アンチパターン
using System;
using System.Net.Http;
public class Program
{
    public static async Task Main(string[] args)
    {
      for (var i = 0; i < 10; i++)
      {
          using(var client = new HttpClient())
          {
            var result = await client.GetAsync("http://aspnetmonsters.com");
            Console.WriteLine(result.StatusCode);
          }
      }
      Console.WriteLine("Connections done");
    }
}
</syntaxhighlight>
<br>
次に、アプリケーションを終了して、netstatコマンドを実行してPCのソケットの状態を確認する。<br>
<br>
状態は<code>TIME_WAIT</code>であり、WebサイトをホストしているPCへの接続が開かれている状態である。<br>
これは、接続は閉じられているが、ネットワーク上で遅延が発生している可能性があるため、追加のパケットが送られてくるのを待つ状態である。<br>
Proto  Local Address          Foreign Address        State
TCP    10.211.55.6:12050      waws-prod-bay-017:http  TIME_WAIT
TCP    10.211.55.6:12051      waws-prod-bay-017:http  TIME_WAIT
TCP    10.211.55.6:12053      waws-prod-bay-017:http  TIME_WAIT
TCP    10.211.55.6:12054      waws-prod-bay-017:http  TIME_WAIT
TCP    10.211.55.6:12055      waws-prod-bay-017:http  TIME_WAIT
TCP    10.211.55.6:12056      waws-prod-bay-017:http  TIME_WAIT
TCP    10.211.55.6:12057      waws-prod-bay-017:http  TIME_WAIT
TCP    10.211.55.6:12058      waws-prod-bay-017:http  TIME_WAIT
TCP    10.211.55.6:12059      waws-prod-bay-017:http  TIME_WAIT
TCP    10.211.55.6:12060      waws-prod-bay-017:http  TIME_WAIT
TCP    10.211.55.6:12061      waws-prod-bay-017:http  TIME_WAIT
TCP    10.211.55.6:12062      waws-prod-bay-017:http  TIME_WAIT
...略
<br>
Windowsでは、デフォルトではTIME_WAITの状態で240秒間コネクションを保持する。<br>
これは、[HKEY_LOCAL_MACHINE_SYSTEM] - [CurrentControlSet] - [Services] - [Tcpip] - [Parameters] - [TcpTimedWaitDelay]で設定される。<br>
<br>
OSが新しいソケットを開くことが可能なスループットには限界があるため、コネクションプールを使い切ると、以下に示すようなエラーが表示される。<br>
Unable to connect to the remote server
System.Net.Sockets.SocketException: Only one usage of each socket address (protocol/network address/port) is normally permitted.
<br>
<u>ただし、OSのシステム変数を変更するのではなく、根本的な設計の問題を解決する必要がある。</u><br>
<br>


== 解決策 ==
==== HttpRequestMessageクラス ====
固定のリクエストヘッダや認証情報を付加した<code>HttpRequestMessage</code>クラスを使用する場合、共通の内部メソッドである<code>CreateRequest()</code>を使用する。<br>
これは、<code>HttpRequestMessage</code>クラスのインスタンスを生成した後、<code>SendAsync()</code>メソッドを使用してメッセージを送信する。<br>
<br>
<syntaxhighlight lang="c#">
var getReult = await client.GetAsync("http://kirakira-service.com/");
var postRsult = await client.PostAsync("http://sugoi-service.com/");
</syntaxhighlight>
<br>
==== Cookieのキャッシュ ====
Cookieの送受信を行う場合、Cookieがキャッシュされる。<br>
これは、<code>HttpClient</code>クラスのインスタンス生成時において、<code>UseCookies</code>プロパティを<code>false</code>にすることにより回避できる。<br>
<br>
もし、プロキシサーバを実装しており、かつ、Cookieを引き継ぐ必要がある場合は、Cookieヘッダを追加する。<br>
<syntaxhighlight lang="c#">
var handler = new HttpClientHandler()
{
    UseCookies = false,  // false : Cookieをキャッシュしない
                        // true  : Cookieをキャッシュする
};
var client = new HttpClient(handler);
</syntaxhighlight>
<br><br>
 
== ソリューション ==
==== 方法 1 : static / readonly ====
<code>HttpClient</code>クラスは、<code>private</code>キーワードおよび<code>static</code>キーワードを指定したプロパティとして持つ必要がある。<br>
<br>
Microsoftの公式ドキュメント[https://docs.microsoft.com/ja-jp/azure/architecture/antipatterns/improper-instantiation/ 不適切なインスタンス化のアンチパターン]の中でこの問題について取り上げており、<br>
Microsoftの公式ドキュメント[https://docs.microsoft.com/ja-jp/azure/architecture/antipatterns/improper-instantiation/ 不適切なインスタンス化のアンチパターン]の中でこの問題について取り上げており、<br>
HttpClientを使用した実装をする時は、インスタンスを静的変数(static)にして使用するとの記載がある。<br>
HttpClientを使用した実装をする時は、インスタンスを静的変数(static)にして使用するとの記載がある。<br>
下記のサンプルコードに実装方法を示す。<br><br>
<br>
 
まず、<code>HttpClient</code>クラスのオブジェクトを生成する。<br>
== サンプルコード ==
この時、タイムアウトの設定等はコンストラクタで行う必要がある。<br>
  <syntaxhighlight lang="cpp">
<br>
  class SmapleClass
複数の<code>HttoClient</code>クラスを使用して同時に実行する場合も、<code>HttpClient</code>はそのような使用を想定した設計となっている。<br>
<br>
<u>ただし、<code>static</code>キーワードを付加する場合、DNSの変更が反映されず、<code>HttpClient</code>クラスは(<code>HttpClientHandler</code>クラスを通じて)、ソケットが閉じるまでコネクションを無制限に使用し続ける。</u><br>
<code>HttpClient</code>クラスは、DNS TTLを尊重しており、デフォルトではこの値は1時間である。<br>
1時間過ぎれば、<code>HttpClient</code>クラスはDNSのエントリが有効であることを検証して、必要に応じて更新されたIPアドレスに対して新しいコネクションを作成する。<br>
<br>
そのため、<code>HttpClient</code>クラスのオブジェクトに、コネクションを自動的にリサイクルするように指定する。<br>
これは、アプリケーションの起動時において、アプリケーションで接続する全てのエンドポイント向けに1度だけ行う。 (エンドポイントが実行時に決まる場合は、決定する時に行う必要がある)<br>
時間は、1分〜5分程度に設定する方がよい。 (ホスト、ポート、スキーマが重要である)<br>
<br>
  <syntaxhighlight lang="c#">
  class SampleClass
  {
  {
     private static readonly HttpClient httpclient = null;
     private static readonly HttpClient httpclient = null;
26行目: 130行目:
     }
     }


     public async Task<SomeResponse> CallAPI()
     public async Task<SomeResponse> CallAPIAsync()
     {
     {
      var sp = ServicePointManager.FindServicePoint(new Uri("{URL}"));
      sp.ConnectionLeaseTimeout = 60 * 1000;  // コネクションのリサイクル時間 : 1分
       await httpclient.PostAsync("{URL}");
       await httpclient.PostAsync("{URL}");
       ...
       // ...
    }
}
</syntaxhighlight>
<br>
また、1つの<code>HttpClient</code>クラスは1つのソケット(1つのホスト)として使用した方がよいため、<br>
異なるホストにもリクエストを投げる場合は、別の<code>HttpClient</code>クラスのオブジェクトを生成する方がよい。<br>
<br>
==== 方法 2 : HttpClientFactory (単一のベースURI) ====
* 依存性注入 (DI) の設定
*: Host.CreateDefaultBuilderメソッドを使用して、.NETの標準的なDIコンテナを設定する。
*: サービスの登録はConfigureServicesメソッドで行う。
*: 異なるライフタイムスコープ (Transient, Scoped, Singleton) から適切なものを選択する。
<br>
HttpClientFactoryでは、2つのパターンがある。<br>
* 名前付きHttpClient
*: services.AddHttpClient("github", ...) で登録する。
*: IHttpClientFactory.CreateClient("github")で取得する。
*: 同じ名前で登録されたクライアントは同じ設定を共有する。
*: <br>
* 型付きHttpClient
*: services.AddHttpClient<GitHubService>メソッドで登録する。
*: コンストラクタインジェクションで自動的に注入する。
*: サービスごとに特化した実装が可能になる。
<br>
Nugetを使用して、以下に示すライブラリをインストールする。<br>
* Microsoft.Extensions.Http
* Microsoft.Extensions.Hosting
<br>
<syntaxhighlight lang="c#">
using System;
using System.Threading.Tasks;
using System.Net.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
class Program
{
    static async Task Main(string[] args)
    {
        // GenericHostを使用してアプリケーションを構築
        // これにより、依存性注入、構成、ログ等の機能が利用可能になる
        var host = Host.CreateDefaultBuilder(args).ConfigureServices((context, services) => {
          // 方法 1 : 名前付きHttpClientを使用する場合
          // 名前付きHttpClientの登録
          // "github"という名前で、GitHubのAPIにアクセスするためのHttpClientを設定
          services.AddHttpClient("github", client => {
              // ベースとなるURIを設定
              client.BaseAddress = new Uri("https://api.github.com/");
              // User-Agentヘッダを設定 (GitHubのAPIでは必須)
              client.DefaultRequestHeaders.Add("User-Agent", "HttpClientFactory-Sample");
          });
          // 方法 2 : 型付きHttpClientを使用する場合
          // 型付きHttpClientの登録
          // GitHubServiceクラスに特化したHttpClientを自動的に注入
          services.AddHttpClient<GitHubService>();
          // ExampleServiceをDIコンテナに登録
          // TransientスコープでサービスをDIコンテナに登録(毎回新しいインスタンスが作成される)
          services.AddTransient<IExampleService, ExampleService>();
        }).Build();
        // DIコンテナからサービスを取得し、実行
        var service = host.Services.GetRequiredService<IExampleService>();
        await service.RunExample();
    }
}
/// <summary>
/// 型付きHttpClientを使用するサービスクラス
/// GitHubのAPIに特化した操作を提供
/// </summary>
public class GitHubService
{
    private readonly HttpClient _httpClient;
    // コンストラクタインジェクション
    // DIコンテナにより、設定済みのHttpClientが自動的に注入される
    public GitHubService(HttpClient client)
    {
      _httpClient = client;
      // このHttpClientインスタンスに対する固有の設定
      _httpClient.BaseAddress = new Uri("https://api.github.com/");
      _httpClient.DefaultRequestHeaders.Add("User-Agent", "HttpClientFactory-Sample");
    }
    /// <summary>
    /// GitHubのAPIからデータを取得する
    /// </summary>
    /// <returns>API応答の文字列</returns>
    public async Task<string> GetApiResponse()
    {
      // dotnet/runtimeリポジトリの情報を取得
      return await _httpClient.GetStringAsync("repos/dotnet/runtime");
    }
}
/// <summary>
/// サービスのインターフェース定義
/// 依存性の注入とテストを容易にするために使用
/// </summary>
public interface IExampleService
{
    Task RunExample();
}
/// <summary>
/// HttpClientFactoryの使用例を示すサービスクラス
/// 名前付きHttpClientと型付きHttpClientの両方の使用例を提供
/// </summary>
public class ExampleService : IExampleService
{
    private readonly IHttpClientFactory _clientFactory;
    private readonly GitHubService _githubService;
    /// <summary>
    /// コンストラクタで依存関係を注入
    /// </summary>
    /// <param name="clientFactory">HttpClientFactory - 名前付きクライアントの作成に使用</param>
    /// <param name="githubService">GitHubService - 型付きHttpClientの例として使用</param>
    public ExampleService(IHttpClientFactory clientFactory, GitHubService githubService)
    {
      _clientFactory = clientFactory;
      _githubService = githubService;
    }
    /// <summary>
    /// HttpClientFactoryの両方の使用パターンを実演
    /// </summary>
    public async Task RunExample()
    {
      // 方法 1 : 名前付きHttpClientの使用例
      try
      {
          // "github"という名前で設定されたHttpClientを取得
          var client = _clientFactory.CreateClient("github");
          var response = await client.GetStringAsync("repos/dotnet/runtime");
          Console.WriteLine("Named HttpClient Response:");
          Console.WriteLine(response.Substring(0, 200) + "...");
      }
      catch (Exception ex)
      {
          Console.WriteLine($"Named client error: {ex.Message}");
      }
      // // 方法 2 : 型付きHttpClientの使用例
      try
      {
          // 注入されたGitHubServiceを使用
          var response = await _githubService.GetApiResponse();
          Console.WriteLine("Typed HttpClient Response:");
          Console.WriteLine(response.Substring(0, 200) + "...");
      }
      catch (Exception ex)
      {
          Console.WriteLine($"Typed client error: {ex.Message}");
      }
    }
}
</syntaxhighlight>
<br>
==== 方法 3 : HttpClientFactory (複数のベースURI) ====
* 名前付きHttpClientを使用する方法
** メリット
**: 個別の設定が容易
** デメリット
**: 文字列ベースの名前指定
**: 型安全性が低い
*: <br>
* 型付きHttpClientを使用する方法
** メリット
**: 型安全性が高い
**: APIごとに特化した実装が可能
**: テストが容易
** デメリット
**: クラス数が増加
**: 各APIに対して個別の実装が必要
*: <br>
* 動的にベースURIを切り替える方法
** メリット
**: 柔軟性が高い
**: 設定ファイルでの管理が容易
**: 実行時の切り替えが可能
** デメリット
**: 複雑な実装
<br>
<syntaxhighlight lang="json">
# 動的なベースURI切り替えで使用
# appsettings.json
{
  "ApiSettings": {
    "BaseUrls": {
      "github": "https://api.github.com/",
      "weather": "https://api.weather.com/",
      "other": "https://api.other.com/"
    }
  }
}
</syntaxhighlight>
<br>
<syntaxhighlight lang="c#">
using System;
using System.Threading.Tasks;
using System.Net.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
class Program
{
    static async Task Main(string[] args)
    {
      var host = Host.CreateDefaultBuilder(args).ConfigureServices((context, services) => {
          // 方法 1 : 異なる名前で複数のHttpClientを登録
          services.AddHttpClient("github", client => {
            client.BaseAddress = new Uri("https://api.github.com/");
            client.DefaultRequestHeaders.Add("User-Agent", "HttpClientFactory-Sample");
          });
          services.AddHttpClient("weather", client => {
            client.BaseAddress = new Uri("https://api.weather.com/");
            client.DefaultRequestHeaders.Add("User-Agent", "Weather-Service");
          });
          // 方法 2 : 型付きHttpClientを各APIサービス用に登録
          services.AddHttpClient<GitHubService>();
          services.AddHttpClient<WeatherService>();
          // 方法 3 : 設定情報を含むサービスを登録
          services.Configure<ApiSettings>(context.Configuration.GetSection("ApiSettings"));
          services.AddHttpClient<MultiBaseUriService>();
          services.AddTransient<IMultiApiService, MultiApiService>();
      }).Build();
      var service = host.Services.GetRequiredService<IMultiApiService>();
      await service.RunExample();
    }
}
// APIの設定を保持するクラス
public class ApiSettings
{
    public Dictionary<string, string> BaseUrls { get; set; } = new();
}
// 複数のベースURIを扱うサービス
public class MultiBaseUriService
{
    private readonly HttpClient _httpClient;
    private readonly ApiSettings _settings;
    private readonly Dictionary<string, string> _baseUrls;
    public MultiBaseUriService(HttpClient client, IOptions<ApiSettings> settings)
    {
      _httpClient = client;
      _settings = settings.Value;
      _baseUrls = _settings.BaseUrls;
    }
    public async Task<string> SendRequest(string apiKey, string endpoint)
    {
      if (!_baseUrls.TryGetValue(apiKey, out var baseUrl))
      {
          throw new ArgumentException($"Unknown API key: {apiKey}");
      }
      var fullUrl = new Uri(new Uri(baseUrl), endpoint);
      return await _httpClient.GetStringAsync(fullUrl);
    }
}
// GithubのAPI用サービス
public class GitHubService
{
    private readonly HttpClient _httpClient;
    public GitHubService(HttpClient client)
    {
      _httpClient = client;
      _httpClient.BaseAddress = new Uri("https://api.github.com/");
      _httpClient.DefaultRequestHeaders.Add("User-Agent", "GitHub-Service");
    }
    public async Task<string> GetRepositoryInfo(string repo)
    {
      return await _httpClient.GetStringAsync($"repos/{repo}");
    }
}
// 気象API用サービス
public class WeatherService
{
    private readonly HttpClient _httpClient;
    public WeatherService(HttpClient client)
    {
      _httpClient = client;
      _httpClient.BaseAddress = new Uri("https://api.weather.com/");
      _httpClient.DefaultRequestHeaders.Add("User-Agent", "Weather-Service");
    }
    public async Task<string> GetWeatherInfo(string location)
    {
      return await _httpClient.GetStringAsync($"weather/{location}");
    }
}
// 複数APIを利用するサービスのインターフェース
public interface IMultiApiService
{
    Task RunExample();
}
// 複数のAPIを利用する実装
public class MultiApiService : IMultiApiService
{
    private readonly IHttpClientFactory  _clientFactory;
    private readonly GitHubService      _githubService;
    private readonly WeatherService      _weatherService;
    private readonly MultiBaseUriService _multiBaseUriService;
    public MultiApiService(IHttpClientFactory clientFactory, GitHubService githubService, WeatherService weatherService,
                          MultiBaseUriService multiBaseUriService)
    {
      _clientFactory = clientFactory;
      _githubService = githubService;
      _weatherService = weatherService;
      _multiBaseUriService = multiBaseUriService;
    }
    public async Task RunExample()
    {
      // 方法 1 : 名前付きHttpClientの使用
      try
      {
          var githubClient = _clientFactory.CreateClient("github");
          var weatherClient = _clientFactory.CreateClient("weather");
          var githubResponse = await githubClient.GetStringAsync("repos/dotnet/runtime");
          var weatherResponse = await weatherClient.GetStringAsync("weather/tokyo");
          Console.WriteLine("Named Clients Response:");
          Console.WriteLine($"GitHub: {githubResponse.Substring(0, 100)}...");
          Console.WriteLine($"Weather: {weatherResponse.Substring(0, 100)}...");
      }
      catch (Exception ex)
      {
          Console.WriteLine($"Named clients error: {ex.Message}");
      }
      // 方法 2 : 型付きHttpClientの使用
      try
      {
          var githubInfo = await _githubService.GetRepositoryInfo("dotnet/runtime");
          var weatherInfo = await _weatherService.GetWeatherInfo("tokyo");
          Console.WriteLine("\nTyped Clients Response:");
          Console.WriteLine($"GitHub: {githubInfo.Substring(0, 100)}...");
          Console.WriteLine($"Weather: {weatherInfo.Substring(0, 100)}...");
      }
      catch (Exception ex)
      {
          Console.WriteLine($"Typed clients error: {ex.Message}");
      }
      // 方法 3 : 動的なベースURI切り替え
      try
      {
          var githubResponse = await _multiBaseUriService.SendRequest("github", "repos/dotnet/runtime");
          var weatherResponse = await _multiBaseUriService.SendRequest("weather", "weather/tokyo");
          Console.WriteLine("\nMulti Base URI Service Response:");
          Console.WriteLine($"GitHub: {githubResponse.Substring(0, 100)}...");
          Console.WriteLine($"Weather: {weatherResponse.Substring(0, 100)}...");
      }
      catch (Exception ex)
      {
          Console.WriteLine($"Multi base URI error: {ex.Message}");
      }
     }
     }
}
</syntaxhighlight>
<br><br>
== HTTP通信 ==
==== ベースとなるクラス ====
<syntaxhighlight lang="c#">
// 通信先のベースURL
private readonly string baseUrl;
// HTTPクライアント
private readonly HttpClient httpClient;
// コンストラクタ
public SampleServiceHttpClient(string baseUrl)
{
    this.baseUrl = baseUrl;
    this.httpClient = new HttpClient();
  }
  }
  </syntaxhighlight>
  </syntaxhighlight>
<br>
<br>
上記のように記述して、HttpClientのオブジェクトを使用する。(TimeOutの設定等はコンストラクタで行う)<br>
==== GET ====
同時実行の場合も、HttpClientはそのような利用を想定した設計となっている。<br><br>
URLに情報を付加してGETリクエストを送受信する。<br>
<br>
<code>HttpClient</code>クラスの<code>SendAsync()</code>メソッドは、<code>HttpResponseMessage</code>クラスを返す。<br>
レスポンスが取得できるため、ステータスコードやボディを確認および使用することができる。<br>
<br>
JSON以外のテキストファイルやPDFファイル等をダウンロードする場合、レスポンスのボディにファイル内容が入ることがある。<br>
<syntaxhighlight lang="c#">
// URLに情報を付加してGETリクエストを送受信する
public string Get(string someId)
{
    String requestEndPoint = this.baseUrl + "/some/search/?someId=" + someId;
    var request = this.CreateRequest(HttpMethod.Get, requestEndPoint);
    string resBodyStr;
    var resStatusCoode = HttpStatusCode.NotFound;
    Task<HttpResponseMessage> response;
    // 通信の実行
    // 引数にrequestを使用する場合は、GetAsync()やPostAsync()ではなく、SendAsync()である
    try
    {
      response = httpClient.SendAsync(request);
      resBodyStr = response.Result.Content.ReadAsStringAsync().Result;
      resStatusCoode = response.Result.StatusCode;
    }
    catch (HttpRequestException e)
    {
      return null;
    }
    if (!resStatusCoode.Equals(HttpStatusCode.OK))
    {  // レスポンスが200以外の場合
      return null;
    }
    if (String.IsNullOrEmpty(resBodyStr))
    {  // レスポンスのボディが空の場合
      return null;
    }
 
    return resBodyStr;
}
// HTTPリクエストメッセージを生成する
// httpMethod : HTTPメソッドのオブジェクト
// requestEndPoint : 通信先のURL
private HttpRequestMessage CreateRequest(HttpMethod httpMethod, string requestEndPoint)
{
    var request = new HttpRequestMessage(httpMethod, requestEndPoint);
    return this.AddHeaders(request);
}
// HTTPリクエストにヘッダーを追加する
// request : リクエスト
private HttpRequestMessage AddHeaders(HttpRequestMessage request)
{
    request.Headers.Add("Accept", "application/json");
    request.Headers.Add("Accept-Charset", "utf-8");
    // 例えば、認証通過後のトークンが "Authorization: Bearer {トークンの文字列}" のように必要な場合は追加する
    return request;
}
</syntaxhighlight>
<br>
 
==== POST : テキストファイル ====
以下の例では、リクエストのボディにJSON形式の内容を格納して送受信している。<br>
JSONは、JS、Ruby、Python、PHP等では連想配列を使用して簡単に作成できるが、C#においては<code>Dictionary</code>型を使用および変換後、<code>StringContent</code>型に情報を付加してリクエストに格納する。<br>
<br>
APIの認証方式において、事前に与えられているAPIキーや他の認証情報の文字列を送信する時、認証が成功した場合はアクセストークンを返却して、以降はそのアクセストークンをリクエストヘッダに追加してAPIを呼ぶことがある。<br>
その場合は、以下の例と同様の処理となる。<br>
<br>
リクエストのボディにおいて、引数に指定の形式でAPIキー等を格納してPOSTで送信する時、レスポンスのボディの一部にトークンが格納されて返される。<br>
以下の例では、内部メソッドであるAddHeaders()において固定のヘッダの追加のみを行っているが、ここにトークンが格納される場合は追加する処理を記述することにより使い回すことができる。<br>
<syntaxhighlight lang="c#">
// リクエストのボディに文字列のキーをJSON形式で格納してPOSTを送受信する
public string Post(string someKey)
{
    String requestEndPoint = this.baseUrl + "some/post";
    var request = this.CreateRequest(HttpMethod.Post, requestEndPoint);
    var jsonDict = new Dictionary<string, string>() {
                      {"someKey", someKey},
                  };
    var reqBodyJson = JsonSerializer.Serialize(jsonDict, this.GetJsonOption());
    var content = new StringContent(reqBodyJson, Encoding.UTF8, @"application/json");
    request.Content = content;
    string resBodyStr;
    var resStatusCoode = HttpStatusCode.NotFound;
    Task<HttpResponseMessage> response;
    try
    {
      response = httpClient.SendAsync(request);
      resBodyStr = response.Result.Content.ReadAsStringAsync().Result;
      resStatusCoode = response.Result.StatusCode;
    }
    catch (HttpRequestException e)
    {  // 通信が失敗した場合
      return null;
    }
    if (!resStatusCoode.Equals(HttpStatusCode.OK))
    {  // レスポンスが200以外の場合
      return null;
    }
    if (String.IsNullOrEmpty(resBodyStr))
    {  // レスポンスのボディが空の場合
      return null;
    }
    // 取得した内容
    return resBodyStr;
}
// HTTPリクエストメッセージを生成する
// httpMethod : HTTPメソッドのオブジェクト
// requestEndPoint : 通信先のURL
private HttpRequestMessage CreateRequest(HttpMethod httpMethod, string requestEndPoint)
{
    var request = new HttpRequestMessage(httpMethod, requestEndPoint);
    return this.AddHeaders(request);
}
// HTTPリクエストにヘッダーを追加する
// request : リクエスト
private HttpRequestMessage AddHeaders(HttpRequestMessage request)
{
    request.Headers.Add("Accept", "application/json");
    request.Headers.Add("Accept-Charset", "utf-8");
    // 例えば、認証通過後のトークンが "Authorization: Bearer {トークンの文字列}" のように必要な場合は追加する
    return request;
}
</syntaxhighlight>
<br>
==== POST : バイナリファイル ====
POSTリクエストは、テキストファイルやPDFファイル等のバイナリファイルをアップロードすることができる。<br>
バイナリファイルを送信するためには、MIMEタイプを<code>multipart/form-data</code>に指定する。<br>
<br>
以下の例では、マルチパートで区切ることにより、複数のデータを送信している。<br>
マルチパートの1つ目は<code>StringContent</code>型でテキストデータ、マルチパートの2つ目は<code>StreamContent</code>型でバイナリファイルのデータである。<br>
これら2つのデータをボディ部に設定して、POSTリクエストで送信している。<br>
<syntaxhighlight lang="c#">
// テキストデータおよびバイナリデータをボディ部にセットして、POSTリクエストを送受信する。
// filePath : アップロードするバイナリファイルのパス
public string PostPdfFile(string filePath)
{
    String requestEndPoint = this.baseUrl + "resume/upload";
    var request = this.CreateRequest(HttpMethod.Post, requestEndPoint);
    // Accept: multipart/form-dataを指定
    request.Headers.Remove("Accept");
    request.Headers.Add("Accept", "multipart/form-data");
    // 生成するボディ部
    // Content-Type  : multipart/form-data; boundary = "{MultipartFormDataContentクラスが自動で設定}"
    // Content-Length: {MultipartFormDataContentクラスが自動で設定}
    var content = new MultipartFormDataContent();
    // ボディ部1に--boundaryで区切られたマルチパートのテキストデータを追加
    // --boundary
    // Content-Type: text/plain; charset=utf-8
    // Content-Disposition: form-data; name=SamplePart
    //
    // Sample
    var multiDocumentsContent = new StringContent("Sample");
    content.Add(multiDocumentsContent, "SamplePart");
    StreamContent streamContent = null;
    var resStatusCoode = HttpStatusCode.NotFound;
    Task<HttpResponseMessage> response;
    String resBodyStr;
    using (var fileStream = File.OpenRead(filePath))
    {
      streamContent = new StreamContent(fileStream);
      // {Content-Disposition: form-data; name=file; filename="{ファイル名}"]
      //streamContent.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data")
      //{
      //  Name = "file",
      //  FileName = Path.GetFileName(filePath)
      //};
      // ファイル名がマルチバイト文字の場合は文字化けするため、ファイル名を手動でエンコードしてヘッダを別に作成する
      var finfo = new FileInfo(filePath);
      var headerStr = string.Format("form-data; name=\"file\"; filename=\"{0}\"", finfo.Name);
      var headerValueByteArray = Encoding.UTF8.GetBytes(headerStr);
      var encodedHeaderValue = new StringBuilder();
      foreach (var b in headerValueByteArray)
      {
          encodedHeaderValue.Append((char)b);
      }
      streamContent.Headers.ContentDisposition = null; // デフォルトで用意されているので一旦削除
      streamContent.Headers.Add("Content-Disposition", encodedHeaderValue.ToString());
      // バイナリファイル (PDFファイル)
      streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
      streamContent.Headers.Add("Content-Length", fileStream.Length.ToString());
      content.Add(streamContent, "file");
      // 生成するボディ部
      // ボディ部2に--boundaryから--boundary--までで区切られたマルチパートのバイナリデータを追加
      // --boundary
      // Content-Disposition: form-data; name="file"; filename="{エンコードされたファイル名}"
      // Content-Type: application/pdf
      // Content-Length: {上で計算された値}
      //
      // {バイナリファイルの実体}
      // --boundary--
      // 2つの部分を加えたボディ部をPOSTリクエストとして送信する
      request.Content = content;
      try
      {
          response = httpClient.SendAsync(request);
          resBodyStr = response.Result.Content.ReadAsStringAsync().Result;
          resStatusCoode = response.Result.StatusCode;
      }
      catch (HttpRequestException e)
      {  // 通信が失敗した場合
          return null;
      }
      fileStream.Close();
    }
    if (!resStatusCoode.Equals(HttpStatusCode.OK))
    {  // レスポンスが200以外の場合
      return null;
    }
    if (String.IsNullOrEmpty(resBodyStr))
    {  // レスポンスのボディが空の場合
      return null;
    }
    return resBodyStr;
}
</syntaxhighlight>
<br>
 
==== MIMEタイプ (コンテンツタイプ) ====
application/x-www-form-urlencodedおよびmultipart/form-dataは、HTTPのPOSTリクエストでデータを送信するための異なるMIMEタイプである。<br>
<br>
* application/x-www-form-urlencoded
*: HTMLフォームデータをエンコードして送信するためのデフォルトのMIMEタイプである。
*: データはキーと値のペアになり、それらのペアは<u>key1=value1&key2=value2</u>のように<code>&</code>で区切られる。
*: <br>
*: 特殊文字は、パーセントエンコーディング(%エンコーディング)される。
*: 例: スペースは%20にエンコーディングされる。
*: <br>
*: 一般的に、簡単なフォームデータやクエリ文字列を送信する場合に使用する。
*: <br>
* multipart/form-data
*: バイナリデータや大きなファイルを含む複数のデータタイプをサポートするためのMIMEタイプである。
*: <br>
*: データは複数の部分に分かれており、各部分にはヘッダがあり、ヘッダにはContent-Dispositionが含まれる。
*: このヘッダは、データがどのように処理されるべきかを示している。
*: <br>
*: バイナリデータを直接送信するために使用され、一般的には、ファイルのアップロード等で使用される。
*: <br>
*: <code>--boundary</code>、<code>--boundary--</code>は、各部分の開始と終了を示すために使用され、部分はそれぞれ独立して処理される。
*: どちらを選択するかは、データの種類と送信する内容に依存する。
*: 一般的には、HTMLフォームでテキストデータを送信する場合は<u>application/x-www-form-urlencoded</u>が使用され、ファイルのアップロード等でバイナリデータを送信する場合は<u>multipart/form-data</u>が使用される。
<syntaxhighlight lang="css">
/* 例: */
 
--boundary
Content-Disposition: form-data; name="key1"
value1
--boundary
Content-Disposition: form-data; name="key2"
value2
--boundary--
</syntaxhighlight>
<br>
 
==== DELETE ====
<code>GET</code>と同様、HTTPメソッドの<code>DELETE</code>を指定して<code>HttpRequestMessage</code>クラスのインスタンスを生成して渡す。<br>
<br>
JSからの通信では、あまり使用しない。<br>
<syntaxhighlight lang="c#">
// URLに情報を付加してDELETEを送受信する
public bool Delete(string someId)
{
    String requestEndPoint = this.baseUrl + "some/" + someId;
    var request = this.CreateRequest(HttpMethod.Delete, requestEndPoint);
    var resStatusCoode = HttpStatusCode.NotFound;
    Task<HttpResponseMessage> response;
    String resBodyStr;
    try
    {
      response = httpClient.SendAsync(request);
      resBodyStr = response.Result.Content.ReadAsStringAsync().Result;
      resStatusCoode = response.Result.StatusCode;
    }
    catch (HttpRequestException e)
    {  // 通信が失敗した場合
      return false;
    }
    if (!resStatusCoode.Equals(HttpStatusCode.OK))
    {  // レスポンスが200以外の場合
      return false;
    }
    if (String.IsNullOrEmpty(resBodyStr))
    {  // レスポンスのボディが空の場合
      return false;
    }
    return true;
}
</syntaxhighlight>
<br><br>


また、他の注意点を挙げると、1つのHttpClientオブジェクトで1つのソケット(1つのホスト)なので、<br>
異なるホストにもリクエストを投げる場合は、別のオブジェクトを生成する方が良い。<br><br>


__FORCETOC__
__FORCETOC__
[[カテゴリ:C_Sharp]]
[[カテゴリ:C_Sharp]]

2024年11月20日 (水) 19:38時点における最新版

概要

HttpClientクラスは、HTTPリクエストを投げる場合に使用するクラスである。

.NET Framework 4.0以前では、それまではHttpWebRequestクラス、WebClientが使用されていた。
HttpClientクラスは.NET Framework 4.5以降から提供された機能であり、簡単にHTTPリクエストを投げることができるクラスとして追加された。


HttpClientクラスの仕様

HttpClientクラスのインスタンスを生成する時、内部では新しいソケットを開く。
したがって、メソッド内でHttpClientクラスのインスタンスを生成する場合、常に新しいソケットを開くため、リソースを消費することになる。

HttpClientクラスのインスタンスを破棄した場合、ソケットが閉じるタイミングは、状態がTIME_WAITに遷移して、暫く時間が経つと自動的に解放される。

これは、リクエストする頻度が少ない場合は問題無いが、大量にリクエストを行う場合は大きなボトルネックとなる。


アンチパターン

HttpClientクラス

HttpClientクラスのインスタンスの生成において、IDisposableインターフェースを実装しているのでusingブロックで囲うものがある。
しかし、これは通信を実行するごとにソケットを開くことにより、大量のリソースを消費してリソースが枯渇する場合がある。

以下の例では、http://aspnetmonsters.com に対して、GETを行う10リクエストを開く。

 // アンチパターン
 
 using System;
 using System.Net.Http;
 
 public class Program
 {
    public static async Task Main(string[] args) 
    {
       for (var i = 0; i < 10; i++)
       {
          using(var client = new HttpClient())
          {
             var result = await client.GetAsync("http://aspnetmonsters.com");
             Console.WriteLine(result.StatusCode);
          }
       }
 
       Console.WriteLine("Connections done");
    }
 }


次に、アプリケーションを終了して、netstatコマンドを実行してPCのソケットの状態を確認する。

状態はTIME_WAITであり、WebサイトをホストしているPCへの接続が開かれている状態である。
これは、接続は閉じられているが、ネットワーク上で遅延が発生している可能性があるため、追加のパケットが送られてくるのを待つ状態である。

Proto  Local Address          Foreign Address         State
TCP    10.211.55.6:12050      waws-prod-bay-017:http  TIME_WAIT
TCP    10.211.55.6:12051      waws-prod-bay-017:http  TIME_WAIT
TCP    10.211.55.6:12053      waws-prod-bay-017:http  TIME_WAIT
TCP    10.211.55.6:12054      waws-prod-bay-017:http  TIME_WAIT
TCP    10.211.55.6:12055      waws-prod-bay-017:http  TIME_WAIT
TCP    10.211.55.6:12056      waws-prod-bay-017:http  TIME_WAIT
TCP    10.211.55.6:12057      waws-prod-bay-017:http  TIME_WAIT
TCP    10.211.55.6:12058      waws-prod-bay-017:http  TIME_WAIT
TCP    10.211.55.6:12059      waws-prod-bay-017:http  TIME_WAIT
TCP    10.211.55.6:12060      waws-prod-bay-017:http  TIME_WAIT
TCP    10.211.55.6:12061      waws-prod-bay-017:http  TIME_WAIT
TCP    10.211.55.6:12062      waws-prod-bay-017:http  TIME_WAIT

...略


Windowsでは、デフォルトではTIME_WAITの状態で240秒間コネクションを保持する。
これは、[HKEY_LOCAL_MACHINE_SYSTEM] - [CurrentControlSet] - [Services] - [Tcpip] - [Parameters] - [TcpTimedWaitDelay]で設定される。

OSが新しいソケットを開くことが可能なスループットには限界があるため、コネクションプールを使い切ると、以下に示すようなエラーが表示される。

Unable to connect to the remote server
System.Net.Sockets.SocketException: Only one usage of each socket address (protocol/network address/port) is normally permitted.


ただし、OSのシステム変数を変更するのではなく、根本的な設計の問題を解決する必要がある。

HttpRequestMessageクラス

固定のリクエストヘッダや認証情報を付加したHttpRequestMessageクラスを使用する場合、共通の内部メソッドであるCreateRequest()を使用する。
これは、HttpRequestMessageクラスのインスタンスを生成した後、SendAsync()メソッドを使用してメッセージを送信する。

 var getReult = await client.GetAsync("http://kirakira-service.com/");
 var postRsult = await client.PostAsync("http://sugoi-service.com/");


Cookieのキャッシュ

Cookieの送受信を行う場合、Cookieがキャッシュされる。
これは、HttpClientクラスのインスタンス生成時において、UseCookiesプロパティをfalseにすることにより回避できる。

もし、プロキシサーバを実装しており、かつ、Cookieを引き継ぐ必要がある場合は、Cookieヘッダを追加する。

 var handler = new HttpClientHandler()
 {
    UseCookies = false,  // false : Cookieをキャッシュしない
                         // true  : Cookieをキャッシュする
 };
 
 var client = new HttpClient(handler);



ソリューション

方法 1 : static / readonly

HttpClientクラスは、privateキーワードおよびstaticキーワードを指定したプロパティとして持つ必要がある。

Microsoftの公式ドキュメント不適切なインスタンス化のアンチパターンの中でこの問題について取り上げており、
HttpClientを使用した実装をする時は、インスタンスを静的変数(static)にして使用するとの記載がある。

まず、HttpClientクラスのオブジェクトを生成する。
この時、タイムアウトの設定等はコンストラクタで行う必要がある。

複数のHttoClientクラスを使用して同時に実行する場合も、HttpClientはそのような使用を想定した設計となっている。

ただし、staticキーワードを付加する場合、DNSの変更が反映されず、HttpClientクラスは(HttpClientHandlerクラスを通じて)、ソケットが閉じるまでコネクションを無制限に使用し続ける。
HttpClientクラスは、DNS TTLを尊重しており、デフォルトではこの値は1時間である。
1時間過ぎれば、HttpClientクラスはDNSのエントリが有効であることを検証して、必要に応じて更新されたIPアドレスに対して新しいコネクションを作成する。

そのため、HttpClientクラスのオブジェクトに、コネクションを自動的にリサイクルするように指定する。
これは、アプリケーションの起動時において、アプリケーションで接続する全てのエンドポイント向けに1度だけ行う。 (エンドポイントが実行時に決まる場合は、決定する時に行う必要がある)
時間は、1分〜5分程度に設定する方がよい。 (ホスト、ポート、スキーマが重要である)

 class SampleClass
 {
    private static readonly HttpClient httpclient = null;
    
    static SampleClass()
    {
        httpclient = new HttpClient();
    }

    public async Task<SomeResponse> CallAPIAsync()
    {
       var sp = ServicePointManager.FindServicePoint(new Uri("{URL}"));
       sp.ConnectionLeaseTimeout = 60 * 1000;  // コネクションのリサイクル時間 : 1分
 
       await httpclient.PostAsync("{URL}");
 
       // ...略
    }
 }


また、1つのHttpClientクラスは1つのソケット(1つのホスト)として使用した方がよいため、
異なるホストにもリクエストを投げる場合は、別のHttpClientクラスのオブジェクトを生成する方がよい。

方法 2 : HttpClientFactory (単一のベースURI)

  • 依存性注入 (DI) の設定
    Host.CreateDefaultBuilderメソッドを使用して、.NETの標準的なDIコンテナを設定する。
    サービスの登録はConfigureServicesメソッドで行う。
    異なるライフタイムスコープ (Transient, Scoped, Singleton) から適切なものを選択する。


HttpClientFactoryでは、2つのパターンがある。

  • 名前付きHttpClient
    services.AddHttpClient("github", ...) で登録する。
    IHttpClientFactory.CreateClient("github")で取得する。
    同じ名前で登録されたクライアントは同じ設定を共有する。

  • 型付きHttpClient
    services.AddHttpClient<GitHubService>メソッドで登録する。
    コンストラクタインジェクションで自動的に注入する。
    サービスごとに特化した実装が可能になる。


Nugetを使用して、以下に示すライブラリをインストールする。

  • Microsoft.Extensions.Http
  • Microsoft.Extensions.Hosting


 using System;
 using System.Threading.Tasks;
 using System.Net.Http;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Hosting;
 
 class Program
 {
    static async Task Main(string[] args)
    {
        // GenericHostを使用してアプリケーションを構築
        // これにより、依存性注入、構成、ログ等の機能が利用可能になる
        var host = Host.CreateDefaultBuilder(args).ConfigureServices((context, services) => {
           // 方法 1 : 名前付きHttpClientを使用する場合
           // 名前付きHttpClientの登録
           // "github"という名前で、GitHubのAPIにアクセスするためのHttpClientを設定
           services.AddHttpClient("github", client => {
              // ベースとなるURIを設定
              client.BaseAddress = new Uri("https://api.github.com/");
 
              // User-Agentヘッダを設定 (GitHubのAPIでは必須)
              client.DefaultRequestHeaders.Add("User-Agent", "HttpClientFactory-Sample");
           });
 
           // 方法 2 : 型付きHttpClientを使用する場合
           // 型付きHttpClientの登録
           // GitHubServiceクラスに特化したHttpClientを自動的に注入
           services.AddHttpClient<GitHubService>();
 
           // ExampleServiceをDIコンテナに登録
           // TransientスコープでサービスをDIコンテナに登録(毎回新しいインスタンスが作成される)
           services.AddTransient<IExampleService, ExampleService>();
        }).Build();
 
        // DIコンテナからサービスを取得し、実行
        var service = host.Services.GetRequiredService<IExampleService>();
        await service.RunExample();
    }
 }
 
 /// <summary>
 /// 型付きHttpClientを使用するサービスクラス
 /// GitHubのAPIに特化した操作を提供
 /// </summary>
 public class GitHubService
 {
    private readonly HttpClient _httpClient;
 
    // コンストラクタインジェクション
    // DIコンテナにより、設定済みのHttpClientが自動的に注入される
    public GitHubService(HttpClient client)
    {
       _httpClient = client;
       // このHttpClientインスタンスに対する固有の設定
       _httpClient.BaseAddress = new Uri("https://api.github.com/");
       _httpClient.DefaultRequestHeaders.Add("User-Agent", "HttpClientFactory-Sample");
    }
 
    /// <summary>
    /// GitHubのAPIからデータを取得する
    /// </summary>
    /// <returns>API応答の文字列</returns>
    public async Task<string> GetApiResponse()
    {
       // dotnet/runtimeリポジトリの情報を取得
       return await _httpClient.GetStringAsync("repos/dotnet/runtime");
    }
 }
 
 /// <summary>
 /// サービスのインターフェース定義
 /// 依存性の注入とテストを容易にするために使用
 /// </summary>
 public interface IExampleService
 {
    Task RunExample();
 }
 
 /// <summary>
 /// HttpClientFactoryの使用例を示すサービスクラス
 /// 名前付きHttpClientと型付きHttpClientの両方の使用例を提供
 /// </summary>
 public class ExampleService : IExampleService
 {
    private readonly IHttpClientFactory _clientFactory;
    private readonly GitHubService _githubService;
 
    /// <summary>
    /// コンストラクタで依存関係を注入
    /// </summary>
    /// <param name="clientFactory">HttpClientFactory - 名前付きクライアントの作成に使用</param>
    /// <param name="githubService">GitHubService - 型付きHttpClientの例として使用</param>
    public ExampleService(IHttpClientFactory clientFactory, GitHubService githubService)
    {
       _clientFactory = clientFactory;
       _githubService = githubService;
    }
 
    /// <summary>
    /// HttpClientFactoryの両方の使用パターンを実演
    /// </summary>
    public async Task RunExample()
    {
       // 方法 1 : 名前付きHttpClientの使用例
       try
       {
          // "github"という名前で設定されたHttpClientを取得
          var client = _clientFactory.CreateClient("github");
          var response = await client.GetStringAsync("repos/dotnet/runtime");
          Console.WriteLine("Named HttpClient Response:");
          Console.WriteLine(response.Substring(0, 200) + "...");
       }
       catch (Exception ex)
       {
          Console.WriteLine($"Named client error: {ex.Message}");
       }
 
       // // 方法 2 : 型付きHttpClientの使用例
       try
       {
          // 注入されたGitHubServiceを使用
          var response = await _githubService.GetApiResponse();
          Console.WriteLine("Typed HttpClient Response:");
          Console.WriteLine(response.Substring(0, 200) + "...");
       }
       catch (Exception ex)
       {
          Console.WriteLine($"Typed client error: {ex.Message}");
       }
    }
 }


方法 3 : HttpClientFactory (複数のベースURI)

  • 名前付きHttpClientを使用する方法
    • メリット
      個別の設定が容易
    • デメリット
      文字列ベースの名前指定
      型安全性が低い

  • 型付きHttpClientを使用する方法
    • メリット
      型安全性が高い
      APIごとに特化した実装が可能
      テストが容易
    • デメリット
      クラス数が増加
      各APIに対して個別の実装が必要

  • 動的にベースURIを切り替える方法
    • メリット
      柔軟性が高い
      設定ファイルでの管理が容易
      実行時の切り替えが可能
    • デメリット
      複雑な実装


 # 動的なベースURI切り替えで使用
 # appsettings.json
 
 {
   "ApiSettings": {
     "BaseUrls": {
       "github": "https://api.github.com/",
       "weather": "https://api.weather.com/",
       "other": "https://api.other.com/"
     }
   }
 }


 using System;
 using System.Threading.Tasks;
 using System.Net.Http;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Hosting;
 
 class Program
 {
    static async Task Main(string[] args)
    {
       var host = Host.CreateDefaultBuilder(args).ConfigureServices((context, services) => {
          // 方法 1 : 異なる名前で複数のHttpClientを登録
          services.AddHttpClient("github", client => {
             client.BaseAddress = new Uri("https://api.github.com/");
             client.DefaultRequestHeaders.Add("User-Agent", "HttpClientFactory-Sample");
          });
 
          services.AddHttpClient("weather", client => {
             client.BaseAddress = new Uri("https://api.weather.com/");
             client.DefaultRequestHeaders.Add("User-Agent", "Weather-Service");
          });
 
          // 方法 2 : 型付きHttpClientを各APIサービス用に登録
          services.AddHttpClient<GitHubService>();
          services.AddHttpClient<WeatherService>();
 
          // 方法 3 : 設定情報を含むサービスを登録
          services.Configure<ApiSettings>(context.Configuration.GetSection("ApiSettings"));
          services.AddHttpClient<MultiBaseUriService>();
 
          services.AddTransient<IMultiApiService, MultiApiService>();
       }).Build();
 
       var service = host.Services.GetRequiredService<IMultiApiService>();
       await service.RunExample();
    }
 }
 
 // APIの設定を保持するクラス
 public class ApiSettings
 {
    public Dictionary<string, string> BaseUrls { get; set; } = new();
 }
 
 // 複数のベースURIを扱うサービス
 public class MultiBaseUriService
 {
    private readonly HttpClient _httpClient;
    private readonly ApiSettings _settings;
    private readonly Dictionary<string, string> _baseUrls;
 
    public MultiBaseUriService(HttpClient client, IOptions<ApiSettings> settings)
    {
       _httpClient = client;
       _settings = settings.Value;
       _baseUrls = _settings.BaseUrls;
    }
 
    public async Task<string> SendRequest(string apiKey, string endpoint)
    {
       if (!_baseUrls.TryGetValue(apiKey, out var baseUrl))
       {
          throw new ArgumentException($"Unknown API key: {apiKey}");
       }
 
       var fullUrl = new Uri(new Uri(baseUrl), endpoint);
       return await _httpClient.GetStringAsync(fullUrl);
    }
 }
 
 // GithubのAPI用サービス
 public class GitHubService
 {
    private readonly HttpClient _httpClient;
 
    public GitHubService(HttpClient client)
    {
       _httpClient = client;
       _httpClient.BaseAddress = new Uri("https://api.github.com/");
       _httpClient.DefaultRequestHeaders.Add("User-Agent", "GitHub-Service");
    }
 
    public async Task<string> GetRepositoryInfo(string repo)
    {
       return await _httpClient.GetStringAsync($"repos/{repo}");
    }
 }
 
 // 気象API用サービス
 public class WeatherService
 {
    private readonly HttpClient _httpClient;
 
    public WeatherService(HttpClient client)
    {
       _httpClient = client;
       _httpClient.BaseAddress = new Uri("https://api.weather.com/");
       _httpClient.DefaultRequestHeaders.Add("User-Agent", "Weather-Service");
    }
 
    public async Task<string> GetWeatherInfo(string location)
    {
       return await _httpClient.GetStringAsync($"weather/{location}");
    }
 }
 
 // 複数APIを利用するサービスのインターフェース
 public interface IMultiApiService
 {
    Task RunExample();
 }
 
 // 複数のAPIを利用する実装
 public class MultiApiService : IMultiApiService
 {
    private readonly IHttpClientFactory  _clientFactory;
    private readonly GitHubService       _githubService;
    private readonly WeatherService      _weatherService;
    private readonly MultiBaseUriService _multiBaseUriService;
 
    public MultiApiService(IHttpClientFactory clientFactory, GitHubService githubService, WeatherService weatherService,
                           MultiBaseUriService multiBaseUriService)
    {
       _clientFactory = clientFactory;
       _githubService = githubService;
       _weatherService = weatherService;
       _multiBaseUriService = multiBaseUriService;
    }
 
    public async Task RunExample()
    {
       // 方法 1 : 名前付きHttpClientの使用
       try
       {
          var githubClient = _clientFactory.CreateClient("github");
          var weatherClient = _clientFactory.CreateClient("weather");
 
          var githubResponse = await githubClient.GetStringAsync("repos/dotnet/runtime");
          var weatherResponse = await weatherClient.GetStringAsync("weather/tokyo");
 
          Console.WriteLine("Named Clients Response:");
          Console.WriteLine($"GitHub: {githubResponse.Substring(0, 100)}...");
          Console.WriteLine($"Weather: {weatherResponse.Substring(0, 100)}...");
       }
       catch (Exception ex)
       {
          Console.WriteLine($"Named clients error: {ex.Message}");
       }
 
       // 方法 2 : 型付きHttpClientの使用
       try
       {
          var githubInfo = await _githubService.GetRepositoryInfo("dotnet/runtime");
          var weatherInfo = await _weatherService.GetWeatherInfo("tokyo");
 
          Console.WriteLine("\nTyped Clients Response:");
          Console.WriteLine($"GitHub: {githubInfo.Substring(0, 100)}...");
          Console.WriteLine($"Weather: {weatherInfo.Substring(0, 100)}...");
       }
       catch (Exception ex)
       {
          Console.WriteLine($"Typed clients error: {ex.Message}");
       }
 
       // 方法 3 : 動的なベースURI切り替え
       try
       {
          var githubResponse = await _multiBaseUriService.SendRequest("github", "repos/dotnet/runtime");
          var weatherResponse = await _multiBaseUriService.SendRequest("weather", "weather/tokyo");
 
          Console.WriteLine("\nMulti Base URI Service Response:");
          Console.WriteLine($"GitHub: {githubResponse.Substring(0, 100)}...");
          Console.WriteLine($"Weather: {weatherResponse.Substring(0, 100)}...");
       }
       catch (Exception ex)
       {
          Console.WriteLine($"Multi base URI error: {ex.Message}");
       }
    }
 }



HTTP通信

ベースとなるクラス

 // 通信先のベースURL
 private readonly string baseUrl;
 
 // HTTPクライアント
 private readonly HttpClient httpClient;
 
 // コンストラクタ
 public SampleServiceHttpClient(string baseUrl)
 {
    this.baseUrl = baseUrl;
    this.httpClient = new HttpClient();
 }


GET

URLに情報を付加してGETリクエストを送受信する。

HttpClientクラスのSendAsync()メソッドは、HttpResponseMessageクラスを返す。
レスポンスが取得できるため、ステータスコードやボディを確認および使用することができる。

JSON以外のテキストファイルやPDFファイル等をダウンロードする場合、レスポンスのボディにファイル内容が入ることがある。

 // URLに情報を付加してGETリクエストを送受信する
 public string Get(string someId)
 {
    String requestEndPoint = this.baseUrl + "/some/search/?someId=" + someId;
    var request = this.CreateRequest(HttpMethod.Get, requestEndPoint);
 
    string resBodyStr;
    var resStatusCoode = HttpStatusCode.NotFound;
    Task<HttpResponseMessage> response;
 
    // 通信の実行
    // 引数にrequestを使用する場合は、GetAsync()やPostAsync()ではなく、SendAsync()である
    try
    {
       response = httpClient.SendAsync(request);
       resBodyStr = response.Result.Content.ReadAsStringAsync().Result;
       resStatusCoode = response.Result.StatusCode;
    }
    catch (HttpRequestException e)
    {
       return null;
    }
 
    if (!resStatusCoode.Equals(HttpStatusCode.OK))
    {  // レスポンスが200以外の場合
       return null;
    }
 
    if (String.IsNullOrEmpty(resBodyStr))
    {  // レスポンスのボディが空の場合
       return null;
    }

    return resBodyStr;
 }
 
 // HTTPリクエストメッセージを生成する
 // httpMethod : HTTPメソッドのオブジェクト
 // requestEndPoint : 通信先のURL
 private HttpRequestMessage CreateRequest(HttpMethod httpMethod, string requestEndPoint)
 {
    var request = new HttpRequestMessage(httpMethod, requestEndPoint);
 
    return this.AddHeaders(request);
 }
 
 // HTTPリクエストにヘッダーを追加する
 // request : リクエスト
 private HttpRequestMessage AddHeaders(HttpRequestMessage request)
 {
    request.Headers.Add("Accept", "application/json");
    request.Headers.Add("Accept-Charset", "utf-8");
 
    // 例えば、認証通過後のトークンが "Authorization: Bearer {トークンの文字列}" のように必要な場合は追加する
 
    return request;
 }


POST : テキストファイル

以下の例では、リクエストのボディにJSON形式の内容を格納して送受信している。
JSONは、JS、Ruby、Python、PHP等では連想配列を使用して簡単に作成できるが、C#においてはDictionary型を使用および変換後、StringContent型に情報を付加してリクエストに格納する。

APIの認証方式において、事前に与えられているAPIキーや他の認証情報の文字列を送信する時、認証が成功した場合はアクセストークンを返却して、以降はそのアクセストークンをリクエストヘッダに追加してAPIを呼ぶことがある。
その場合は、以下の例と同様の処理となる。

リクエストのボディにおいて、引数に指定の形式でAPIキー等を格納してPOSTで送信する時、レスポンスのボディの一部にトークンが格納されて返される。
以下の例では、内部メソッドであるAddHeaders()において固定のヘッダの追加のみを行っているが、ここにトークンが格納される場合は追加する処理を記述することにより使い回すことができる。

 // リクエストのボディに文字列のキーをJSON形式で格納してPOSTを送受信する
 public string Post(string someKey)
 {
    String requestEndPoint = this.baseUrl + "some/post";
    var request = this.CreateRequest(HttpMethod.Post, requestEndPoint);
    var jsonDict = new Dictionary<string, string>() {
                      {"someKey", someKey},
                   };
 
    var reqBodyJson = JsonSerializer.Serialize(jsonDict, this.GetJsonOption());
    var content = new StringContent(reqBodyJson, Encoding.UTF8, @"application/json");
    request.Content = content;
 
    string resBodyStr;
    var resStatusCoode = HttpStatusCode.NotFound;
    Task<HttpResponseMessage> response;
 
    try
    {
       response = httpClient.SendAsync(request);
       resBodyStr = response.Result.Content.ReadAsStringAsync().Result;
       resStatusCoode = response.Result.StatusCode;
    }
    catch (HttpRequestException e)
    {  // 通信が失敗した場合
       return null;
    }
 
    if (!resStatusCoode.Equals(HttpStatusCode.OK))
    {  // レスポンスが200以外の場合
       return null;
    }
 
    if (String.IsNullOrEmpty(resBodyStr))
    {  // レスポンスのボディが空の場合
       return null;
    }
 
    // 取得した内容
    return resBodyStr;
 }
 
 // HTTPリクエストメッセージを生成する
 // httpMethod : HTTPメソッドのオブジェクト
 // requestEndPoint : 通信先のURL
 private HttpRequestMessage CreateRequest(HttpMethod httpMethod, string requestEndPoint)
 {
    var request = new HttpRequestMessage(httpMethod, requestEndPoint);
 
    return this.AddHeaders(request);
 }
 
 // HTTPリクエストにヘッダーを追加する
 // request : リクエスト
 private HttpRequestMessage AddHeaders(HttpRequestMessage request)
 {
    request.Headers.Add("Accept", "application/json");
    request.Headers.Add("Accept-Charset", "utf-8");
 
    // 例えば、認証通過後のトークンが "Authorization: Bearer {トークンの文字列}" のように必要な場合は追加する
 
    return request;
 }


POST : バイナリファイル

POSTリクエストは、テキストファイルやPDFファイル等のバイナリファイルをアップロードすることができる。
バイナリファイルを送信するためには、MIMEタイプをmultipart/form-dataに指定する。

以下の例では、マルチパートで区切ることにより、複数のデータを送信している。
マルチパートの1つ目はStringContent型でテキストデータ、マルチパートの2つ目はStreamContent型でバイナリファイルのデータである。
これら2つのデータをボディ部に設定して、POSTリクエストで送信している。

 // テキストデータおよびバイナリデータをボディ部にセットして、POSTリクエストを送受信する。
 // filePath : アップロードするバイナリファイルのパス
 public string PostPdfFile(string filePath)
 {
    String requestEndPoint = this.baseUrl + "resume/upload";
    var request = this.CreateRequest(HttpMethod.Post, requestEndPoint);
 
    // Accept: multipart/form-dataを指定
    request.Headers.Remove("Accept");
    request.Headers.Add("Accept", "multipart/form-data");
 
    // 生成するボディ部
    // Content-Type  : multipart/form-data; boundary = "{MultipartFormDataContentクラスが自動で設定}"
    // Content-Length: {MultipartFormDataContentクラスが自動で設定}
    var content = new MultipartFormDataContent();
 
    // ボディ部1に--boundaryで区切られたマルチパートのテキストデータを追加
    // --boundary
    // Content-Type: text/plain; charset=utf-8
    // Content-Disposition: form-data; name=SamplePart
    //
    // Sample
    var multiDocumentsContent = new StringContent("Sample");
    content.Add(multiDocumentsContent, "SamplePart");
 
    StreamContent streamContent = null;
 
    var resStatusCoode = HttpStatusCode.NotFound;
    Task<HttpResponseMessage> response;
    String resBodyStr;
 
    using (var fileStream = File.OpenRead(filePath))
    {
       streamContent = new StreamContent(fileStream);
 
       // {Content-Disposition: form-data; name=file; filename="{ファイル名}"]
       //streamContent.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data")
       //{
       //   Name = "file",
       //   FileName = Path.GetFileName(filePath)
       //};
 
       // ファイル名がマルチバイト文字の場合は文字化けするため、ファイル名を手動でエンコードしてヘッダを別に作成する
       var finfo = new FileInfo(filePath);
       var headerStr = string.Format("form-data; name=\"file\"; filename=\"{0}\"", finfo.Name);
       var headerValueByteArray = Encoding.UTF8.GetBytes(headerStr);
       var encodedHeaderValue = new StringBuilder();
       foreach (var b in headerValueByteArray)
       {
          encodedHeaderValue.Append((char)b);
       }
 
       streamContent.Headers.ContentDisposition = null; // デフォルトで用意されているので一旦削除
       streamContent.Headers.Add("Content-Disposition", encodedHeaderValue.ToString());
 
       // バイナリファイル (PDFファイル)
       streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
       streamContent.Headers.Add("Content-Length", fileStream.Length.ToString());
       content.Add(streamContent, "file");
 
       // 生成するボディ部
       // ボディ部2に--boundaryから--boundary--までで区切られたマルチパートのバイナリデータを追加
       // --boundary
       // Content-Disposition: form-data; name="file"; filename="{エンコードされたファイル名}"
       // Content-Type: application/pdf
       // Content-Length: {上で計算された値}
       //
       // {バイナリファイルの実体}
       // --boundary--
 
       // 2つの部分を加えたボディ部をPOSTリクエストとして送信する
       request.Content = content;
 
       try
       {
          response = httpClient.SendAsync(request);
          resBodyStr = response.Result.Content.ReadAsStringAsync().Result;
          resStatusCoode = response.Result.StatusCode;
       }
       catch (HttpRequestException e)
       {  // 通信が失敗した場合
          return null;
       }
 
       fileStream.Close();
    }
 
    if (!resStatusCoode.Equals(HttpStatusCode.OK))
    {  // レスポンスが200以外の場合
       return null;
    }
 
    if (String.IsNullOrEmpty(resBodyStr))
    {  // レスポンスのボディが空の場合
       return null;
    }
 
    return resBodyStr;
 }


MIMEタイプ (コンテンツタイプ)

application/x-www-form-urlencodedおよびmultipart/form-dataは、HTTPのPOSTリクエストでデータを送信するための異なるMIMEタイプである。

  • application/x-www-form-urlencoded
    HTMLフォームデータをエンコードして送信するためのデフォルトのMIMEタイプである。
    データはキーと値のペアになり、それらのペアはkey1=value1&key2=value2のように&で区切られる。

    特殊文字は、パーセントエンコーディング(%エンコーディング)される。
    例: スペースは%20にエンコーディングされる。

    一般的に、簡単なフォームデータやクエリ文字列を送信する場合に使用する。

  • multipart/form-data
    バイナリデータや大きなファイルを含む複数のデータタイプをサポートするためのMIMEタイプである。

    データは複数の部分に分かれており、各部分にはヘッダがあり、ヘッダにはContent-Dispositionが含まれる。
    このヘッダは、データがどのように処理されるべきかを示している。

    バイナリデータを直接送信するために使用され、一般的には、ファイルのアップロード等で使用される。

    --boundary--boundary--は、各部分の開始と終了を示すために使用され、部分はそれぞれ独立して処理される。
    どちらを選択するかは、データの種類と送信する内容に依存する。
    一般的には、HTMLフォームでテキストデータを送信する場合はapplication/x-www-form-urlencodedが使用され、ファイルのアップロード等でバイナリデータを送信する場合はmultipart/form-dataが使用される。
 /* 例: */

 --boundary
 Content-Disposition: form-data; name="key1"
 
 value1
 --boundary
 Content-Disposition: form-data; name="key2"
 
 value2
 --boundary--


DELETE

GETと同様、HTTPメソッドのDELETEを指定してHttpRequestMessageクラスのインスタンスを生成して渡す。

JSからの通信では、あまり使用しない。

 // URLに情報を付加してDELETEを送受信する
 public bool Delete(string someId)
 {
    String requestEndPoint = this.baseUrl + "some/" + someId;
    var request = this.CreateRequest(HttpMethod.Delete, requestEndPoint);
 
    var resStatusCoode = HttpStatusCode.NotFound;
    Task<HttpResponseMessage> response;
    String resBodyStr;
 
    try
    {
       response = httpClient.SendAsync(request);
       resBodyStr = response.Result.Content.ReadAsStringAsync().Result;
       resStatusCoode = response.Result.StatusCode;
    }
    catch (HttpRequestException e)
    {  // 通信が失敗した場合
       return false;
    }
 
    if (!resStatusCoode.Equals(HttpStatusCode.OK))
    {  // レスポンスが200以外の場合
       return false;
    }
 
    if (String.IsNullOrEmpty(resBodyStr))
    {  // レスポンスのボディが空の場合
       return false;
    }
 
    return true;
 }