LoginSignup
48
52

More than 3 years have passed since last update.

IHttpClientFactory を使って今はこれ一択と思った話

Last updated at Posted at 2020-06-07

C#のプログラマとしては、Httpリクエストを送りたいときは、HttpClient 使ってきたわけですが、存在は知っていたものの IHttpClientFactory を試したことが無かったので、試してみました。実際に試してみると、自分がこういうシナリオでやりたいことがほぼカバーされているので、自分へのメモとシェアのためにこのブログの記事を書いています。

HttpClient の問題

ソケット枯渇の問題

HttpClient でよく起る問題はずばり「使われ方」です。帝国兵さんがHttpClientをusingで囲わないでくださいという人気記事でかかれているとおり、HttpClientは毎回インスタンス化して使うように設計されていません。使う都度、HttpClient を Create/Dispose し続けていると、何が起こるか?というと、Socketがすぐにリリースされずに、ソケットの枯渇が発生します。ですので、HttpClinet を使うときは DI を使って、毎回インスタンス化しないようにするか、static で HttpClient を使いまわす必要があります。特に、Azure Functions や、AppService を使っているときは問題になるので絶対に避けたほうがいいと思います。

DNSが変更されたときの問題

クラウドの世界では、DNSのエントリが自動で書き換えられるシナリオが良く発生します。例えばTrafficManager はDNSのエントリを変えることでルーティングを変更します。ところが、上記の理由でHttpClient を static/Singletonで使っていると、DNSエントリの変更が反映されません。Singleton HttpClient doesn't respect DNS changesのIssueでも、.NetCoreの時に解決策がなく、昔自分でDelegateHandlerか何かをオーバライドして実装した思い出があります。(定期的に無理やりキャッシュをクリアする)

これらの問題は、HttpClient のデフォルトコンストラクタで、それが、HttpMessageHandlerのインスタンスを作るからで、これが、ソケット枯渇の問題を引き起こします。

IHttpClientFactory の利点

IHttpClientFactoryは、DependencyIncjectionを使用する前提となっていますので、ソケット枯渇の問題が起こりません。また、DNSの設定変更の問題も、HttpMessageHandlerがファクトリに管理されることによって、ソケット枯渇の問題を気にすることなくタイムアウトなどを設定などのインスタンスのコントロールが可能です。というわけで、上記の HttpClientの課題が、IHttpClientFactoryを使うだけで解決するようです。

  • Pollyとインテグレートされていて、リトライや、サーキットブレーカーが簡単に書ける
  • HttpClientのオブジェクトが、1か所で管理される(DIが使用される)
  • HttpMessageHandler がファクトリによって管理されるので、HttpClientのインスタンスの削除が安全に行われる

IHttpClientFactory の使われ方

image.png

ClientService がHttpClientFactoryのインジェクトされるコードです。DIを使うとHttpClientFactoryがInjectionされます。問題となっていた、HttpMessageHandlerは特定のインスタンスが強く結びつくかわりに、プールの中から選ばれて紐づきます。また、Pollyの機能によって、リトライや、サーキットブレーカーを設定することが可能です。今までは、DIでは単純にHttpClientのインスタンスを生成して渡していたと思いますが、Factoryを経由することで、HttpClient自体の廃棄のコントロールも行われることになります。

大まかなイメージをつかんだら、具体的なコードで理解していきましょう。

 JSONダウンロードサンプルで理解する HttpClientFactory

今回はサンプルとして、Blob ストレージに置いたJSONファイルをダウンロードするシナリオを考えてみます。JSONファイルは削除されたり、作られたりするので、タイミングによってはファイルが見つからないケースがあります。ですので是非ともリトライはしたいところです。

使用するライブラリ

NuGet のパッケージは以下のものを使っています。csprojファイルをご参照ください。Microsoft.Extensions.DependencyInjectionは、DependencyInjectionをするために必要です。皆さんがもしASP.NETを使っている場合は最初から含まれています。ただ、今回のように、コンソールアプリケーションを使っている場合は、NuGetパッケージとして読み込んであげる必要があります。Microsoft.Extensions.HttpにIHttpClientFactoryが含まれます。Pollyとのインテグレーションを使いたい場合は、Microsoft.Extensions.Http.Pollyを追加します。

crsproj

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp3.1</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="3.1.4" />
    <PackageReference Include="Microsoft.Extensions.Http" Version="3.1.4" />
    <PackageReference Include="Microsoft.Extensions.Http.Polly" Version="3.1.4" />
    <PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
  </ItemGroup>

</Project>

HttpClientを使いたいクラス

