ASP.NET Core 2.1にて追加されたHttpClientFactory(とその周辺)を試す

お断り

試した環境は以下の通りで、それ以外の環境については一切試していません。

  • Visual Studio Community 2017 (15.6)
  • Miscrosoft.AspNetCore.App (2.1.0-preview1-final)
  • Miscrosoft.NETCore.App (2.1.0-previwe1-26216-03)

HttpClient についてのアレコレ

System.Net.Http.HttpClient はIDisposableを実装しているのに、データアクセスのたびにusingを用いることは好ましくないという罠があります。 もちろん私もDbConnectionやStreamを扱うような感じでじゃんじゃんusingしまくっていた一人です。

www.infoq.com

qiita.com

では、どうすれば良かったのかというと、HttpClientオブジェクトをシングルトンのようにして扱わないといけないとのことです。

シングルトンを使うとテストを記述するのが一段階難しくなるので出来れば御免被りたいところです。

HttpClientFactory

HttpClientの使い勝手が直感に反する問題を受けてか、HttpClientFactoryASP.NET Core 2.1にて追加されました*1

www.stevejgordon.co.uk

https://github.com/aspnet/HttpClientFactory/blob/dev/src/Microsoft.Extensions.Http/DefaultHttpClientFactory.cs#L110github.com

読んで字のごとく、HttpClientを返すFactoryなのですが、悩ましかったライフサイクル管理を自分でしなくてもよくなるようです。

var client = _httpClientFactory.CreateClient();
var result = await client.GetStringAsync("http://~");

CreateClient()が返すものはやっぱりHttpClientで、依然としてIDisposableを実装しているので、下記のような使い方はダメです。
(追記:実装を読むと、disposeHandler: falseとなっているのでusingしても問題は無いのかもしれません)

//NG! 色んな人の努力が無に帰す
using(var client = _httpClientFactory.CreateClient()){
  var result = await client.GetStringAsync("http://~");
}

Dependency Injectionと組み合わせる

さて、HttpClientFactoryのままでもそれなりに使えるのですが、上記Webサイト内でTyped Clientsと称されている使い方とDIを組み合わせてみます。

ソースコード

//[SampleDojo.Repository] Project

using System.Threading.Tasks;

namespace SampleDojo.Repository
{
  //ITodoRepository.cs
  public interface ITodoRepository
  {
    Task<string> GetTodoAsync();
  }
  //IGorillaRepository.cs
  public interface IGorillaRepository
  {
    Task<string> GetGorillaAsync();
  }
}
//[SampleDojo.Repository.Http] Project
using System;
using System.Threading.Tasks;
using System.Net.Http;
using Microsoft.Extensions.Logging;

namespace SampleDojo.Repository.Http
{
  //TodoRepository.cs
  public class TodoRepository : ITodoRepository
  {
    private readonly HttpClient httpClient;
    private readonly ILogger<TodoRepository> logger;

    public TodoRepository(HttpClient httpClient, ILogger<TodoRepository> logger)
    {
      this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
      this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
    }

    public async Task<string> GetTodoAsync()
    {
      var uri = "/api/todo";
      return await httpClient.GetStringAsync(uri).ConfigureAwait(false);

      //return await Task.FromResult(httpClient.BaseAddress.ToString());
    }
  }

  //GorillaRepository.cs
  public class GorillaRepository : IGorillaRepository
  {
    private readonly HttpClient httpClient;
    private readonly ILogger<GorillaRepository> logger;

    public GorillaRepository(HttpClient httpClient, ILogger<GorillaRepository> logger)
    {
      this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
      this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
    }

