C#で http アクセスするサンプル

C#で http アクセスするサンプル

2023/10/25 21:00:00
Program
Vs2017, .Net, C#, Python

前提 #

  • 環境は windows10 pro + git bash + vs 2017 とする
  • ダミーサーバは python 3.11.6 とする
  • C# 環境は vs 2017 .NET Framework 4.7.x とする
  • httpclient は使わない
    IHttpClientFactory を使用して対応する
    理由は以下の通り
    • リソース(ポート)の枯渇
    • DNS更新対応
  • 完成ソース一式は以下に保存
    https://github.com/oya3/cs_http_access_test

プロジェクト作成 #

git bash で作業する
プロジェクト構成は以下の通り

  • cs_http_access_test … プロジェクト全体
    • dummy_server … python bottle のダミーサーバ
    • cs_apps … C# http アクセスアプリ

完成ソース一式は以下:
https://github.com/oya3/cs_http_access_test

プロジェクト作業エリアを作成する

# プロジェクトルートの cs_http_access_test ディレクトリを作成
$ mkdir cs_http_access_test

# プロジェクトルートに移動
$ cd cs_http_access_test

# git 管理開始
$ git init

ダミーサーバ作成 #

作業エリア作成 #

cs_http_access_test で作業

$ mkdir dummy_server

# python 3.11.6 をインストール
$ pyenv install 3.11.6

# python 3.11.6 環境にする(.python-version生成)
$ pyenv local 3.11.6

# 3.11.6 の venv を生成する
$ python -m venv venv

# venv を有効にしておく
$ source venv/Scripts/activate

# venv に bottle をインストール
$ pip install bottle

# bottle をインストールしたことを保持
$ pip freeze > requirements.txt

# テンプレート置き場作成
$ mkdir views

ダミーサーバ本体の server.py を作成 #

$ emacs server.py

from bottle import route, run, template, HTTPResponse


@route('/', method='GET')  # or @get('/')
def index():
    return template('index', username="ダミーサーバ")


@route('/api/<param>', method='GET')
def api(param):
    body = {"status": "OK", "message": "hello world"}
    r = HTTPResponse(status=200, body=body)
    r.set_header("Content-Type", "application/json")
    return r


# プロセスの起動
if __name__ == "__main__":
    run(host='localhost', port=8080, reloader=True, debug=True)

テンプレートを使った index.html を作成 #

$ views/index.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>テンプレートエンジン</title>
</head>
<body>

  <h2>WELCOME: {{ username }}</h2>

</body>
</html>

.gitignore を作成 #

$ emacs .gitignore

venv/

実行 #

git bash で以下を実行する。
実行時に、セキュリティ関連のダイアログが表示された場合は許可しておく

$ python server.py

上記を実行し、ブラウザで以下のURLにアクセスする

  • http://localhost:8080
    テンプレートのindex.html の内容が表示される
  • http://localhost:8080/api/xxx
    json フォーマットのレスポンスが得られる
    現状、xxx はなんでもいい。何でも応答するようにしてある

※ この2つのURLをC#からアクセスして取得するサンプルを作成する

# curl 実行例
$ curl http://localhost:8080
$ curl http://localhost:8080/api/test | jq .

# msys2 に jq がない場合は以下でインストールできるはず(管理者権限で実施必要)
$ pacman -S mingw-w64-x86_64-jq

C# でHTTPで値を取得する #

作業エリア作成 #

ここでは git bash で作成するのではなく、vs 2017 で作成する
cs_http_access_test 配下にhttp_getterを配置する

vs 2017 プロジェクト(ソリューション)作成 #

ソリューション内の構成は以下の通りとしておく

  • cs_apps … ソリューション
    • apps … アプリ保存場所
      • http_getter_app … http アクセスアプリプロジェクト
    • concerns … 共有ライブラリ等 保存場所
      • http_stream … http アクセスライブラリプロジェクト

以下の手順でソリューションと各プロジェクトを作成する

  1. Windows フォームアプリケーション(.NET Framework) Visual C# 選択し作成

    • 名前: http_getter
    • 場所: c:\home\developer\work\cs_http_access_test <— ダミーサーバと同じ階層
    • ソリューション名: 自動入力に任せる
    • フレームワーク: .NET Framework 4.7.2
  2. vs2017 起動後、無駄に生成された http_getter.proj を削除

  3. apps, concerns のソリューションディレクトリを追加

  4. 物理ディレクトリとソリューションディレクトリを合わせておく
    以下のソリューションディレクトリを作成する。
    また作成されたcsファイルのnamespace空間もこのディレクトリ構成に合わせて変更する

    • apps/http_getter_app
    • concerns/http_stream

    クラス構成は以下の通り

  5. http_stream ライブラリプロジェクトを作成

  6. http_getter_app アプリプロジェクトを作成