先ほどの絵では、ClientServiceに相当する、HttpClientを使いたいクラスを定義しましょう。Interface とその実装になっています。とても単純で、コンストラクタに、HttpClientを引数としてとるようにすると、DIが自動でインジェクションしてくれます。ですので、こちらのコードで見てわかる通り、普通のHttpClientをDIでインジェクションするときのコードと全く同じで、リトライなどの設定をここでする必要はありません。
このクラスは、Executeメソッドを使うと、渡ってきたUrlのリストに対してリクエストを送り、そのBodyを表示するだけの単純なものです。

BlobService.cs

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

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

        public async Task Execute(IEnumerable<string> urls)
        {
            foreach (var url in urls)
            {
                // TODO Retry
                var response = await _client.GetAsync(url);
                Console.WriteLine($"StatusCode: {response.StatusCode}");
                if (response.IsSuccessStatusCode)
                {
                    var body = await response.Content.ReadAsStringAsync();
                    Console.WriteLine(body);
                }
            }
        }
    }

DI の設定

基本的な使い方

次に、DIの設定です。HttpClientFactoryを使うだけならとても簡単です。先ほどインストールしたMicrosoft.Extensions.Httpに含まれる拡張メソッドのAddHttpClientを用いてHttpClientを使いたいサービスを登録するのみです。内部では、HttpClientが直接生成されるわけではなく、HttpClientFactory経由で生成されたHttpClientのインスタンスがこのサービスに引き渡されます。ちなみに、IHttpClientFactoryのインスタンスも、コンストラクタ経由で取得することも可能です。

var service = new ServiceCollection();
service.AddHttpClient<IBlobService, BlobService>();

使うときには DI 経由でサービスのインスタンスを取得すればよいです。基本的な使い方はこれで終了!HttpClientを使うときはもともと staticかDIを使うしかなく、大抵の場合は、本番のシステムではDIが選択されることが多いと思うのでそれを考えるとほぼ手間が変わらないと思われます。

var provider = service.BuildServiceProvider();
IBlobService blobService = provider.GetRequiredService<IBlobService>();
await blobService.Execute(urls);

タイムアウトを設定する

最初にご紹介したDNSが変更されても反映されない問題を対処するために、タイムアウトの設定をしたいと思います。これもとても簡単です。

var service = new ServiceCollection();
service.AddHttpClient<IBlobService, BlobService>()
  .SetHandlerLifetime(TimeSpan.FromMinutes(5))

先ほどのコードにSetHandlerLifetimeを設定するだけです。デフォルトは2分です。HttpClientで使うHttpMessageHandlerのタイムアウトをここで設定します。この時間が過ぎるとExpireします。Expire後もすぐにインスタンスが削除されるわけではありません。もし、ロングランニングなHttpClientインスタンスがあった場合、参照がなかなか削除されないようです。

リトライを設定する

次にリトライを設定したい場合のコードを見てみましょう。リトライをするのにあたって、3秒後とかの設定をするのはあまり良い手ではありません。もし一時的な障害が発生したとして、3秒後にリトライを設定すると、多くのインスタンスが、3秒後に集中して再度リクエストを起こします。またその3秒後に大量のリクエストが発生します。つまり、短い時間に集中的に何回もリクエストが発生するのでシステムが死んでしまうかもしれません。そこで通常は、Exponential Backoffというアルゴリズムを使います。最初は2秒後にリトライ、その次は4秒後、その次は、8秒後といったように指数関数的にリトライのタイミングを長くしていきます。すると、リトライが頻繁に発生する状況を減らすことができます。

これを実現するのがこのコードです。DIの箇所でAddPolicyHandler拡張メソッドに、Policyの設定をするコードを書いてあげるだけです。最初のHttpPolicyExtensions.HandlerTransientHttpError()は、どういうときにリトライするかの設定です。ステータスコードが500番台もしくは、408でリトライが発生します。しかし、私のシナリオでは十分ではありません。ファイルが一瞬書き換えられるときに404のNOTFOUNDが発生する可能性があります。そういうときは、OrResultメソッドで条件を追加することができます。

WaitAndRetryAsyncメソッドがまさにリトライの設定のメソッドです。retryAttemptにはリトライ回数が渡されますので、Math.Powで指数関数的にリトライ時間が長くなるように設定しているわけです。

                        :
            var service = new ServiceCollection();
            service.AddHttpClient<IBlobService, BlobService>()
                .SetHandlerLifetime(TimeSpan.FromMinutes(5))
                .AddPolicyHandler(GetRetryPolicy());
                         :
        private static 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));
       }

リトライに失敗した時の挙動

本格的に障害が発生したときは、リトライを繰り返すのではなく、エラーを出してほしいものです。私が手元で実験したときには、所定の回数(ここでは3回)リトライしてもダメだった場合は、HttpClientを使っている箇所で、エラーのステータスコードが帰ります。BlobServiceの一部のコードですが、ここでは、responseのステータスコードにおそらく最後に発生したエラーが返却されました。(例えば404等)

