Rのつく財団入り口

ITエンジニア関連の様々な話題を書いているはずのブログです。

【C#】System.Net.Http.HttpClientを使ってWeb APIとHTTP通信してみよう

System.Net.Http.HttpClientを使ってみよう

 C#で通信する時に標準となっているHttpClientクラス。使った時に調べたのですが古い情報が混ざっていたり、後から忘れて毎回ググったりしました。
 ということで備忘録替わりに使い方のサンプルを載せる記事です。主に特定のWebサービスで公開されているAPIと通信するような形を想定しています。

f:id:iwasiman:20210327173744p:plain
System.Net.Http.HttpClientを使ってWeb APIと通信してみよう

C#のHTTP通信の歴史

 主に2000年代など古めの情報を中心に System.Net.WebRequestSystem.Net.HttpWebRequest, Systen.Net.WebClient を使った使ったサンプルもネット上で見つかりますが、これらは古いです。Microsoftの公式ドキュメントでもHttpClientを使うよう指示があります。ググるときはご注意ください。
 2012/8/15リリースの.NET Framework 4.5から System.Net.Http.HttpClientが追加され、async/awaitを使った非同期通信にも対応、メソッド群も使いやすくなっています。DLLとしてはSystem.Net.Http.dllの中に入っています。これが現在のC#の標準となります。

docs.microsoft.com

インスタンス生成の方法について

 ググるとよく出てくる使い方が、IDisposableインターフェースを実装(implememnts)しているのでusingブロックで囲うもの。

using (var client = new HttpClient())
   {
       await client.PostAsync("http://iketeru-service.com/");
   }

 囲うと通信後にオブジェクトが破棄されるのでまったくもって正しい使い方に見えますが……これだと通信を行うたびに毎回ソケットを開いてリソースを消費するため、リソースが枯渇してしまうこともあるそうです。分かりにくいですが使い回すのが正解となります。
 この記事のサンプルコードではラップしたHttpClientSampleクラスのインスタンス生成時に一緒に作られるよう、privateなフィールド(=メンバ変数)で持つようにしています。(とやっておいて不完全で恐縮ですが、下のQiita記事にあるようにprivate static な静的メンバ変数で持つのがさらに確実で良いようです。)

qiita.com

www.infoq.com

呼び出し方

 ネットのサンプルでよくあるのが

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

 のようにHTTPメソッド名と対応したメソッドを使うもの。Qiitaのこの記事がよくヒットします。

qiita.com

 手軽に使うにはこれで十分ですが、しかし本格的に使う場合は、だいたい毎回固定のリクエストヘッダーや認証情報を付けたHttpRequestMessageインスタンスを使うかと思います。
今回のサンプルでは本格的に使う場合を想定し、共通の内部メソッドCreateRequest()を使ってHttpRequestMessageを作ってから、SendAsync()メソッドを使ってこのメッセージを送信するやり方にしています。

サンプルコード

 対象フレームワークは.NET Core 3.1 で書いていますが、.NET Framework 4.5でも同じです。

クラス全体
  • https://sample-service.com/api/ にて公開中のSampleServiceというWebサービスとのHTTP通信を専門に行うのが責務の、SampleServiceHttpClientクラスという雰囲気で作ってみました。
  • 相手にするサービスが固定なので、クライアントをnewする際に引数でベースのURLを渡すようにしてします。
  • コード中に UNDONE: で書いてありますが、エラー処理が全て抜けています。ロギングしたりラップした例外を外側に渡したり、お好みで処理してください。
    コメントでTODO:FIXME: を付けておく文化は他の言語でもよく見るんですが、Visual Studioではこういうコメントが元から認識されるように登録してあるんですねえ。(ところでUNDONE:というのはあまり現場のコードでは見かけないような……? 区別のために今回は書いていますが僕も経験ないです。世の中のCSharperの皆さんはどうでしょう。)