http_stream ライブラリプロジェクト作成 #

右ペインの concerns/http_stream にカーソル合わせてマウス右ボタンで「追加」を選択し、
「新しいプロジェクト」を作成する
設定内容は以下の通り。

  • クラスライブラリ(.NET Framework)
  • 名前(N): http_stream
  • 場所(L): C:\home\developer\work\cs_http_access_test\concerns\http_stream
  • フレームワーク(F): .NET Framework 4.7.2

nuget を利用してhttp_streamに必要なパッケージを追加する

nuget で以下の3パッケージをインストールする

  • Microsoft.Extensions.DependencyInjection 作成者:microsft v7.0.0
  • Microsoft.Extensions.Http 作成者:microsft v7.0.0
  • Microsoft.Extensions.Http.Polly 作成者:microsft v7.0.13

http_stram.cs を追加する
namespace は、concerns.http_stream としていることに注意すること。

using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;
using System.Xml.Linq;
using Microsoft.Extensions.DependencyInjection;
using Polly;
using Polly.Extensions.Http;

namespace concerns.http_stream    // <-- 名前空間に注意
{
    /// <summary>
    /// HTTPレスポンス クラス
    /// </summary>
    public class HttpStreamResponse
    {
        public int m_code;
        public byte[] m_body;
        public string m_url;
        public HttpStreamResponse(int code, byte[] body, string url)
        {
            this.m_code = code;
            this.m_body = body;
            this.m_url = url;
        }
        public string GetBodyString()
        {
            if (this.m_body != null)
            {
                return Encoding.UTF8.GetString(this.m_body);
            }
            return "null";
        }
    }

    /// <summary>
    /// HTTPストリームオブザーバ I/F クラス
    /// </summary>
    public interface IHttpStreamObserver
    {
        /// <summary>
        /// HTTPストリーム受信コールバック関数
        /// </summary>
        /// <param name="byteArray">受信バッファー</param>
        void HttpStreamResponse(List<HttpStreamResponse> responses);
    }

    public class HttpStream: IDisposable
    {
        private ServiceCollection m_service;
        private ServiceProvider m_provider;
        private IBlobService m_blobService;
        private object m_mutex; //!< ミューテックス
        private List<IHttpStreamObserver> m_observers; //!< 通知先リスト

        /// <summary>
        /// HTTPストリームクラス
        /// </summary>
        public HttpStream()
        {
            /*
             * SetHandlerLifetime(TimeSpan.FromMinutes(5))は、HttpClientFactoryが生成するHttpMessageHandlerの寿命を設定。
             * この設定により、HttpClientFactoryは5分ごとに新しいHttpMessageHandlerを生成し、それをHttpClientに関連付ける。
             * この設定の目的は、長時間実行されるプロセスでHttpClientの共有インスタンスを使用する際の問題を解決する。
             * 具体的には、HttpClientがシングルトンまたは静的オブジェクトとしてインスタンス化される状況では、DNSの変更を処理できない問題がある。
             * しかし、HttpMessageHandlerの寿命を管理することで、この問題を回避できる。
             * 現状、5分という時間は、HttpClientがDNSの変更を認識するための最大遅延時間と考えることができる。
             * この値はアプリケーションの要件に応じて調整することが可能。
             * 例えば、DNSの変更をより迅速に反映させる必要がある場合や、ネットワーク接続が頻繁に変わる環境では、
             * より短い時間を設定することも考えられる。
             * 逆に、DNSの変更がほとんどない安定した環境では、より長い時間を設定することも可能。
             * ※設定できる最小値は TimeSpan.Zero(つまり0秒)で、最大値は TimeSpan.MaxValue(約10,000年)。ただし設計/運用次第
             *  (1) IHttpClientFactory を使用して回復力の高い HTTP 要求を実装する
             *      https://learn.microsoft.com/ja-jp/dotnet/architecture/microservices/implement-resilient-applications/use-httpclientfactory-to-implement-resilient-http-requests.
             *  (2) Polly で指数バックオフを含む HTTP 呼び出しの再試行を実装する
             *      https://learn.microsoft.com/ja-jp/dotnet/architecture/microservices/implement-resilient-applications/implement-http-call-retries-exponential-backoff-polly.
             *  (3) IHttpClientFactory を使って今はこれ一択と思った話 #C# - Qiita
             *      https://qiita.com/TsuyoshiUshio@github/items/7092fbc510772ce5db63.
             *  (4) Is a Singleton HttpClient receiving a new HttpMessageHandler
             *      https://stackoverflow.com/questions/68820007/is-a-singleton-httpclient-receiving-a-new-httpmessagehandler-after-x-minutes.
             */
            this.m_service = new ServiceCollection();
            this.m_service.AddHttpClient<IBlobService, BlobService>()
                .SetHandlerLifetime(TimeSpan.FromMinutes(5))
                .AddPolicyHandler(GetRetryPolicy());
            this.m_provider = m_service.BuildServiceProvider();
            this.m_blobService = m_provider.GetRequiredService<IBlobService>();

            this.m_mutex = new object();
            this.m_observers = new List<IHttpStreamObserver>();
        }

