EFCoreに深入り(DbContextをnewするあたり)

2/22追記、この記事の内容の応用としてPomelo.EFCoreのシャーディング対応をgistにあげてみました Pomelo.EFCoreでSharding · GitHub


この記事は、以下のコードを読んでみた結果です。 github.com 当記事内のコードはどれも簡略化したものなので本当の実装が気になる型は上記公式リポジトリのコードを確認してください。 またDbContextPoolまでは踏み込めていません。後日調べようと思っています。

DbContext継承クラスは、基本的にDbContextOptions<TDbContext>を受け取るコンストラクタが必要です。

public partial class MyDbContext : DbContext
{
    public MyDbContext (DbContextOptions<MyDbContext > options) : base(options)
    {
    }
}

これをDIに登録するときはStartup.csに以下のようなコードを書きます。例はPomelo.EntityFrameworkCoreのものですが、SqlServerでも似たようなものです。

public void ConfigureServices(IServiceCollection services)
{
    services.AddDbContext<MyDbContext>(b => b.UseMySql(
            Configuration.GetConnectionString("ConnectionString"),
            new MySqlServerVersion(new Version(8, 0))
        )
    );
}

ラムダの引数であるところのbDbContextOptionsBuilder<MyDbContext>になります。DbContextOptions<TDbContext>を直接弄るのではなく、Builderのほうを弄ることで、弄った内容がOptionsに反映される、という形になっています。

AddDbContext<TDbContext>拡張メソッドで登録されるDI設定は、デフォルトがServiceLifetime.Scopedです。つまりWebの場合は同一リクエスト内ならば同じオブジェクトが使いまわされます。AddDbContext<TDbContext>オーバーロードによってはServiceLifetime.Scopedを引数で指定できますが、ほとんどのケースでScopedのほうが望ましいでしょう。

また、DbContext以外にもDbContextOptionsのほうもDI登録されていて、同じようにServiceLifetime.Scopedの寿命を持ちます。MyDbContextのコンストラクタに入ってくるDbContextOptionsはDIが解決しているのです。

思うにDbContextOptionsBuilder<MyDbContext>DbContextOptions<MyDbContext>ジェネリックな型になっているのはDIの都合です。もしジェネリックでないDbContextOptionsを引数に取るとしたら、DbContext継承型が複数あった場合、つまりMyDbContext1とMyDbContext2で引数が同じオブジェクトになってしまいますから、分離するために型をつけているのです。このあたりはちょっとしょぼいというか強引というか。

さて、ServiceLifetime.ScopedなのでMyDbContextはリクエスト毎にnewされるわけですが、コンストラクタ引数のDbContextOptionsも同じ寿命であって、リクエスト毎に以下のようなコードが呼び出されることになります。(簡略化してあります)

private DbContextOptions<TDbContext> CreateOptions<TDbContext>(Action<DbContextOptionsBuilder<TDbContext>>? optionAction)
{
    var builder = new DbContextOptionsBuilder<TContext>();
    optionAction?.Invoke(builder); //UseMySqlとかUseSqlServerとかがこの中で呼ばれてConnectionStringをセットしたりする
    return builder.Options;
}
//DI登録
services.TryAdd(new ServiceDescriptor(typeof(TDbContext), lifetime);
services.TryAdd(new ServiceDescriptor(typeof(DbContextOptions<TDbContext>), sp => CreateOptions(optionAction),  lifetime);

そもそもDbContextOptionsとは何ぞやというと、複数のIDbContextOptionsExtensionを束ねたDictionaryです。

abstract class DbContextOptions
{
    IReadOnlyDictionary<Type, IDbContextOptionsExtension> _extensions;
}

IDbContextOptionsExtension自体にめぼしい機能はありませんが、これを実装した抽象クラスであるRelationalOptionsExtensionがConnectionStringを持っており、SqlServerOptionsExtensionMySqlOptionsExtensionの親クラスになっています。

public abstract class RelationalOptionsExtension : IDbContextOptionsExtension
{
    string _connectionString;
    //他にもTimeoutとかMigrationAssemblyとか設定いろいろ
}

public class SqlServerOptionsExtension : RelationalOptionsExtension 
{
}

public class MySqlOptionsExtension : RelationalOptionsExtension 
{
}

DbContextOptionに対してUseMySqlMySqlOptionsExtensionを突っ込むし、UseSqlServerSqlServerOptionsExtensionを突っ込みます。

public static DbContextOptionsBuilder UseMySql(this DbContextOptionsBuilder builder, string connectionString,  ServerVersion serverVersion, Action<MySqlDbContextOptionsBuilder> optionsAction)
{
    MySqlOptionsExtension extension = GetOrCreateExtension(builder)
        .WithServerVersion(serverVersion)
        .WithConnectionString(connectionString);

    builder.AddOrUpdate(extension);

    optionsAction?.Invoke(new MySqlDbContextOptionsBuilder(builder));

    return builder;
}

さて、これでMyDbContextがnewされるまでの大体の要素が出そろいました。

  1. DbContextOptionsBuilderをnewする
  2. UseMySqlUseSqlServer相当のコードで、IDbContextOptionsExtensionDbContextOptionsBuilder.Optionsに登録する。この過程でConnectionStringが決まる
  3. ActivatorUtilities.CreateInstance<MyDbContext>DbContextOptionsBuilder.Optionsを引数に渡してインスタンスを作る
  4. 上記1~3を行うファクトリをDI登録する

これで生成周りを乗っ取れそうです。RDS自前シャーディングなどでConnectionStringをKeyによって切り替えたい時などに使えるかもと思っています。