www.atmarkit.co.jp

        /// <summary>
        /// 通信先のベースURL
        /// </summary>
        private readonly string baseUrl;
        /// <summary>
        /// C#側のHttpクライアント
        /// </summary>
        private readonly HttpClient httpClient;

        /// <summary>
        /// デフォルトコンストラクタ。外部からは呼び出せません。
        /// </summary>
        private SampleServiceHttpClient()
        {
        }

        /// <summary>
        /// 引数付きのコンストラクタ。こちらを使用します。
        /// 引数には正しいURLが入っていることが前提です。
        /// </summary>
        /// <param name="baseUrl">ベースのURL</param>
        public SampleServiceHttpClient(string baseUrl)
        {
            this.baseUrl = baseUrl;
            // 通信するメソッドでその都度HttpClientをnewすると毎回ソケットを
            // 開いてリソースを消費するため、
            // メンバ変数で使い回す手法を取っています。
            this.httpClient = new HttpClient();
        }
GETを投げる例
  • サンプルコードの通り、URLに情報が載った先にGETを投げる例です。
  • httpClient.SendAsync(request) すると結果はTask<HttpResponseMessage>型。C#で非同期処理の戻り値に使われるTaskです。
  • 変数名.Result とするとSystem.Net.Http.HttpResponseMessage型でレスポンスが取得できるので、サンプルコードのようにステータスコードやボディを取り出して中身を確認、必要なら使っていきます。
        /// <summary>
        /// 情報がURLに載ったGETリクエストを送受信するサンプル。
        /// </summary>
        /// <param name="someId">何かのID</param>
        /// <returns>正常:レスポンスのボディ / 異常:null</returns>
        public string GetSample(string someId)
        {
            String requestEndPoint = this.baseUrl + "/some/search/?someId=" + someId;
            HttpRequestMessage request
                = this.CreateRequest(HttpMethod.Get, requestEndPoint);

            string resBodyStr;
            HttpStatusCode resStatusCoode = HttpStatusCode.NotFound;
            Task<HttpResponseMessage> response;
            // 通信実行。メンバ変数でhttpClientを持っているので、using(~)で囲いません。
            // 囲うと通信後にオブジェクトが破棄されます。
            // 引数にrequestを取る場合はGetAsyncやPostAsyncでなくSendAsyncメソッドになります。
            // 戻り値はTask<HttpResponseMessage>で、変数名.Resultとすると
            // System.Net.Http.HttpResponseMessageクラスが取れます。
            try
            {
                response = httpClient.SendAsync(request);
                resBodyStr = response.Result.Content.ReadAsStringAsync().Result;
                resStatusCoode = response.Result.StatusCode;
            }
            catch (HttpRequestException e)
            {
                // UNDONE: 通信失敗のエラー処理
                return null;
            }

            if (!resStatusCoode.Equals(HttpStatusCode.OK))
            {
                // UNDONE: レスポンスが200 OK以外の場合のエラー処理
                return null;
            }
            if (String.IsNullOrEmpty(resBodyStr))
            {
                // UNDONE: レスポンスのボディが空の場合のエラー処理
                return null;
            }
            // 中身のチェックなどを経て終了。
            return resBodyStr;
        }
  • JSON以外のプレーンテキスト、CSVやTSV、果てはOfficeファイルやPDFファイルをダウンロード取得するようなAPIでは、レスポンスのボディにそのまま入っていることが多いです。
