WebSurfer's Home

トップ > Blog 1   |   ログイン
APMLフィルター

Windows Forms で IHttpClientFactory 利用 (CORE)

by WebSurfer 2021年3月12日 13:05

Windows Forms アプリから Web API などにアクセスする際、IHttpClientFactory を利用して HTTP 接続プールを作り、それから HttpClient インスタンスを取得してアクセスする方法を書きます。

WinForms から ASP.NET Web API にアクセス

HttpClient のインスタンスを生成すると、そのたびにソケットも生成されます。しかし、HttpClient のインスタンスを Dispose してもソケットはすぐにはクローズされないので(デフォルトで 4 分かかるそう)、短期間で何度も生成 / Dispose を繰り返すとソケットの枯渇につながるという問題があり、それを避けるため、HttpClient のインスタンスはシングルトンにしてアプリで使いまわすということが推奨されています。ただし、長期にわたってシングルトンにした HttpClient のインスタンスを使い続けると、DNS の変更が反映されないという別の問題があるそうです。

先の記事「ASP.NET と HttpClient (CORE)」で書きましたように、Core 2.1 以降の ASP.NET Web アプリでは IHttpClientFactory を利用して、Microsoft のドキュメント「IHttpClientFactory を使用して回復力の高い HTTP 要求を実装する」の Figure 8-4 にあるように HTTP 接続をプールして使うことができるそうです。

上に紹介した Microsoft のドキュメントによると、IHttpClientFactory オブジェクトを利用することにより HTTP 接続プールが生成でき、プールから HttpClient を取得して使うことにより上記の問題(socket の枯渇と DNS の変更)を回避できるということのようです。

そのためには、Core に備わっている DI 機能を使って IHttpClientFactory オブジェクトを注入する必要があるそうです。(自分が知らないだけで DI 以外の方法もあるかもしれませんが)

ASP.NET Core アプリを Visual Studio 2019 のテンプレートを使って生成した場合は DI 機能はプロジェクトに組み込まれるのですが、Windows Forms アプリの場合は自力で実装する必要があります。そのために以下の NuGet パッケージをインストールします。

  • Microsoft.Extensions.DependencyInjection
  • Microsoft.Extensions.Http

前者は DI 機能の実装のために必要です。それだけで、先の記事「.NET Core での Dependency Injection」に書きましたようにコンソールアプリにさえも DI 機能を実装できます。

後者は IServiceCollection(DI コンテナ)に対して AddHttpClient 拡張メソッドを使用し IHttpClientFactory を DI コンテナに登録するために必要だそうです。

ということで Core v5 の Windows Forms アプリで試してみました。比較のために、先の記事「HttpClient で ASP.NET Web API にアクセス」と全く同じ機能を実装し、HttpClient を利用するところだけ DI コンテナから注入された IHttpClientFactory が作る HTTP 接続プールを使うようにしてみました。

そのコードを以下にアップしておきます。上の画像を表示したものです。見やすくするため HttpClient の使い方のコメントは先の記事から削除していますので、必要なら上にリンクを張った先の記事を見てください。

HttpClientWebApi.cs

HttpClientWebApi クラスが HttpClient を使って Web API にアクセスするためのクラスです。初期化する際にコンストラクタで IHttpClientFactory を注入できるように設定します。クラスに実装した GetToken, PostData メソッドでは、IHttpClientFactory が作る HTTP 接続プールから CreateClient メソッドで HttpClient インスタンスを取得して使っています。

JSON のシリアライズ / デシリアライズには先の記事と同じく DataContractJsonSerializer を使っています。Core v3.0 以降であれば System.Text.Json の JsonSerializer クラスを使うべきかもしれませんが、比較しやすくするため DI 機能部分以外は先の記事と同じにするということでそうしています。

using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;
using System.Net.Http;
using System.IO;
using System.Runtime.Serialization;
using System.Runtime.Serialization.Json;

namespace WinFormsApp1
{
    class HttpClientWebApi
    {
        private readonly IHttpClientFactory _clientFactory;

        // コンストラクタで IHttpClientFactory を注入
        public HttpClientWebApi(IHttpClientFactory clientFactory)
        {
            this._clientFactory = clientFactory;
        }

