😸

【C#】DIコンテナでFactoryメソッドを使う際の実装パターン

に公開

アプリケーションコードを記述する際、クラス間の依存関係を分離するためにDIコンテナがよく使われます。
C#においてもASP.netのDIコンテナが非常に便利ですが弱点もあります。
今回はAsyncLazyを使って弱点をスマートなDIコンテナを実現する方法を紹介します。

Factoryパターンの必要性

C#のコンストラクタはasyncにできません。

public class DatabaseConnection
{
    public DatabaseConnection()
    {
        // ❌ コンストラクタでawaitは使えない
        await ConnectAsync();
    }
    
    private async Task ConnectAsync()
    {
        // DB接続処理(非同期I/O)
        await Task.Delay(1000);
    }
}

なので、初期化時に非同期I/Oを伴う必要があるクラスでは非同期メソッドを使ったFactoryパターンが使われます。

public class DatabaseConnection
{
    private string _connectionString;
    
    // コンストラクタはprivate
    private DatabaseConnection()
    {
    }
    
    // 非同期のFactoryメソッド
    public static async Task<DatabaseConnection> CreateAsync(string server, int port)
    {
        var connection = new DatabaseConnection();
        
        // 非同期で初期化処理
        await connection.InitializeAsync(server, port);
        
        return connection;
    }
    
    private async Task InitializeAsync(string server, int port)
    {
        Console.WriteLine("DB接続中...");
        
        // 実際のDB接続(非同期I/O)
        await Task.Delay(1000); // DB接続をシミュレート
        
        _connectionString = $"Server={server};Port={port}";
        Console.WriteLine("DB接続完了");
    }
    
    public void ExecuteQuery(string query)
    {
        Console.WriteLine($"クエリ実行: {query}");
    }
}

// 使い方
static async Task Main()
{
    // Factoryメソッドで作成(非同期初期化される)
    var db = await DatabaseConnection.CreateAsync("localhost", 5432);
    
    db.ExecuteQuery("SELECT * FROM users");
}

ASP.NET CoreのDIコンテナでFactoryパターンを使う際の問題

DIコンテナ(Microsoft.Extensions.DependencyInjection)は基本的に同期初期化しかサポートしていません。なので、非同期Factoryでインスタンスを初期化することはできません。
例えば

public class DatabaseConnection
{
    private DatabaseConnection()
    {
    }
    
    public static async Task<DatabaseConnection> CreateAsync(string connectionString)
    {
        var connection = new DatabaseConnection();
        await connection.InitializeAsync(connectionString);
        return connection;
    }
    
    private async Task InitializeAsync(string connectionString)
    {
        // DB接続(非同期I/O)
        await Task.Delay(1000);
    }
}

// Program.cs
var builder = WebApplication.CreateBuilder(args);

// ❌ これはできない!
// DIコンテナの登録は同期的
builder.Services.AddSingleton<DatabaseConnection>(provider =>
{
    // await が使えない!
    return await DatabaseConnection.CreateAsync("connection-string");
});

は構文的にも実行的にもアウトです。

解決策はいくつかあります。

解決策1:同期的なFactoryクラスをDIに登録

FactoryクラスをシングルトンとしてDIコンテナに登録しても、依存クラスで毎回時間のかかるI/O処理を伴うCreateAsyncを実行しなければならない。

// Factoryインターフェース
public interface IDatabaseConnectionFactory
{
    Task<DatabaseConnection> CreateAsync();
}

public class DatabaseConnectionFactory : IDatabaseConnectionFactory
{
    private readonly string _connectionString;
    
    public DatabaseConnectionFactory(IConfiguration configuration)
    {
        _connectionString = configuration.GetConnectionString("Default");
    }
    
    public Task<DatabaseConnection> CreateAsync()
    {
        return DatabaseConnection.CreateAsync(_connectionString);
    }
}

// DI登録
builder.Services.AddSingleton<IDatabaseConnectionFactory, DatabaseConnectionFactory>();

// 使用例
public class MyController : ControllerBase
{
    private readonly IDatabaseConnectionFactory _factory;
    
    public MyController(IDatabaseConnectionFactory factory)
    {
        _factory = factory;
    }
    
    [HttpGet]
    public async Task<IActionResult> Get()
    {
        var connection = await _factory.CreateAsync(); // 毎回初期化処理が走る
        return Ok(connection.ConnectionString);
    }
}

解決策2:アプリ起動時に初期化してシングルトン登録

Program.csに初期化処理を書く必要があるのでスマートさにかけてしまう。

// Program.cs
var builder = WebApplication.CreateBuilder(args);

// アプリ起動時に初期化
var connectionString = builder.Configuration.GetConnectionString("Default");
var dbConnection = await DatabaseConnection.CreateAsync(connectionString);

// 初期化済みインスタンスを登録
builder.Services.AddSingleton(dbConnection);

var app = builder.Build();
app.Run();

// 使用例
public class MyController : ControllerBase
{
    private readonly DatabaseConnection _connection;
    