DELETEを投げる例
  • ブラウザ上で動くJavaScriptからの通信だとあまり使わないかもしれないHTTPメソッド、DELETEです。
  • こちらもGETとほとんど同じで、HTTPメソッドのDeleteを指定してHttpRequestMessageを作って投げればOKです。
        /// <summary>
        /// URLに情報を持ってDELETEを送受信するサンプル。
        /// </summary>
        /// <param name="someId">何かのID</param>
        /// <returns>正常:固定文字列 / 異常:null</returns>
        public string DeleteSample(string someId)
        {
            String requestEndPoint = this.baseUrl + "some/" + someId;
            HttpRequestMessage request = this.CreateRequest(HttpMethod.Delete, requestEndPoint);

            HttpStatusCode 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)
            {
                // UNDONE: 通信失敗のエラー処理
                return null;
            }

            if (!resStatusCoode.Equals(HttpStatusCode.OK))
            {
                // UNDONE: レスポンスが200 OK以外の場合のエラー処理
                return null;
            }
            if (String.IsNullOrEmpty(resBodyStr))
            {
                // UNDONE: レスポンスのボディが空の場合のエラー処理
                return null;
            }
            return someId + "の削除に成功したんだが!";
        }
POSTを投げる例
  • よくありそうな、リクエストのボディにJSON形式で何かを設定する例です。
  • JavaScript/Ruby/Python/PHPなどでは連想配列などでサッと作れるJSON文字列ですが、C#では辞書型のDictionary型で作ってからサンプルコードのように変換を掛け、StringContent型の中に入れてリクエストに格納します。
  • 2007/11リリースのC# 3.0から使えるようになった、変数宣言の右辺を見て型を推論してくれるvarキーワードも使っています。これはいろいろ議論の後、現在では使える時は使いましょうと、なっているようですね。
  • なおJavaでは2018/3のJava SE 10でようやくこのvarが使えるようになっています。C#の方がだいぶ早くてさすがです。
  • JSerの方はvarを見ると反射的にletに書き換えたくなるかもしれませんが、だいぶ言語仕様が違うのでお気を付けください。ってそんなこと思わないかw
        /// <summary>
        /// ボディに文字列のキーをJSONで持ってPOSTを送受信するサンプルです。
        /// </summary>
        /// <param name="someKey">何かのキーとか</param>
        /// <returns>正常:レスポンスのボディ / 異常:null</returns>
        public string PostWithStringBodySample(string someKey)
        {
            String requestEndPoint = this.baseUrl + "some/post";
            var request = this.CreateRequest(HttpMethod.Post, requestEndPoint);
            var jsonDict = new Dictionary<string, string>()
                {
                    {"someKey", someKey},
                };
            string reqBodyJson = JsonSerializer.Serialize(jsonDict, this.GetJsonOption());
            var content = new StringContent(reqBodyJson, Encoding.UTF8, @"application/json");
            request.Content = content;

            string resBodyStr;
            HttpStatusCode resStatusCoode = HttpStatusCode.NotFound;
            Task<HttpResponseMessage> response;
            try
            {
                response = httpClient.SendAsync(request);
                resBodyStr = response.Result.Content.ReadAsStringAsync().Result;
                resStatusCoode = response.Result.StatusCode;
            }
            catch (HttpRequestException e)
            {
                // UNDONE: 通信失敗のエラー処理
                return null;
            }

            if (!resStatusCoode.Equals(HttpStatusCode.OK))
            {
                // UNDONE: レスポンスが200 OK以外の場合のエラー処理
                return null;
            }
            if (String.IsNullOrEmpty(resBodyStr))
            {
                // UNDONE: レスポンスのボディが空の場合のエラー処理
                return null;
            }
            // 中身を取り出したりして終了
            return resBodyStr;
        }
  • APIの認証方式でよくあるのが、事前に与えられているAPIキーやなんらかの認証情報の文字列を送ると、認証が成功したらアクセストークンが返却、以降はそのアクセストークンをリクエストヘッダに追加してAPIを呼ぶというもの。
  • その場合はだいたい上のメソッド例と似たような感じになります。リクエストのボディにこの例でsomeKeyとなっているところに指定の形式でAPIキーなどを格納、POSTで送信すると、レスポンスのボディの一部にトークンが入って返ってくる...という感じです。
  • サンプルコードの内部メソッド AddHeaders() は固定のヘッダー追加しか行っていませんが、ここにトークンがあれば追加するような処理を加えると使い回せます。