        // リトライポリシー設定
        private IAsyncPolicy<HttpResponseMessage> GetRetryPolicy()
        {
            var jitterier = new Random();
            return HttpPolicyExtensions
                .HandleTransientHttpError()
                .OrResult(msg => msg.StatusCode == System.Net.HttpStatusCode.NotFound)
                .WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))
                                                      + TimeSpan.FromMilliseconds(jitterier.Next(0, 100)),
                    onRetry: (response, delay, retryCount, context) =>
                    {
                        if (response.Exception != null) // 接続できない問題なので、レスポンス情報はない時
                        {
                            // ここに来た場合、リトライ回数分すべてNGとなった場合、呼び出し元の await m_blobService.Execute(urls) で
                            // HttpRequestException 例外が発生するので注意
                            Console.WriteLine($"Retrying due to Exception: {response.Exception.Message}");
                        }
                        else if (response.Result != null) // 接続できているが別の問題(権限等)なので、レスポンス情報がある時(404とか)
                        {
                            // 失敗の理由をデバッグ出力しておく
                            Console.WriteLine($"Retrying: StatusCode: {response.Result.StatusCode} Message: {response.Result.ReasonPhrase} RequestUri: {response.Result.RequestMessage.RequestUri}");
                        }
                    });
        }

        public async void Requests(List<string> urls)
        {
            Console.WriteLine("Requests() start");
            try
            {
                var responses = await m_blobService.Execute(urls);
                Console.WriteLine("Requests() execute end");
                this.NotifyResponse(responses); // 通知
            }
            catch (HttpRequestException e)
            {
                // サーバが応答を返さない場合
                Console.WriteLine($"Requests() failed: {e.Message}");
                var erroResponses = new List<HttpStreamResponse>();
                erroResponses.Add(new HttpStreamResponse(-1, null, urls[0]));
                this.NotifyResponse(erroResponses);
                return;
            }
            Console.WriteLine("Requests() end");
        }

        public void Request(string url)
        {
            Console.WriteLine("Request() start");
            var urls = new List<string>();
            urls.Add(url);
            this.Requests(urls);
            Console.WriteLine("Request() end");
        }

        /// <summary>
        /// リスナーを登録する
        /// </summary>
        /// <remarks>
        ///  受信通知、状態変化通知を受けるリスナーを登録する
        /// </remarks>
        /// <param name="observer">オブザーバ</param>
        public bool AddObserver(IHttpStreamObserver observer)
        {
            lock (this.m_mutex)
            {
                if (this.m_observers.Contains(observer))
                {
                    return false; // 重複
                }
                this.m_observers.Add(observer);
            }
            return true;
        }

        /// <summary>
        /// リスナーを破棄する
        /// </summary>
        /// <remarks>
        ///  受信通知を受けるリスナーを破棄する
        /// </remarks>
        /// <param name="observer">オブザーバ</param>
        public bool RemoveObserver(IHttpStreamObserver observer)
        {
            lock (this.m_mutex)
            {
                if (!this.m_observers.Contains(observer))
                {
                    return false; //未登録
                }
                this.m_observers.Remove(observer);
            }
            return true;
        }

        /// <summary>
        /// HTTPストリーム受信通知
        /// </summary>
        /// <param name="responses">レスポンス</param>
        public void NotifyResponse(List<HttpStreamResponse> responses)
        {
            lock (this.m_mutex)
            {
                foreach (var sb in this.m_observers)
                {
                    sb.HttpStreamResponse(responses);
                }
            }
        }

        /// <summary>
        /// 後始末
        /// </summary>
        public void Dispose()
        {
            this.m_observers.Clear();
            this.m_provider.Dispose();
            this.m_service.Clear();
        }
    }

    public interface IBlobService
    {
        Task<List<HttpStreamResponse>> Execute(IEnumerable<string> urls);
    }

    public class BlobService : IBlobService
    {
        private readonly HttpClient _client;
        public BlobService(HttpClient client)
        {
            this._client = client;
        }

        public async Task<List<HttpStreamResponse>> Execute(IEnumerable<string> urls)
        {
            Console.WriteLine("Execute() start");
            var responses = new List<HttpStreamResponse>();
            foreach (var url in urls)
            {
                var response = await _client.GetAsync(url);
                // byte[] bytes = await response.Content.ReadAsByteArrayAsync();
                // 一部のエラーステータスコード(例えば、404 Not Foundや204 No Contentなど)では、レスポンスの本文が存在しない場合がある
                byte[] bytes = response.Content != null ? await response.Content.ReadAsByteArrayAsync() : null;
                responses.Add(new HttpStreamResponse((int)response.StatusCode, bytes, url));
            }
            Console.WriteLine("Execute() end");
            return responses;
        }
    }
}