        // id と password を loginUrl に送信してベアラトークンを
        // 取得し、戻り値として返すメソッド
        public async Task<string> GetToken(string id, 
                                           string password, 
                                           string loginUrl)
        {
            var param = new Dictionary<string, string>
            {
                { "grant_type", "password"},
                { "username", id },
                { "password", password }
            };

            var content = new FormUrlEncodedContent(param);

            // DI コンテナから注入された IHttpClientFactory が作る
            // HTTP 接続プールから HttpClient インスタンスを取得
            HttpClient client = _clientFactory.CreateClient();

            var response = await client.PostAsync(loginUrl, content);

            using (Stream responseStream =
                await response.Content.ReadAsStreamAsync())
            {
                var ser = new DataContractJsonSerializer(typeof(Token));
                Token auth = (Token)ser.ReadObject(responseStream);
                return auth.access_token;
            }
        }

        // id と name(この記事の例では "6" と "ガッチャマンの息子")
        // をベアラトークン token と共に apiUrl に POST 送信し、
        // Api から帰ってきた JSON 文字列をデシリアライズして文字列を
        // 組み立てて戻り値として返すメソッド
        public async Task<string> PostData(string id, 
                                           string name, 
                                           string apiUrl, 
                                           string token)
        {
            var request = new HttpRequestMessage(HttpMethod.Post, apiUrl);
            string postData = "";

            Hero postHero = new Hero
            {
                Id = int.Parse(id),
                Name = name
            };

            using (MemoryStream stream = new MemoryStream())
            {
                var ser = new DataContractJsonSerializer(typeof(Hero));
                ser.WriteObject(stream, postHero);
                stream.Position = 0;
                using (var reader = new StreamReader(stream))
                {
                    postData = reader.ReadToEnd();
                }
            }

            request.Content = new StringContent(postData,
                                            Encoding.UTF8,
                                            "application/json");

            request.Headers.Add("Authorization",
                                "Bearer " + token);


            // DI コンテナから注入された IHttpClientFactory が作る
            // HTTP 接続プールから HttpClient インスタンスを取得
            HttpClient client = _clientFactory.CreateClient();

            var response = await client.SendAsync(request);

            using (Stream responseStream =
                await response.Content.ReadAsStreamAsync())
            {
                var ser = new DataContractJsonSerializer(typeof(List<Hero>));
                List<Hero> heros = (List<Hero>)ser.ReadObject(responseStream);
                string result = "";
                foreach (Hero hero in heros)
                {
                    result += string.Format("{0}: {1}\r\n", hero.Id, hero.Name);
                }
                return result;
            }
        }
    }

    // トークン要求に対し応答として返ってくるデータ
    [DataContract]
    public class Token
    {
        [DataMember]
        public string access_token { get; set; }

        [DataMember]
        public string token_type { get; set; }

        [DataMember]
        public int expires_in { get; set; }

        [DataMember]
        public string userName { get; set; }
    }

    // Web API に POST 送信するデータ
    [DataContract]
    public class Hero
    {
        [DataMember]
        public int Id { get; set; }

        [DataMember]
        public string Name { get; set; }
    }
}

Form1.cs

Form1 のコンストラクタで IServiceCollection オブジェクト(DI コンテナ)を生成し、AddHttpClient メソッドで IHttpClientFactory を DI コンテナに登録しています。

さらに AddSingleton メソッドで上のコードの HttpClientWebApi クラスを登録してから、BuildServiceProvider メソッドで ServiceProvider オブジェクトを取得し、GetRequiredService<HttpClientWebApi> メソッドで IHttpClientFactory が DI 済の HttpClientWebApi インスタンスを取得しています。

using System;
using System.Windows.Forms;
using Microsoft.Extensions.DependencyInjection;

namespace WinFormsApp1
{
    public partial class Form1 : Form
    {
        private string loginUrl = "トークン要求先 URL";
        private string apiUrl = "Web API の URL";
        private string email = "ユーザー ID";
        private string passsword = "パスワード";
        private int id = 6;
        private string name = "ガッチャマンの息子";
        private string token = "";

        private HttpClientWebApi httpClientWebApi;

        public Form1()
        {
            InitializeComponent();

            this.textBox1.Text = email;
            this.textBox2.Text = passsword;
            this.textBox3.Text = id.ToString();
            this.textBox4.Text = name;

            IServiceCollection services = new ServiceCollection();

            // これを忘れないように!
            services.AddHttpClient();

            services.AddSingleton<HttpClientWebApi>();
            var provider = services.BuildServiceProvider();
            httpClientWebApi = provider.GetRequiredService<HttpClientWebApi>();
        }