var response = await _client.GetAsync(url);
Console.WriteLine($"StatusCode: {response.StatusCode}");

Jitter アルゴリズムの導入

ちなみに、Exponential Backoffを使っても、問題が発生するケースがあります。時間間隔があくので前よりましですが、まったく同じ時間に大量のリクエストがあるような仕組みだと、何分かたった段階の全く同じ時間に再度大量のリクエストが送られます。この状況を回避するために、Jitterアルゴリズムを使います。単純に言うと、乱数を使って、それぞれで、微妙にタイミングをずらしてあげればリクエストが分散します。

            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));

リトライ時のロギングの実装

実際に使ってみるといい感じですが、使って気づいたことに、ログがはかれないというのがありました。自分が運用するとすると、リトライが発生したら、Warningでいいので、ログに残しておきたいと思うでしょう。その時には、WaitAndRetryAsyncメソッドに、良いオーバーロードがありました。それは、onRetryという関数を渡せるオーバーロードです。これを使うと、リトライの時に該当の関数が呼ばれるので、そこでリトライ時に何かさせたいときにはコードを書いておいたらいいと思います。このコードが実験の最終系です。

private static 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) =>
         {
             Console.WriteLine($"Retrying: StatusCode: {response.Result.StatusCode} Message: {response.Result.ReasonPhrase} RequestUri: {response.Result.RequestMessage.RequestUri}");
         });
 }

サンプルの全体像と実行

サンプル

このサンプルは、単純にhttps://some.blob.core.windows.net/healthcheck/hello.jsonからhello.jsonをダウンロードしようとするサンプルです。

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

namespace ExtremeSpike
{
    class Program
    {
        static async Task Main(string[] args)
        {
            var urls = new List<string>()
            {
                "https://some.blob.core.windows.net/healthcheck/hello.json"
            };
            var service = new ServiceCollection();
            service.AddHttpClient<IBlobService, BlobService>()
                .SetHandlerLifetime(TimeSpan.FromMinutes(5))
                .AddPolicyHandler(GetRetryPolicy());
            var provider = service.BuildServiceProvider();
            IBlobService blobService = provider.GetRequiredService<IBlobService>();
            await blobService.Execute(urls);
        }

        private static 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) =>
                    {
                        Console.WriteLine($"Retrying: StatusCode: {response.Result.StatusCode} Message: {response.Result.ReasonPhrase} RequestUri: {response.Result.RequestMessage.RequestUri}");
                    });
        }
    }

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

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

        public async Task Execute(IEnumerable<string> urls)
        {
            foreach (var url in urls)
            {
                // TODO Retry
                var response = await _client.GetAsync(url);
                Console.WriteLine($"StatusCode: {response.StatusCode}");
                if (response.IsSuccessStatusCode)
                {
                    var body = await response.Content.ReadAsStringAsync();
                    Console.WriteLine(body);
                }
            }
        }
    }
}

実行

実行時には、hello.jsonはあらかじめアップロードしていません。最初のリトライが起こったところで、Uploadしてリトライが起こったら回復できるにようしてみました。
リトライのログがしっかり記録されていますし、2回のリトライの後3回目で成功したので、BlobService側のコードとしては、StatusCode: OKが帰っており、その値が確認できます。

{"downloadUri":"https://abc.com","state":0,"updatedTime":"2020-06-07T19:26:16.2509792Z"}
Read From blob ---
Retrying: StatusCode: NotFound Message: The specified blob does not exist. RequestUri: https://some.blob.core.windows.net/healthcheck/hello.json
Retrying: StatusCode: NotFound Message: The specified blob does not exist. RequestUri: https://some.blob.core.windows.net/healthcheck/hello.json
StatusCode: OK
{"downloadUri":"https://abc.com","state":2,"updatedTime":"2020-06-07T01:51:50.7047331Z"}

C:\Users\tsushi\source\repos\ExtremeSpike\ExtremeSpike\bin\Debug\netcoreapp3.1\ExtremeSpike.exe (process 67564) exited with code 0.
To automatically close the console when debugging stops, enable Tools->Options->Debugging->Automatically close the console when debugging stops.
Press any key to close this window . . .

まとめ

IHttpClientFactoryを使うことで、従来 HttpClientを生で使用するときの様々な問題が解決したり、自分でコントロールできるようになります。そのうえ、使い勝手は、DIを使う前提であれば、前と同じ手間でさらに、リトライやサーキットブレーカーなどのPollyの機能を簡単に使えるようになりました。これに該当しないケースは、DIが使えないケースとか、サンプルで書いているだけなので、DIを導入するのがめんどくさいケースだと思いますが、大抵の用途では、DIを使うと思うので、ほぼ、生のHttpClientを直生成して使うメリットがもうない気がします。というわけで、これからは、IHttpClientFactoryをサンプル以外では積極的に使っていこうと思います。

48
52
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
48
52