WebSurfer's Home

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

HttpClient のキャンセルは要求の中断に相当? (CORE)

by WebSurfer 2021年7月13日 12:31

下の画像のシステムで、クライアントがブラウザを操作して要求を中断した場合、Web API でのサーバーで実行中の処理をキャンセルできるでしょうか? 自分が検証した限りではできるようです。その詳細を以下に書きます。

システム構成

クライアントがブラウザを使って MVC にアクセスすると、MVC のサーバーは HttpClient クラスを使って Web API にアクセスして必要な情報を取得し、ブラウザに応答として返すというシステムです。

クライアントが要求を送信した後待ちきれなくなって、サーバーによる処理が終わって応答が返ってくる前に要求を中断した場合、それ以上サーバーのリソースを消費しないで済むよう、サーバー側の処理を MVC でも Web API でも中断できるかがポイントです。

なお、クライアントによる要求の中断とは、下の画像の赤丸印の中のブラウザの ✕ ボタンをクリックするとか Esc キーを押す、Ajax を使っての要求の場合は XMLHttpRequest.abort() メソッドを実行することを意味します。

要求の中断

先の記事「要求の中断による処理のキャンセル (CORE)」に書きましたように、処理のキャンセルには HttpContext.RequestAborted プロパティで取得できる CancellationToken を利用します。クライアントが要求を中断すると、取得した CancellationToken がキャンセル通知を配信しますので、それをリッスンして処理の中断を行います。

ブラウザ ⇔ MVC の間は、先の記事に書いたように、MVC のアクションメソッドの引数に渡された CancellationToken によるキャンセル通知を利用して MVC のサーバー内での処理を中断できます。

その先の MVC ⇔ Web API の間は MVC のサーバーから HttpClient クラスを利用して Web API にアクセスするというシステムですが、そこがどうできるかをこの記事の下の方に載せた検証用のコードを使って調べてみました。

MVC のアクションメソッドでは次のようにします。HttpClient クラスの SendAsync メソッドや PostAsync メソッドには引数に CancellationToken を取るオーバーロードがあるので、それに HttpContext.RequestAborted プロパティで取得できる CancellationToken を渡します。そうすることで、クライアントによる要求の中断で SendAsync メソッドや PostAsync メソッドの実行をキャンセルできます。

Web API のアクションメソッドの引数にも Web API のサーバー内で HttpContext.RequestAborted プロパティで取得できる CancellationToken を渡します。そのキャンセル通知をリッスンして処理を中断します。

そうした場合、MVC のサーバー内で SendAsync メソッドや PostAsync メソッドの実行がキャンセルされると、Web API のアクションメソッドの引数に渡した CancellationToken はキャンセル通知を配信してくれるかが問題です。

下に載せたコードで検証した結果 Web API の CancellationToken もキャンセル通知を配信してくれることが分かりました。

なので、ブラウザ ⇔ MVC ⇔ Web API という構成でも、適切に CancellationToken を渡してキャンセル通知で処理の中断を行う実装をしておけば、ブラウザで要求を中断しても、MVC でも Web API でもサーバー内の処理を中断できるようです。

参考に検証に使った MVC および Web API のコードを以下に載せておきます。.NET 5.0 の ASP.NET Core アプリで MVC と Web API のプロジェクトは異なります (検証の際のホストが異なりますので、HttpContext.RequestAborted プロパティで取得できる CancellationToken は MVC と Web API で違うものになります)。

MVC(MvcCore5App4)

using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using MvcCore5App4.Models;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using System.Net.Http;

namespace MvcCore5App4.Controllers
{
    public class HomeController : Controller
    {        
        private readonly ILogger<HomeController> _logger;
        private readonly IHttpClientFactory _clientFactory;

        public HomeController(ILogger<HomeController> logger,
                              IHttpClientFactory clientFactory)
        {
            _logger = logger;
            _clientFactory = clientFactory;
        }

        // ・・・中略・・・

        public async Task<IActionResult> Cancel(CancellationToken token)
        {
            HttpClient client = _clientFactory.CreateClient();
            var url = "https://localhost:44398/api/values";

            // GET 要求する場合はこちら
            //var request = new HttpRequestMessage(HttpMethod.Get, url);
            //HttpResponseMessage response = 
            //                await client.SendAsync(request, token);

            // POST 要求する場合はこちら
            HttpResponseMessage response =
                            await client.PostAsync(url, null, token);

            if (response.IsSuccessStatusCode)
            {
                string result = 
                    await response.Content.ReadAsStringAsync(token);
                ViewBag.Result = result;
            }

            return View();
        }
    }
}

Web API (MvcCore5App2)

using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using System.Threading;
using System.Threading.Tasks;
using System;

namespace MvcCore5App2.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class ValuesController : ControllerBase
    {
        private readonly ILogger<ValuesController> _logger;

        public ValuesController(ILogger<ValuesController> logger)
        {
            _logger = logger;
        }

        [HttpGet]
        public async Task<IActionResult> Get(CancellationToken token)
        {
            _logger.LogInformation($"start: {DateTime.Now:ss.fff}");
            await Task.Delay(5000, token);
            _logger.LogInformation($"end: {DateTime.Now:ss.fff}");
            return Ok("GET 処理完了");
        }

        [HttpPost]
        public async Task<IActionResult> Post(CancellationToken token)
        {
            _logger.LogInformation($"start: {DateTime.Now:ss.fff}");
            await Task.Delay(5000, token);
            _logger.LogInformation($"end: {DateTime.Now:ss.fff}");
            return Ok("POST 処理完了");
        }
    }
}

なお、上記のように キャンセルができるのは、IIS を使ったインプロセス ホスティング モデルに限った話ですので注意してください。

インプロセス ホスティング モデル

先の記事「要求の中断による処理のキャンセル (CORE)」に書きましたように、アウトプロセス ホスティング モデルや Linux 系の OS で Nginx とか Apache をリバースプロキシに使う場合は CancellationToken のキャンセル通知を配信できませんので、サーバーでの処理の中断はできません。

それから、データベースサーバーを相手にする場合、Entity Framework で使う ToListAsync とか SaveChangesAsync などではどうなるかですが、そこはまだ調べ切れていません。走り出したらキャンセルは効かないということもあるかもしれません。今後の検討課題にしたいと思います。

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

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

by WebSurfer 2019年9月29日 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

About this blog

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

Calendar

<<  2024年3月  >>
252627282912
3456789
10111213141516
17181920212223
24252627282930
31123456

View posts in large calendar