WebSurfer's Home

トップ > Blog 1   |   Login
Filter by APML

ASP.NET と HttpClient

by WebSurfer 8. November 2020 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 文字列のキーの最初の文字が小文字になってしまうことです。上の Hero クラスのプロパティの最初の文字を小文字にしておかないとデシリアライズに失敗します。それに気が付かず半日以上ハマってしまいました。

Tags: , ,

CORE

HttpClient で ASP.NET Web API にアクセス

by WebSurfer 29. September 2019 12:44

Web API に HttpClient を使ってアクセスし、認証トークンを取得して、JSON 形式のデータを POST 送信するサンプルを書きます。

HttpClient で ASP.NET Web API にアクセス

アクセス先の Web API は先の記事「ASP.NET MVC に Web API 追加」に書いたもので、具体的には、Visual Studio 2015 のテンプレートで生成した既存の MVC5 プロジェクトに、別途 Web API プロジェクトを作って必要なパッケージ、コードを追加したものです。

既存の MVC5 プロジェクトは ASP.NET Identity を利用してクッキーベースのユーザー認証を行っています。

追加した Web API のユーザー認証はクッキーベースとするのではなく、Web API で推奨されているトークンベースとしています。

MVC5 側はクッキーベースで、Web API 側はトークンベースで独立して認証が働きます。なお、ユーザ情報はどちらも ASP.NET Identity から得ています。

その他、運用上は SSL の実装は必須ということで、SSL 通信を強制するためのフィルターを追加しています。

HttpClient を使ったアプリから Web API にアクセスするには Microsoft のドキュメント Call a Web API From a .NET Client (C#) で紹介されている Microsoft.AspNet.WebApi.Client を使うのが便利だそうですが、ここではそれは使わないで実装してみました。

基本的には先の記事「HttpClient で WCF サービスを呼出」の実装例とほぼ同じです。

ただし、認証トークンを取得するには、ユーザー情報を application/x-www-form-urlencoded 形式で POST しなければなりませんが、そこのところが上の記事とは異なります。

認証トークンを得るためには、先の記事「ASP.NET Web API の認証」で書きましたように、grant_type, username, password 情報をトークンエンドポイント /Token に POST します。

認証に成功すると access_token, token_type, expires_in, userName, .issued, .expires という情報が JSON 文字列として返ってきます。その中の access_token が認証トークンです。

Web API にアクセスする際は要求ヘッダに Authorization: Bearer に続けて空白一文字+有効な認証トークンを設定してやれば認証が通ります。

認証トークンの取得と JSON 形式のデータを POST 送信する Windows Forms アプリのサンプルコードは以下の通りです。説明はコードの中にコメントで入れましたので、それを見てください。

using System;
using System.Collections.Generic;
using System.Text;
using System.Windows.Forms;
using System.Net.Http;
using System.IO;
using System.Runtime.Serialization;
using System.Runtime.Serialization.Json;

namespace WindowsFormsApplication2
{
    public partial class Form5 : Form
    {
        // socket 浪費防止のため static のして使い回す
        private static HttpClient client;

        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 = "";

        public Form5()
        {
            InitializeComponent();

            if (client == null)
            {
                client = new HttpClient();
            }

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

        // 認証トークンの取得
        private async void button1_Click(object sender, 
                                                EventArgs e)
        {
            if (!string.IsNullOrEmpty(this.token)) return;

            // 認証に必要なユーザー情報は
            // application/x-www-form-urlencoded 形式で POST
            // 送信する。
            // そのために送信するユーザー情報から以下のように
            // Dictionary<string, string> オブジェクトを生成し、
            var param = new Dictionary<string, string>
            {
                { "grant_type", "password"},
                { "username", this.textBox1.Text },
                { "password", this.textBox2.Text }
            };

            // それを引数に FormUrlEncodedContent オブジェクト
            // を生成・初期化して PostAsync で送信する。
            // Content-Type: application/x-www-form-urlencoded
            // は自動的に設定されるとのこと
            var content = new FormUrlEncodedContent(param);
            var response = 
                 await client.PostAsync(this.loginUrl, content);

            // 応答コンテンツを Stream として取得
            using (Stream responseStream = 
                await response.Content.ReadAsStreamAsync())
            {
                // JSON シリアライザの初期化
                var ser = 
                  new DataContractJsonSerializer(typeof(Token));

                // 応答コンテンツを逆シリアル化して C# のオブジ
                // ェクトを取得
                Token auth = 
                         (Token)ser.ReadObject(responseStream);

                // 認証トークンを token フィールドに設定
                this.token = auth.access_token;
            }
        }

        // JSON 形式のデータを POST 送信
        private async void button2_Click(object sender, 
                                                EventArgs e)
        {
            if (string.IsNullOrEmpty(this.token)) return;

            // POST 送信を指定
            var request = 
                new HttpRequestMessage(HttpMethod.Post, apiUrl);

            // POST 送信する JSON 文字列
            string postData = "";

            // Hero オブジェクトを生成しそれを JSON 文字列に
            // シリアライズする
            Hero postHero = new Hero
            {
                Id = int.Parse(this.textBox3.Text),
                Name = this.textBox4.Text
            };

            // シリアライズは DataContractJsonSerializer を使う
            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();
                }
            }

            // Content-Type: application/json; charset=utf-8 が
            // 要求ヘッダに必要。それを POST 送信する JSON 文字
            // 列と共にここで設定
            request.Content = new StringContent(postData, 
                                            Encoding.UTF8, 
                                            "application/json");

            // 認証トークンを要求ヘッダに設定
            request.Headers.Add("Authorization", 
                                "Bearer " + this.token);

            // JSON 文字列を SendAsync で POST 送信する
            var response = await client.SendAsync(request);

            // 応答コンテンツを Stream として取得
            using (Stream responseStream = 
                await response.Content.ReadAsStreamAsync())
            {
                // JSON シリアライザの初期化
                var ser = new DataContractJsonSerializer(
                                           typeof(List<Hero>));

                // 応答コンテンツを逆シリアル化して C# のオブジ
                // ェクトを取得
                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);
                }
                this.textBox5.Text = result;
            }
        }
    }

    // 以下のクラス定義を public partial class Form5 : Form の
    // 上に持ってくるとデザイン画面が開かないので注意

    // トークン要求に対し応答として返ってくるデータ
    // access_token, token_type, expires_in は OAuth2 で定めら
    // れているもの。userName は informational。他に .issued,
    // .expires というデータも返ってくるが . がプロパティの識
    // 別子として使えないので以下には設定しない
    [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; }
    }
}

