C Sharpとネットワーク - HttpClient

提供: MochiuWiki : SUSE, EC, PCB

2024年1月23日 (火) 20:12時点におけるWiki (トーク | 投稿記録)による版 (POST)

概要

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リクエストを開く。

<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>


次に、アプリケーションを終了して、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()メソッドを使用してメッセージを送信する。

<syntaxhighlight lang="c#">
var getReult = await client.GetAsync("http://kirakira-service.com/");
var postRsult = await client.PostAsync("http://sugoi-service.com/");
</syntaxhighlight>


Cookieのキャッシュ

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

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

<syntaxhighlight lang="c#">
var handler = new HttpClientHandler()
{
   UseCookies = false,  // false : Cookieをキャッシュしない
                        // true  : Cookieをキャッシュする
};

var client = new HttpClient(handler);
</syntaxhighlight>



ソリューション

手順

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分程度に設定する方がよい。 (ホスト、ポート、スキーマが重要である)

<syntaxhighlight lang="c#">
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}");

      // ...略
   }
}
</syntaxhighlight>


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


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>


GET

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

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

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

<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;
}
</syntaxhighlight>


POST

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

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

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

<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>


DELETE

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

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

<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>