    public async Task<string> GetGorillaAsync()
    {
      var uri = "/api/gorilla";
      return await httpClient.GetStringAsync(uri).ConfigureAwait(false);
      //return await Task.FromResult(httpClient.BaseAddress.ToString());
    }
  }
}
//[SampleDojo.WebSite] Project
//Startup.cs
  public class Startup
  {

    public void ConfigureServices(IServiceCollection services)
    {
      var uri = new Uri("http://localhost:5005");
      var gorillaUri = new Uri("http://localhost:55555");
      services.AddHttpClient<ITodoRepository, Repository.Http.TodoRepository>(c => c.BaseAddress = uri);
      services.AddHttpClient<IGorillaRepository, Repository.Http.GorillaRepository>(c => c.BaseAddress = gorillaUri);

SampleDojo.WebSite.Startup().ConfigureServices(IServiceCollection )内、AddHttpClientHttpClientFactory周辺のDIとなります。 これによりRepository.Http.TodoRepositoryのコンストラクタには、BaseAddress : http://localhost:5005と設定されたHttpClientがInjectされます。

ページでの利用は、例えば下記の通りです。

namespace SampleDojo.WebSite.Pages
{
  public class TodoModel : PageModel
  {
    private readonly ITodoRepository todoService;

    public ContactModel(ITodoRepository todoService)
    {
      this.todoService = todoService ?? throw new ArgumentNullException(nameof(todoService));
    }

    public string Message { get; set; }

    public async Task OnGet()
    {
      Message = await todoService.GetTodoAsync().ConfigureAwait(false);
    }
  }
}

RepositoryのDapper実装例と比較してみる

次に、SampleDojo.Repositoryの各インターフェースをDapperを用いた実装例を作成してみます。

//[SampleDojo.Repository.SqlServer] Project
using Dapper;
using Microsoft.Extensions.Logging;
using System;
using System.Data;
using System.Threading.Tasks;

namespace SampleDojo.Repository.SqlServer
{
  //TodoRepository.cs
  public class TodoRepository : ITodoRepository
  {
    private readonly IDbConnection dbConnection;
    private readonly ILogger<TodoRepository> logger;

    public TodoRepository(IDbConnection dbConnection, ILogger<TodoRepository> logger)
    {
      this.dbConnection = dbConnection ?? throw new ArgumentNullException(nameof(dbConnection));
      this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
    }

    public async Task<string> GetTodoAsync()
    {
      var sql = "SELECT ~";
      return await dbConnection.QueryFirstOrDefaultAsync<string>(sql).ConfigureAwait(false);
      //return await Task.FromResult(dbConnection.ConnectionString);
    }
  }
  //GorillaRepository.cs
  public class GorillaRepository : IGorillaRepository
  {
    private readonly IDbConnection dbConnection;
    private readonly ILogger<GorillaRepository> logger;

    public GorillaRepository(IDbConnection dbConnection, ILogger<GorillaRepository> logger)
    {
      this.dbConnection = dbConnection ?? throw new ArgumentNullException(nameof(dbConnection));
      this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
    }

    public async Task<string> GetGorillaAsync()
    {
      var sql = "SELECT ~";
      return await dbConnection.QueryFirstOrDefaultAsync<string>(sql).ConfigureAwait(false);
      //return await Task.FromResult(dbConnection.ConnectionString);
    }
  }
}
//[SampleDojo.WebSite] Project
//Startup.cs
  public class Startup
  {

    public void ConfigureServices(IServiceCollection services)
    {
      var connectionString = Configuration.GetConnectionString("DefaultConnection");
      services.AddScoped<IDbConnection>(p => new SqlConnection(connectionString));
      services.AddTransient<ITodoRepository, Repository.SqlServer.TodoRepository>();
      services.AddTransient<IGorillaRepository, Repository.SqlServer.GorillaRepository>();

Repositoryの実装部分、SampleDojo.Repository.HttpSampleDojo.Repository.SqlServerのコードは、形が非常に似ました。 ここで重要なことは、形が同じであるけれど、SampleDojo.Repository.HttpHttpClientHttpClientFactoryを利用しているおかげでDisposeされることはなくされても問題なく動作し、SampleDojo.Repository.SqlServerでのIDbConnectionはDIコンテナにより正しくDisposeされる*2ということです。

このように、データアクセス方法によらずロジックを似せることが出来ました。これぞDIの妙と言ったところではないでしょうか。

感想

HttpClientFactoryとその周辺を試しました。非常に便利なものになると思います。

*1:執筆時にはPreviewなので「追加されます」

*2:そう信じてきましたがよく考えたら確認したことはありません。要検証