        // [Get Token] ボタンクリック
        private async void button1_Click(object sender, EventArgs e)
        {
            if (!string.IsNullOrEmpty(this.token)) return;

            this.token = await httpClientWebApi.GetToken(this.textBox1.Text,
                                                         this.textBox2.Text, 
                                                         loginUrl);
        }

        // [Post Data] ボタンクリック
        private async void button2_Click(object sender, EventArgs e)
        {
            if (string.IsNullOrEmpty(this.token)) return;

            this.textBox5.Text = await httpClientWebApi.PostData(this.textBox3.Text,
                                                                 this.textBox4.Text, 
                                                                 apiUrl, 
                                                                 this.token);
        }
    }
}

上の HttpClientWebApi クラスのコードでは、HttpClient を使う直前にその都度 IHttpClientFactory.CreateClient メソッドで HTTP 接続プールから HttpClient インスタンスを取得しています。そうしないと DNS 変更の問題に対処できないと思われるからです。

Microsoft のドキュメント「ASP.NET Core で IHttpClientFactory を使用して HTTP 要求を行う」の「IHttpClientFactory の代替手段」のセクションに "Using IHttpClientFactory in a DI-enabled app avoids: ... Stale DNS problems by cycling HttpMessageHandler instances at regular intervals." と書いてありますように、定期的に HTTP 接続プールをリサイクルしているので、HttpClient を使う直前にプールからインスタンスを取得しないと IHttpClientFactory を使う意味がないということになるはずです。

それから、ADO.NET + SqlClient で使う接続プールのようにプールから取得してきた接続は使い終わったらプールに戻さないとプールの接続の枯渇になるのではと思ったのですが、IHttpClientFactory が作る HTTP 接続プールにはそういう心配はなさそうです。

使い終わった HttpClient を HTTP 接続プールに戻すために Dispose するのかと思いましたが、「HttpClient と有効期間の管理」のセクションに "HttpClient instances can generally be treated as .NET objects not requiring disposal. Disposal cancels outgoing requests and guarantees the given HttpClient instance can't be used after calling Dispose. IHttpClientFactory tracks and disposes resources used by HttpClient instances." と書いてあるように、そんな必要はなさそうです。

しかしながら、そもそも短い期間で一日に数回程度しか HttpClient は使わない Windows Forms アプリであれば、上記のように IHttpClientFactory を DI して使う必要はないかもしれません。

なので、そのような HttpClient の使い方であれば、先の記事「HttpClient で ASP.NET Web API にアクセス」に書いたように、単純に static と宣言して使い回す方が正解だと思います。

Tags: , ,

CORE

ASP.NET と HttpClient (CORE)

by WebSurfer 2020年11月8日 14:52

ASP.NET Core 3.1 MVC アプリから HttpClient を利用して他のサイトの ASP.NET Web API にアクセスして情報を取得する方法を書きます。

ASP.NET と HttpClient

HttpClient のインスタンスを生成すると、そのたびにソケットも生成されます。しかし、HttpClient のインスタンスを Dispose してもソケットはクローズされないので(下記注参照)、何度も繰り返すとソケットの枯渇につながるという問題があり、それを避けるため、HttpClient のインスタンスはシングルトンにしてアプリで使いまわすということを行うそうです。ただし、そうすると DNS の変更が反映されないという別の問題があるそうですが。

注: Microsoft のドキュメント「ASP.NET Core パフォーマンスのベストプラクティス」によると "Closed HttpClient instances leave sockets open in the TIME_WAIT state for a short period of time" とのことです。別の記事「開発者を苦しめる.NETのHttpClientのバグと紛らわしいドキュメント」にはデフォルトは 4 分と書いてあります。

.NET Framework 版の ASP.NET Web アプリでの対処方法は Microsoft のドキュメント「Improper Instantiation antipattern」の How to fix the problem というセクションに書かれているのを見つけました。

そのドキュメントには、コントローラーに、

private static readonly HttpClient httpClient;

という static フィールドを設けて、コントローラーのコンストラクタで、

httpClient = new HttpClient();