バイナリファイルをPOSTでアップロードする例
  • 最後は長い例。引数で指定されたパスに置いてある履歴書のようなPDFファイルをアップロードするAPIを想定しています。"application/x-www-form-urlencoded"ではなく"multipart/form-data"指定の想定です。
  • マルチパートで複数のファイルの中身なりデータを送れるのですが、この場合はコードもちょっと長く複雑になります。
  • この例ではマルチパートの1つめがStringContent型でテキスト。
  • マルチパートの2つめがStreamContent型で、読み込んだファイルの内容。
  • この2つを一緒にしてボディ部としてリクエストで送信……という例になっています。
  • サンプルコードにありますがファイル名が日本語だと化けてしまうので、そこも対応しています。
  • コメントに書いてありますがボディの中をランダムなバウンダリーの文字列で区切って、パートごとにヘッダーと本体を追加……というちょっとめんどくさいことを、C#側のこれらのクラス群がよろしくやってくれます。
  • 似たようなことを実際にやった時うまくいかずハマって、ボディ部を全部手動で作って投げたりする手も試したのですが上手くいきませんでした。その時は結局はC#側のクラス群を正しく使ってボディを作るのが正解でした。高度に抽象化されたその言語のライブラリに任せた方が良いということですね。
        /// <summary>
        /// バイナリファイルなどをボディに持ってPOSTを送受信するサンプルです。
        /// </summary>
        /// <param name="filePath">アップロードするファイルのファイルパス</param>
        /// <returns>正常:レスポンスのボディ / 異常:null</returns>
        public string PostWithPdfFileBodySample(string filePath)
        {
            String requestEndPoint = this.baseUrl + "resume/upload";
            HttpRequestMessage request = this.CreateRequest(HttpMethod.Post, requestEndPoint);
            // こうした場合、Accept: multipart/form-data を指定となっていることが多いです。
            request.Headers.Remove("Accept");
            request.Headers.Add("Accept", "multipart/form-data");

            var content = new MultipartFormDataContent();
            // ☆生成されるボディ部
            // Content-Type: multipart/form-data; boundary = "{MultipartFormDataContentクラスが自動で設定}"
            // Content-Length: {MultipartFormDataContentクラスが自動で設定}

            // ボディに--boundaryで区切られたマルチパートのデータを追加
            var multiDocumentsContent = new StringContent("hoge");
            content.Add(multiDocumentsContent, "hogePart");
            // ☆生成されるボディ部
            // --boundary
            // Content-Type: text/plain; charset=utf-8
            // Content-Disposition: form-data; name=hogePart
            //
            // hoge

            StreamContent streamContent = null;

            HttpStatusCode 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)
                //};
                // しかしファイル名が2バイト文字だと化けてしまうので、手動でエンコードしたfilenameを
                // 追加したヘッダーを別に作ります。
                var finfo = new FileInfo(filePath);
                string headerStr = string.Format("form-data; name=\"file\"; filename=\"{0}\"", finfo.Name);
                byte[] headerValueByteArray = Encoding.UTF8.GetBytes(headerStr);
                var encodedHeaderValue = new StringBuilder();
                foreach (byte 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");
                // ☆生成されるボディ部
                // --boundary
                // Content-Disposition: form-data; name="file"; filename="{エンコードされたファイル名}"
                // Content-Type: application/pdf
                // Content-Length: {上で計算された値}
                //
                // {バイナリファイルの実体}
                // --boundary--

                // 2つの部分が加えられたボディ部をリクエストと一緒にして、送信
                request.Content = content;

                try
                {
                    response = httpClient.SendAsync(request);
                    resBodyStr = response.Result.Content.ReadAsStringAsync().Result;
                    resStatusCoode = response.Result.StatusCode;
                }
                catch (HttpRequestException e)
                {
                    // UNDONE: 通信失敗のエラー処理
                    return null;
                }
                fileStream.Close();
            }

            if (!resStatusCoode.Equals(HttpStatusCode.OK))
            {
                // UNDONE: レスポンスが200 OK以外の場合のエラー処理
                return null;
            }
            if (String.IsNullOrEmpty(resBodyStr))
            {
                // UNDONE: レスポンスのボディが空の場合のエラー処理
                return null;
            }
            // 中身のチェックなどを経て終了。
            return resBodyStr;
        }