http_getter_app アプリプロジェクト作成 #

右ペインの apps/http_getter_app にカーソル合わせてマウス右ボタンで「追加」を選択し、
「新しいプロジェクト」を作成する
設定内容は以下の通り。

  • Windowsフォームアプリケーション(.NET Framework)
  • 名前(N): MainApp
  • 場所(L): C:\home\developer\work\cs_http_access_test\cs_apps\apps\http_getter_app
    ここでは、apps 配下に http_getter_app という物理ディレクトリを作成し、MainApp プロジェクトを作成している。
    この構成は、http_getter_app 配下に専用のライブラリ等を配置したい場合に都合がいいために実施している
  • フレームワーク(F): .NET Framework 4.7.2

http_streamを使用するクラス HttpObserver.cs ファイルを新規作成する
MainApp にマウスカーソルを合わせて、マウス右ボタンメニューから「追加」を選択

クラスを選択し、名前をHttpObserver.csとする

http_stream を MainApp プロジェクトから使用できるようにする。
MainApp プロジェクトの参照にマウスカーソルを合わせて右ボタンメニューから「参照の追加」を選択する

http_stream のチェックを入れ「OK」ボタン押下で参照設定が追加される。

http_stream 参照設定完了後、HttpObserver.cs に http_stream 呼び出しロジックを追加する

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
using concerns.http_stream;

namespace apps.http_getter_app.MainApp  // <-- 名前空間に注意
{
    public class HttpObserver : IHttpStreamObserver, IDisposable
    {
        public HttpStream m_httpStream;
        public HttpObserver()
        {
            this.m_httpStream = new HttpStream();
            this.m_httpStream.AddObserver(this);
        }

        public void Dispose()
        {
            if (this.m_httpStream != null)
            {
                this.m_httpStream.Dispose();
            }
        }

        public void RequestOneTime()
        {
            this.m_httpStream.Request("http://localhost:8080/api2/test");
        }

        public void RequestMultipleTime()
        {
            var urls = new List<string>() { "http://localhost:8080/api/test", "http://localhost:8080/api2/test" };
            this.m_httpStream.Requests(urls);
        }

        public void HttpStreamResponse(List<HttpStreamResponse> responses)
        {
            foreach (var response in responses)
            {
                Console.WriteLine("HttpStreamResponse received!!!");
                Console.WriteLine($"code:{response.m_code}\nbody:{response.GetBodyString()}\nurl:{response.m_url}");
            }
        }
    }
}

HttpObserver が完成したので、Form1 を画面を表示し「ボタン」を1個配置し、
そのボタンをクリックすると HttpObserver が http_stream に要求を出し、応答を得られるようにする

Form1.cs に HttpObserver の要求/応答ロジックを追加する

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace apps.http_getter_app.MainApp  // <-- 名前空間に注意
{
    public partial class Form1 : Form
    {
        public HttpObserver httpObserver;
        public Form1()
        {
            InitializeComponent();
            this.httpObserver = new HttpObserver();
            this.FormClosing += new FormClosingEventHandler(Form1_FormClosing);
        }

        private void button1_Click(object sender, EventArgs e)
        {
            Console.WriteLine("button1_Click() START");
            // this.httpObserver.RequestOneTime(); // 単品
            this.httpObserver.RequestMultipleTime(); // 複数
            Console.WriteLine("button1_Click END");
        }

        // Formが閉じられるときに呼び出されるイベントハンドラ
        private void Form1_FormClosing(object sender, FormClosingEventArgs e)
        {
            Console.WriteLine("Form1_FormClosing() START");
            if (this.httpObserver != null)
            {
                this.httpObserver.Dispose();
                this.httpObserver = null;
            }
            Console.WriteLine("Form1_FormClosing() END");
        }
    }
}