上記のコードの実行結果がこの記事の上にある画像です。認証トークンを取得した後、Id と Name から作成した JSON 文字列を Web API に POST 送信し、返ってきた JSON 文字列を一番下のテキストボックスに表示したところです。

Tags: ,

Web API

応答ヘッダが 64KB を超えるとエラー

by WebSurfer 2. September 2019 12:00

HttpClient を使った HPPT 通信で、応答ヘッダが 64KB を超えると WebException 例外がスローされるという話を備忘録として書いておきます。

(元の話は Teratail のスレッド「GetAsync処理時のメッセージの長さが制限の解消方法について」のもので、実際に自分が経験した訳ではなく聞いた話です)

同様な問題は HttpWebResponse / HttpWebRequest を使った時から起こっていた問題だそうで、応答ヘッダが 64KB を超えると WebException がスローされ、以下のエラーメッセージが出るそうです。

"接続が切断されました: メッセージの長さが制限を超えています。"

英文では、

"The underlying connection was closed: The message length limit was exceeded."

応答ヘッダが 64KB を超えるというのはレアなのか、日本語のエラーメッセージでググっても参考になる記事はヒットしませんでした。

でも、英語圏まで検索範囲を広げる(英文でググる)と HttpWebResponse / HttpWebRequest でこの問題に遭遇した人はいるようで、WebException: "The message length limit was exceeded" 他の記事がヒットします。

その記事に書いてある解決策は、HttpWebRequest の MaximumResponseHeadersLength プロパティを -1 (無制限) に設定することだそうです。(未検証・未確認です)

HttpClient を使う場合は、.NET Framework 4.7.1 以降ですが、HttpClientHandler クラスMaxResponseHeadersLength プロパティを使って応答ヘッダのサイズの許容最大値を設定できるそうです。

// Create an HttpClientHandler object
HttpClientHandler handler = new HttpClientHandler();
handler.MaxResponseHeadersLength = 128;  // 128KB

// Create an HttpClient object
HttpClient client = new HttpClient(handler);

.NET 4.5 では MaxResponseHeadersLength プロパティは使えず .NET 4.7.1 で使えるようになったということは、HttpWebRequest / HttpWebResponse で起こっていた問題に対応できないことを指摘されて追加したのかもしれませんね。(想像です)

Tags: , , ,

.NET Framework

About this blog

2010年5月にこのブログを立ち上げました。その後 ブログ2 を追加し、ここは ASP.NET 関係のトピックス、ブログ2はそれ以外のトピックスに分けました。

Calendar

<<  November 2020  >>
MoTuWeThFrSaSu
2627282930311
2345678
9101112131415
16171819202122
23242526272829
30123456

View posts in large calendar