最後にクラス全体
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Unicode;
using System.Threading.Tasks;

namespace HttpClientSample
{
    /// <summary>
    /// SampleServiceとの通信を担当する、HTTPクライアントのサンプル。
    /// </summary>
    public class SampleServiceHttpClient
    {
        /// <summary>
        /// 通信先のベースURL
        /// </summary>
        private readonly string baseUrl;
        /// <summary>
        /// C#側のHttpクライアント
        /// </summary>
        private readonly HttpClient httpClient;

        /// <summary>
        /// デフォルトコンストラクタ。外部からは呼び出せません。
        /// </summary>
        private SampleServiceHttpClient()
        {
        }

        /// <summary>
        /// 引数付きのコンストラクタ。こちらを使用します。
        /// 引数には正しいURLが入っていることが前提です。
        /// </summary>
        /// <param name="baseUrl">ベースのURL</param>
        public SampleServiceHttpClient(string baseUrl)
        {
            this.baseUrl = baseUrl;
            // 通信するメソッドでその都度HttpClientをnewすると毎回ソケットを開いてリソースを消費するため、
            // メンバ変数で使い回す手法を取っています。
            this.httpClient = new HttpClient();
        }

        /// <summary>
        /// 情報がURLに載ったGETリクエストを送受信するサンプル。
        /// </summary>
        /// <param name="someId">何かのID</param>
        /// <returns>正常:レスポンスのボディ / 異常:null</returns>
        public string GetSample(string someId)
        {
            String requestEndPoint = this.baseUrl + "/some/search/?someId=" + someId;
            HttpRequestMessage request = this.CreateRequest(HttpMethod.Get, requestEndPoint);

            string resBodyStr;
            HttpStatusCode resStatusCoode = HttpStatusCode.NotFound;
            Task<HttpResponseMessage> response;
            // 通信実行。メンバ変数でhttpClientを持っているので、using(~)で囲いません。囲うと通信後にオブジェクトが破棄されます。
            // 引数にrequestを取る場合はGetAsyncやPostAsyncでなくSendAsyncメソッドになります。
            // 戻り値はTask<HttpResponseMessage>で、変数名.ResultとするとSystem.Net.Http.HttpResponseMessageクラスが取れます。
            try
            {
                response = httpClient.SendAsync(request);
                resBodyStr = response.Result.Content.ReadAsStringAsync().Result;
                resStatusCoode = response.Result.StatusCode;
            }
            catch (HttpRequestException e)
            {
                // UNDONE: 通信失敗のエラー処理
                return null;
            }

            if (!resStatusCoode.Equals(HttpStatusCode.OK))
            {
                // UNDONE: レスポンスが200 OK以外の場合のエラー処理
                return null;
            }
            if (String.IsNullOrEmpty(resBodyStr))
            {
                // UNDONE: レスポンスのボディが空の場合のエラー処理
                return null;
            }
            // 中身のチェックなどを経て終了。
            return resBodyStr;
        }

        /// <summary>
        /// URLに情報を持ってDELETEを送受信するサンプル。
        /// </summary>
        /// <param name="someId">何かのID</param>
        /// <returns>正常:固定文字列 / 異常:null</returns>
        public string DeleteSample(string someId)
        {
            String requestEndPoint = this.baseUrl + "some/" + someId;
            HttpRequestMessage request = this.CreateRequest(HttpMethod.Delete, requestEndPoint);

            HttpStatusCode 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)
            {
                // UNDONE: 通信失敗のエラー処理
                return null;
            }