    public MyController(DatabaseConnection connection)
    {
        _connection = connection; // 初期化済み
    }
    
    [HttpGet]
    public IActionResult Get()
    {
        return Ok(_connection.ConnectionString);
    }
}

解決策3:AsyncLazyで初回アクセス時に初期化

いきなりAsyncLazyを出してしまいますが後述します。

// AsyncLazy実装
public class AsyncLazy<T>
{
    private readonly Lazy<Task<T>> _lazy;
    
    public AsyncLazy(Func<Task<T>> factory)
    {
        _lazy = new Lazy<Task<T>>(() => Task.Run(factory));
    }
    
    public Task<T> Value => _lazy.Value;
}

// ラッパークラス
public class LazyDatabaseConnection
{
    private readonly AsyncLazy<DatabaseConnection> _lazyConnection;
    
    public LazyDatabaseConnection(IConfiguration configuration)
    {
        var connectionString = configuration.GetConnectionString("Default");
        _lazyConnection = new AsyncLazy<DatabaseConnection>(() =>
            DatabaseConnection.CreateAsync(connectionString)
        );
    }
    
    public Task<DatabaseConnection> GetConnectionAsync() => _lazyConnection.Value;
}

// DI登録
builder.Services.AddSingleton<LazyDatabaseConnection>();

// 使用例
public class MyController : ControllerBase
{
    private readonly LazyDatabaseConnection _lazyConnection;
    
    public MyController(LazyDatabaseConnection lazyConnection)
    {
        _lazyConnection = lazyConnection;
    }
    
    [HttpGet]
    public async Task<IActionResult> Get()
    {
        var connection = await _lazyConnection.GetConnectionAsync(); // 初回のみ初期化
        return Ok(connection.ConnectionString);
    }
}

解決策4:IHostedServiceで起動時に初期化

Factoryパターンではなくなるが、IHostedServiceを使う方法もある。

// 初期化サービス
public class DatabaseInitializationService : IHostedService
{
    private readonly DatabaseConnection _connection;
    private readonly IConfiguration _configuration;
    
    public DatabaseInitializationService(DatabaseConnection connection, IConfiguration configuration)
    {
        _connection = connection;
        _configuration = configuration;
    }
    
    public async Task StartAsync(CancellationToken cancellationToken)
    {
        var connectionString = _configuration.GetConnectionString("Default");
        await _connection.InitializeAsync(connectionString);
    }
    
    public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

// DI登録
builder.Services.AddSingleton<DatabaseConnection>();
builder.Services.AddHostedService<DatabaseInitializationService>();

// 使用例
public class MyController : ControllerBase
{
    private readonly DatabaseConnection _connection;
    
    public MyController(DatabaseConnection connection)
    {
        _connection = connection; // 起動時に初期化済み
    }
    
    [HttpGet]
    public IActionResult Get()
    {
        return Ok(_connection.ConnectionString);
    }
}

LazyとAsyncLazyとは??

  1. Lazy<T>:同期の遅延初期化(一度だけ)
    Lazy<T> は、T を返す任意の関数(Func<T>)を渡して初期化することができ、値ファクトリを実行することでFunc<T>が実行されインスタンスを取得することができる。

    // T を返すファクトリ(Func<T>)を渡す
    var lazyFoo = new Lazy<Foo>(() => new Foo(1));
    
    // 初回アクセス時にだけファクトリが実行される
    Foo foo = lazyFoo.Value;
    
  2. Lazy<Task<T>>:非同期の“タスク”を遅延初期化
    Lazy<Task<T>> は、Task<T>> を返す任意の関数(Func<Task<T>>>)を渡して初期化することができ、値ファクトリを実行することでFunc<T>が実行されインスタンスを取得することができる。

    // Task<T> を返すファクトリ(Func<Task<T>>)を渡す
    var lazyFooTask = new Lazy<Task<Foo>>(() => Foo.CreateAsync(2));
    
    // 必要なときに await(初回だけ起動・以後は同じ Task を共有)
    Foo foo2 = await lazyFooTask.Value;
    
  3. AsyncLazy<T>:使いやすい薄いラッパー
    AsyncLazyはLazyをラップしたものです。
    Task<T>を返す関数を指定しているので値ファクトリの実行時にawaitを付けることになる。
    つまり、非同期処理に対応できるようになる。

    public sealed class AsyncLazy<T>
    {
        private readonly Lazy<Task<T>> _lazy;
        public AsyncLazy(Func<Task<T>> factory,
            LazyThreadSafetyMode mode = LazyThreadSafetyMode.ExecutionAndPublication)
            => _lazy = new Lazy<Task<T>>(factory, mode);
    
        public Task<T> Value => _lazy.Value;
    }
    
    // 使い方(非同期ファクトリを渡す → 使うときだけ await)
    var asyncLazyFoo = new AsyncLazy<Foo>(() => Foo.CreateAsync(3));
    Foo foo3 = await asyncLazyFoo.Value;
    

Discussion