namespace 空間をディレクトリに合わせて設定する
(ここではソリューションディレクトリと物理ディレクトリは同一にしている)
対象ファイルは以下の通り。

  • Form1.cs
  • Form1.Designer.cs
  • Program.cs
  • HttpObserver.cs(新規追加したhttp_streamを使用するクラス)

実行する #

  1. dummy_server を起動する

    $ cd dummy_server
    # 初回のみ
    $ python -m venv venv
    $ source venv/Scripts/activate
    # 実行
    $ python server.py
    Bottle v0.12.25 server starting up (using WSGIRefServer())...
    Listening on http://localhost:8080/
    Hit Ctrl-C to quit.
    # 以後、アクセスログが表示される
    
  2. cs_apps/apps/http_getter_app/MainApp を起動する
    MainApp プロジェクトをスタータアッププロジェクトに設定し実行する(F5押下)

  3. cs_apps/apps/http_getter_app/MainApp のボタンを押下する
    以下の赤のボタンを押下すると実行される。
    また、青色箇所がデバッグログが出力されるエリア

    要求内容は以下の通りとしているが、 1回目は存在する api なので正常応答(200)でjsonがレスポンスされる。
    2回目は存在しない api なので異常応答(404)がレスポンスされている。

    • 1回目: “http://localhost:8080/api/test”
    • 2回目: “http://localhost:8080/api2/test”

    vsのデバッグログに以下のような出力がされれば正常動作

    button1_Click() START
    Requests() start
    Execute() start
    button1_Click END
    // 2回目の404のログ出力
    Retrying: StatusCode: NotFound Message: Not Found RequestUri: http://localhost:8080/api2/test
    Retrying: StatusCode: NotFound Message: Not Found RequestUri: http://localhost:8080/api2/test
    Retrying: StatusCode: NotFound Message: Not Found RequestUri: http://localhost:8080/api2/test
    Execute() end
    Requests() execute end
    HttpStreamResponse received!!!
    // 1回目 "http://localhost:8080/api/test"への要求(存在するapiなので応答が返っている)
    code:200
    body:{"status": "OK", "message": "hello world"}
    url:http://localhost:8080/api/test
    HttpStreamResponse received!!!
    // 2回目 "http://localhost:8080/api2/test"への要求(存在しないから404の応答が返っている)
    code:404
    body:
        <!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
        <html>
            <head>
                <title>Error: 404 Not Found</title>
                <style type="text/css">
                  html {background-color: #eee; font-family: sans;}
                  body {background-color: #fff; border: 1px solid #ddd;
                        padding: 15px; margin: 15px;}
                  pre {background-color: #eee; border: 1px solid #ddd; padding: 5px;}
                </style>
            </head>
            <body>
                <h1>Error: 404 Not Found</h1>
                <p>Sorry, the requested URL <tt>&#039;http://localhost:8080/api2/test&#039;</tt>
                   caused an error:</p>
                <pre>Not found: &#039;/api2/test&#039;</pre>
            </body>
        </html>
    
    url:http://localhost:8080/api2/test
    Requests() end
    

vs2017 その他設定 #

ドラキュラテーマ適用 #

以下の手順でコード部分がドラキュラテーマになる。
※vs2017(2019)まではこの手順で問題ないが、それ以外は、公式の README.md を参照するほうがいい。

  1. 2019/2017: テーマをGitHubからダウンロードし展開 https://github.com/dracula/visual-studio/archive/2019.zip
  2. vs2017 起動
  3. 配色を「青」にする
    「ツール」→「オプション」を選択し、「環境」/「全般」の配色テーマを「青」にする
    ※ドラキュラテーマは単純にコード配色だっけっぽいので「青」を選択
  4. ドラキュラテーマ(コード配色)を反映
    1. 「ツール」→「設定のインポート/エクスポート」を選択 
    2. 設定のインポートとエクスポート画面が表示される
      「選択された環境設定をインポート」選択。「次へ」ボタン押下
    3. 現在設定の保存確認
      保存しなくてもいい。保存したい場合は適当に設定。「次へ」ボタン押下
    4. インポートする設定コレクションの選択
      「参照」からダウンロードしたドラキュラ設定「dracula.vssettings」を選択し「次へ」ボタン押下
    5. インポートする設定の選択
      何も変更しない。「完了」ボタン押下

ウィンドウ表示状態を元に戻す #

たとえば、間違って必要なタブを削除して再表示のさせ方がわからない場合等に使える

メニューから「ウィンドウ (W)」 → 「ウィンドウ レイアウトのリセット」を選択

参考URL #