            if (!resStatusCoode.Equals(HttpStatusCode.OK))
            {
                // UNDONE: レスポンスが200 OK以外の場合のエラー処理
                return null;
            }
            if (String.IsNullOrEmpty(resBodyStr))
            {
                // UNDONE: レスポンスのボディが空の場合のエラー処理
                return null;
            }
            return someId + "の削除に成功したんだが!";
        }

        /// <summary>
        /// ボディに文字列のキーをJSONで持ってPOSTを送受信するサンプルです。
        /// </summary>
        /// <param name="someKey">何かのキーとか</param>
        /// <returns>正常:レスポンスのボディ / 異常:null</returns>
        public string PostWithStringBodySample(string someKey)
        {
            String requestEndPoint = this.baseUrl + "some/post";
            var request = this.CreateRequest(HttpMethod.Post, requestEndPoint);
            var jsonDict = new Dictionary<string, string>()
                {
                    {"someKey", someKey},
                };
            string reqBodyJson = JsonSerializer.Serialize(jsonDict, this.GetJsonOption());
            var content = new StringContent(reqBodyJson, Encoding.UTF8, @"application/json");
            request.Content = content;

            string resBodyStr;
            HttpStatusCode resStatusCoode = HttpStatusCode.NotFound;
            Task<HttpResponseMessage> response;
            try
            {
                response = httpClient.SendAsync(request);
                resBodyStr = response.Result.Content.ReadAsStringAsync().Result;
                resStatusCoode = response.Result.StatusCode;
            }
            catch (HttpRequestException e)
            {
                // UNDONE: 通信失敗のエラー処理
                return null;
            }

            if (!resStatusCoode.Equals(HttpStatusCode.OK))
            {
                // UNDONE: レスポンスが200 OK以外の場合のエラー処理
                return null;
            }
            if (String.IsNullOrEmpty(resBodyStr))
            {
                // UNDONE: レスポンスのボディが空の場合のエラー処理
                return null;
            }
            // 中身を取り出したりして終了
            return resBodyStr;
        }

        /// <summary>
        /// オプションを設定します。内部メソッドです。
        /// </summary>
        /// <returns>JsonSerializerOptions型のオプション</returns>
        private JsonSerializerOptions GetJsonOption()
        {
            // ユニコードのレンジ指定で日本語も正しく表示、インデントされるように指定
            var options = new JsonSerializerOptions
            {
                Encoder = JavaScriptEncoder.Create(UnicodeRanges.All),
                WriteIndented = true,
            };
            return options;
        }

        /// <summary>
        /// バイナリファイルなどをボディに持ってPOSTを送受信するサンプルです。
        /// </summary>
        /// <param name="filePath">アップロードするファイルのファイルパス</param>
        /// <returns>正常:レスポンスのボディ / 異常:null</returns>
        public string PostWithPdfFileBodySample(string filePath)
        {
            String requestEndPoint = this.baseUrl + "resume/upload";
            HttpRequestMessage request = this.CreateRequest(HttpMethod.Post, requestEndPoint);
            // こうした場合、Accept: multipart/form-data を指定となっていることが多いです。
            request.Headers.Remove("Accept");
            request.Headers.Add("Accept", "multipart/form-data");

            var content = new MultipartFormDataContent();
            // ☆生成されるボディ部
            // Content-Type: multipart/form-data; boundary = "{MultipartFormDataContentクラスが自動で設定}"
            // Content-Length: {MultipartFormDataContentクラスが自動で設定}

            // ボディに--boundaryで区切られたマルチパートのデータを追加
            var multiDocumentsContent = new StringContent("hoge");
            content.Add(multiDocumentsContent, "hogePart");
            // ☆生成されるボディ部
            // --boundary
            // Content-Type: text/plain; charset=utf-8
            // Content-Disposition: form-data; name=hogePart
            //
            // hoge

            StreamContent streamContent = null;

            HttpStatusCode 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)
                //};
                // しかしファイル名が2バイト文字だと化けてしまうので、手動でエンコードしたfilenameを追加したヘッダーを別に作ります。
                var finfo = new FileInfo(filePath);
                string headerStr = string.Format("form-data; name=\"file\"; filename=\"{0}\"", finfo.Name);
                byte[] headerValueByteArray = Encoding.UTF8.GetBytes(headerStr);
                var encodedHeaderValue = new StringBuilder();
                foreach (byte 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");
                // ☆生成されるボディ部
                // --boundary
                // Content-Disposition: form-data; name="file"; filename="{エンコードされたファイル名}"
                // Content-Type: application/pdf
                // Content-Length: {上で計算された値}
                //
                // {バイナリファイルの実体}
                // --boundary--

                // 2つの部分が加えられたボディ部をリクエストと一緒にして、送信
                request.Content = content;

                try
                {
                    response = httpClient.SendAsync(request);
                    resBodyStr = response.Result.Content.ReadAsStringAsync().Result;
                    resStatusCoode = response.Result.StatusCode;
                }
                catch (HttpRequestException e)
                {
                    // UNDONE: 通信失敗のエラー処理
                    return null;
                }
                fileStream.Close();
            }

