「C Sharpとネットワーク - HttpClient」の版間の差分
(→POST) |
|||
285行目: | 285行目: | ||
<br> | <br> | ||
==== POST : バイナリファイル ==== | ==== 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タイプ (コンテンツタイプ) ==== | ==== MIMEタイプ (コンテンツタイプ) ==== | ||
application/x-www-form-urlencodedおよびmultipart/form-dataは、HTTPのPOSTリクエストでデータを送信するための異なるMIMEタイプである。<br> | application/x-www-form-urlencodedおよびmultipart/form-dataは、HTTPのPOSTリクエストでデータを送信するための異なるMIMEタイプである。<br> |
2024年1月23日 (火) 23:31時点における版
概要
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);
ソリューション
手順
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
クラスのオブジェクトを生成する方がよい。
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;
}
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;
}