とすると書いてあります。しかし、コントローラーのコンストラクタはクライアントから要求を受けるたびに呼び出されるので、要求を受けるたびに HttpClient のインスタンスを新たに作るということになってしまうと思うのですが・・・ 無知な自分には何故それが問題ないのか理解し難いです。

でも、まぁ、Microsoft のドキュメントですし、検証したようですし、.NET Framework 版の ASP.NET アプリでは他に適当な手はなさそうですし、もし問題が起きたら Microsoft のせいにできるので(笑)、その方法を使ってみるのが良いかもしれません。

しかし、Core 2.1 以降の ASP.NET Web アプリでは話が違ってくるようで、Microsoft の以下のドキュメントに書いてある IHttpClientFactory を利用する手段があるそうです。

詳しい仕組みの理解はちょっと置いといて、要するに上の一番目の記事の IHttpClientFactory の代替手段のセクションに書いてある以下の点を信じればよさそうです。(翻訳がイマイチなので英語版)

Using IHttpClientFactory in a DI-enabled app avoids:

  • Resource exhaustion problems by pooling HttpMessageHandler instances.
  • Stale DNS problems by cycling HttpMessageHandler instances at regular intervals.

上の 2 つの問題の前者は HttpClient のインスタンスの生成・廃棄を繰り返すことによるソケットの枯渇、後者はそれに対処するためシングルトンにして長期に使いまわすと DNS の変更が反映されないことを言っており、Core に備わっている DI 機能を使って IHttpClientFactory オブジェクトを注入する方法でそれらの問題を回避できるということのようです。

というわけで、詳しい仕組みは理解できてませんが、とりあえず上の一番目の記事の「基本的な使用方法」のセクションに従って実装してみました。

Startup.cs

namespace MvcCoreApp
{
    public class Startup
    {        
        // ・・・中略・・・

        public void ConfigureServices(IServiceCollection services)
        {
            // 以下を追加。これにより IHttpClientFactory を DI できる
            services.AddHttpClient();

        // ・・・中略・・・
}

Controller / Action Method

using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using System.Net.Http;
using System.IO;
using System.Text.Json;
using System.Text.Encodings.Web;
using System.Text.Unicode;

namespace MvcCoreApp.Controllers
{
    public class IHttpClientFactoryController : Controller
    {
        private readonly IHttpClientFactory _clientFactory;

        public IHttpClientFactoryController(
                              IHttpClientFactory clientFactory)
        {
            _clientFactory = clientFactory;
        }

        public async Task<IActionResult> Index()
        {
            var request = new HttpRequestMessage(HttpMethod.Get, 
                                    "https://localhost:44365/values");
            HttpClient client = _clientFactory.CreateClient();
            HttpResponseMessage response = await client.SendAsync(request);
            List<Hero> list = null;

            if (response.IsSuccessStatusCode)
            {
                using (Stream responseStream = 
                              await response.Content.ReadAsStreamAsync())
                {
                    list = await System.Text.Json.JsonSerializer.
                           DeserializeAsync<List<Hero>>(responseStream);
                }
            }

            // JSON 文字列のエスケープ回避&インデント設定
            return Json(list, new JsonSerializerOptions
            {
                Encoder = JavaScriptEncoder.Create(UnicodeRanges.All),
                WriteIndented = true,
            });
        }
    }

    public class Hero
    {
        public int id { get; set; }
        public string name { get; set; }
    }
}

上のコントローラーの Index アクションメソッドを呼び出した結果がこの記事の上の方にある画像です。一応動くということを確認しただけで、ソケットの枯渇とか DNS の変更に対応できているかは分かりませんが。 (汗)

最後にもう一つ。ASP.NET Core 3.1 Web API が返す JSON 文字列のキーの最初の文字が小文字になってしまうことに注意してください。Web API でも同様で、デフォルトで camel casing になるということだそうです。なので、上の Hero クラスのプロパティの最初の文字を小文字にしておかないとデシリアライズに失敗します。camel casing になるのを回避する方法はあります。詳しくは別の記事「JsonSerializer の Camel Casing」を見てください。

Tags: , ,

CORE

About this blog

2010年5月にこのブログを立ち上げました。主に ASP.NET Web アプリ関係の記事です。

Calendar

<<  2024年4月  >>
31123456
78910111213
14151617181920
21222324252627
2829301234
567891011

View posts in large calendar