            if (!resStatusCoode.Equals(HttpStatusCode.OK))
            {
                // UNDONE: レスポンスが200 OK以外の場合のエラー処理
                return null;
            }
            if (String.IsNullOrEmpty(resBodyStr))
            {
                // UNDONE: レスポンスのボディが空の場合のエラー処理
                return null;
            }
            // 中身のチェックなどを経て終了。
            return resBodyStr;
        }

        /// <summary>
        /// HTTPリクエストメッセージを生成する内部メソッドです。
        /// </summary>
        /// <param name="httpMethod">HTTPメソッドのオブジェクト</param>
        /// <param name="requestEndPoint">通信先のURL</param>
        /// <returns>HttpRequestMessage</returns>
        private HttpRequestMessage CreateRequest(HttpMethod httpMethod, string requestEndPoint)
        {
            var request = new HttpRequestMessage(httpMethod, requestEndPoint);
            return this.AddHeaders(request);
        }

        /// <summary>
        /// HTTPリクエストにヘッダーを追加する内部メソッドです。
        /// </summary>
        /// <param name="request">リクエスト</param>
        /// <returns>HttpRequestMessage</returns>
        private HttpRequestMessage AddHeaders(HttpRequestMessage request)
        {
            request.Headers.Add("Accept", "application/json");
            request.Headers.Add("Accept-Charset", "utf-8");
            // 同じようにして、例えば認証通過後のトークンが "Authorization: Bearer {トークンの文字列}"
            // のように必要なら適宜追加していきます。
            return request;
        }
    }
}

呼び出し側のクラス

 呼び出す側のProgramクラスは簡単で、前述のSampleServiceHttpClientインスタンスを作ってメソッドを順に呼び出すだけです。こちらがバッチのプログラムだったりすることをなんとなく想定しています。

using System;

namespace HttpClientSample
{
    public class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("HttpClientのサンプルを呼び出すよ!");
            var client = new SampleServiceHttpClient("https://sample-service.com/api/");
            client.GetSample("someId-1");
            client.DeleteSample("someId-1");
            client.PostWithStringBodySample("someKey-123");
            // 本当は呼ぶ前に引数のファイルパスの存在チェックをするべき。
            client.PostWithPdfFileBodySample(@"C:\temp\履歴書だワン.pdf");
        }
    }
}

 ということでC#でHTTP通信する例でした。

github.com

f:id:iwasiman:20210327173744p:plain
System.Net.Http.HttpClientを使ってWeb APIと通信してみよう

関連書籍

こちらの記事もどうぞ

iwasiman.hatenablog.com

iwasiman.hatenablog.com

iwasiman.hatenablog.com

iwasiman.hatenablog.com