C# で、「ジェネリッククラスを継承し、型引数付コンストラクタを持つクラス」を作成する

C# ではジェネリックを使って、型を引数として渡すことができます。
ジェネリック - C# によるプログラミング入門 | ++C++; // 未確認飛行 C

そんな中、「ジェネリッククラスを継承し、型引数付コンストラクタを持つクラス」を作成する機会があったので、メモに残します。

なお、ジェネリックに関する公式ドキュメントはこちら。
ジェネリック - C# プログラミング ガイド | Microsoft Docs

 
目次

 

環境

  • macOS 10.13.6
  • .NET Core 3.1.0

また、サンプルコードには EntityFramework Core 3.1.0 を使っています。

 

サンプルコード

ジェネリッククラスを継承し、型引数付コンストラクタを持つクラスを作成する では書いている自分もよく分からないので、具体的な例を書いていきます。

 
EntityFramework Coreで発行されるSQLを確認する目的で、

  • エンティティクラス
  • DbContext
  • SQLを確認するために使うテストコード

を実装したものをサンプルとして使います。

 

エンティティクラス

BlogとUserの2つのエンティティクラスがあります。

それらの UrlName という文字列項目に対して、 Fluent APIを使ってNOT NULL制約の有無を制御します。

そのため、 #nullable enable を使って、null許容参照型(string?)として項目を定義します。
null 許容参照型 - C# によるプログラミング入門 | ++C++; // 未確認飛行 C

なお、2つのエンティティクラス間にはリレーションがないものとします。

namespace MyApp.GenericInheritance
{
#nullable enable
    public class Blog
    {
        public int Id { get; set; }
        public string? Url { get; set; }
    }

    public class User
    {
        public int Id { get; set; }
        public string? Name { get; set; }
    }
#nullable restore
}

 

DbContext

事情により、Blog/Userはそれぞれ別のDbContextとして用意しました。

 

Blog向けのDbContext

BlogのDbSetの他、OnModelCreating()にてFluent APIを使って NOT NULL制約を制御しています。

また、MyLoggerFactoryやOnConfiguringでは、SQLの実行ログをコンソール出力するようにしています。

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

namespace MyApp.GenericInheritance
{
    public class OriginalBlogContext : DbContext
    {
        protected bool _isRequired;
        
        public OriginalBlogContext(DbContextOptions<OriginalBlogContext> options, bool isRequired)
            : base(options)
        {
            _isRequired = isRequired;
        }

        public static readonly ILoggerFactory MyLoggerFactory
            = LoggerFactory.Create(builder =>
            {
                builder
                    .AddFilter(DbLoggerCategory.Database.Command.Name, LogLevel.Information)
                    .AddConsole();
            });
        
        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            base.OnConfiguring(optionsBuilder);
            optionsBuilder
                .EnableSensitiveDataLogging()
                .UseLoggerFactory(MyLoggerFactory);
        }
        
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Blog>()
                .Property(blog => blog.Url)
                .IsRequired(_isRequired);
        }
    }
}

 

User向けのDbContext

Blogとはログ出力の部分が重複する一方、DbSetとコンストラクタ・OnModelCreatingが異なります。

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

namespace MyApp.GenericInheritance
{
    public class OriginalUserContext : DbContext
    {
        protected bool _isRequired;
        
        public OriginalUserContext(DbContextOptions<OriginalUserContext> options, bool isRequired)
            : base(options)
        {
            _isRequired = isRequired;
        }

        public static readonly ILoggerFactory MyLoggerFactory
            = LoggerFactory.Create(builder =>
            {
                builder
                    .AddFilter(DbLoggerCategory.Database.Command.Name, LogLevel.Information)
                    .AddConsole();
            });
        
        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            base.OnConfiguring(optionsBuilder);
            optionsBuilder
                .EnableSensitiveDataLogging()
                .UseLoggerFactory(MyLoggerFactory);
        }
        
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<User>()
                .Property(user => user.Name )
                .IsRequired(_isRequired);
        }
    }
}

 

テストコード

テストコード中では、

  • インメモリなSQLiteを使用
  • context.Database.EnsureCreated(); を実行して、エンティティクラスの内容をDBに反映
  • その内容をDbContextでコンソール出力

を行い、コンソールでSQLの実行結果を確認します。

using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using MyApp.GenericInheritance;
using Xunit;

namespace MyApp.Test.GenericInheritanceTest
{
    public class OriginalTest
    {
        [InlineData("IsRequiredが true の場合", true)]
        // [InlineData("IsRequiredが false の場合", false)]
        [Theory]
        public void BlogのSQL確認(string title, bool isRequired)
        {
            var connection = new SqliteConnection("DataSource=:memory:");
            connection.Open();
            try
            {
                var options = new DbContextOptionsBuilder<OriginalBlogContext>()
                    .EnableSensitiveDataLogging()
                    .UseSqlite(connection)
                    .Options;
                using var context = new OriginalBlogContext(options, isRequired);

                context.Database.EnsureCreated();
            }
            finally
            {
                connection.Close();
            }
        }
        
        // [InlineData("IsRequiredが true の場合", true)]
        [InlineData("IsRequiredが false の場合", false)]
        [Theory]
        public void UserのSQL確認(string title, bool isRequired)
        {
            var connection = new SqliteConnection("DataSource=:memory:");
            connection.Open();
            try
            {
                var options = new DbContextOptionsBuilder<OriginalUserContext>()
                    .EnableSensitiveDataLogging()
                    .UseSqlite(connection)
                    .Options;
                using var context = new OriginalUserContext(options, isRequired);

                context.Database.EnsureCreated();
            }
            finally
            {
                connection.Close();
            }
        }
    }
}

 

実行結果

dotnet test を実行し、SQLを確認します。

なお、オプションとして以下を指定しています。

$ dotnet test --filter "FullyQualifiedName~MyApp.Test.GenericInheritanceTest.OriginalTest" --logger:"console;verbosity=detailed"
...
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "User" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_User" PRIMARY KEY AUTOINCREMENT,
          "Name" TEXT NULL
      );
...
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "Blog" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_Blog" PRIMARY KEY AUTOINCREMENT,
          "Url" TEXT NOT NULL
      );

 

実装していて気になったこと

Blog/UserのDbContextについて、

  • ログ出力の部分が重複
  • DbSetとコンストラクタ・OnModelCreatingは異なる

という状態です。

そんな中、今後もエンティティクラスごとのDbContextが増えそうな予感がする環境とします。

 

やりたいこと

各エンティティのDbContextについて、異なる部分だけ実装し、コードが見通しやすくしたいです。

一方、テストコードについても重複っぽい部分がありますが、ひとまず置いておきます。

 
そこで、

を行います。

これが、ジェネリッククラスを継承し、型引数付コンストラクタを持つクラスを作成する になります*1

 

作業の流れ

最終的な形だけ出すとどのように変形していったのか分かりづらいので、作業の流れに沿って実装を進めます。

なお、作業途中のものはコンパイルできないと思いますが、説明のために書いていきます。

 

ベースとなるクラスへ重複部分を抽出

重複している部分は

  • ログまわり
  • Fluent APIで動的に指定する、 IsRequired の値

ので、それらをベースクラスに抜き出します。

public class GenericInheritanceBaseContext
    : DbContext
{
    protected bool _isRequired;
    public GenericInheritanceBaseContext(DbContextOptions<OriginalBlogContext> options, bool isRequired) : base(options)
    {
        _isRequired = isRequired;
    }

    public static readonly ILoggerFactory MyLoggerFactory
        = LoggerFactory.Create(builder =>
        {
            builder
                .AddFilter(DbLoggerCategory.Database.Command.Name, LogLevel.Information)
                .AddConsole();
        });
    
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        base.OnConfiguring(optionsBuilder);
        optionsBuilder
            .EnableSensitiveDataLogging()
            .UseLoggerFactory(MyLoggerFactory);
    }
}

 

コンストラクタ引数の型をジェネリック化 & クラスに型引数を追加

ここでコンストラクタを見ると

public GenericInheritanceBaseContext(DbContextOptions<OriginalBlogContext> options, bool isRequired)

と、引数optionは DbContextOptions<OriginalBlogContext> 型の引数しか受け付けません。このままではUserのDbContextが渡せません。

 
そこで、引数optionの型をジェネリックにするとともに、クラスに型引数を定義します。

// クラスに型引数 "<T>" を追加
public class GenericInheritanceBaseContext<T>
    : DbContext
{
    // コンストラクタの型を "DbContextOptions<OriginalBlogContext>" から型引数 "T" へと変更
    public GenericInheritanceBaseContext(T options, bool isRequired) : base(options)
    {
        _isRequired = isRequired;
    }
}

 

クラスの型引数に対し、制約条件を付与

現在のクラスの型引数は

public class GenericInheritanceBaseContext<T>

となっており、base(options) の中で与えた型に対して操作ができません。

 
そこで、キーワード where を使用して、型引数に制約条件を付け加えます。

今回の型引数は DbContext およびその子クラスであれば良いです。

public class GenericInheritanceBaseContext<T>
    : DbContext
    where T : DbContextOptions  // 制約条件を追加

 
日本語で書くと

public class GenericInheritanceBaseContext<型引数>
    : 親クラス
    where 型引数 : 制約条件となるクラス等

と、 子クラス名、親クラス名、制約条件 の順で定義します。

 

ベースクラスの全体像

ここまででベースとなるクラスができました。全体像はこちら。

public class GenericInheritanceBaseContext<T>
    : DbContext
    where T : DbContextOptions
{
    protected bool _isRequired;
    public GenericInheritanceBaseContext(T options, bool isRequired) : base(options)
    {
        _isRequired = isRequired;
    }

    public static readonly ILoggerFactory MyLoggerFactory
        = LoggerFactory.Create(builder =>
        {
            builder
                .AddFilter(DbLoggerCategory.Database.Command.Name, LogLevel.Information)
                .AddConsole();
        });
    
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        base.OnConfiguring(optionsBuilder);
        optionsBuilder
            .EnableSensitiveDataLogging()
            .UseLoggerFactory(MyLoggerFactory);
    }
}

 

BlogのDbContextの現状

親クラスに移動した部分以外が残っていますので、その実装を書いておきます。

なお、今回は後で見返せるよう、今までの OriginalBlogContext は残しておき、別の GenericInheritanceBlogContext として作成しています。

public class GenericInheritanceBlogContext : DbContext
{
    public GenericInheritanceBlogContext(DbContextOptions<GenericInheritanceBlogContext> options, bool isRequired)
        : base(options)
    {
        _isRequired = isRequired;
    }

    public DbSet<Blog> Blogs { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Blog>()
            .Property(blog => blog.Url)
            .IsRequired(_isRequired);
    }
}

ここで、 OnModelCreating() は子クラスごとに固有なので残しておき、それ以外を修正します。

 

BlogのDbContextで、型引数付きの親クラスを継承するように修正

現在の親クラスは DbContext ですが、これをベースクラス GenericInheritanceBaseContext へと切り替えます。

また、ベースクラスを継承する際、型引数が必要です。今回使う型引数は、コンストラクタで使う DbContextOptions<OriginalBlogContext> です。

つまり

public class GenericInheritanceBlogContext : DbContext

public class GenericInheritanceBlogContext
    : GenericInheritanceBaseContext<DbContextOptions<GenericInheritanceBlogContext>>

へと、子クラス : 親クラス<型引数> な定義へと修正します。

 

BlogのDbContextで、コンストラクタの型を修正

ベースクラスのコンストラクタは

public GenericInheritanceBaseContext(T options, bool isRequired) : base(options)

と、型引数で指定したジェネリックを使っています。

そのため、子クラスであるBlogのDbContextでは明示的に型を指定します。

public GenericInheritanceBlogContext(
    DbContextOptions<GenericInheritanceBlogContext> options, bool isRequired)
    : base(options, isRequired)

 

BlogのDbContext全体像

ここまででBlogのDbContextの修正が終わりましたので、全体像を書いておきます。

using Microsoft.EntityFrameworkCore;

namespace MyApp.GenericInheritance
{
    public class GenericInheritanceBlogContext
        : GenericInheritanceBaseContext<DbContextOptions<GenericInheritanceBlogContext>>
    {
        public GenericInheritanceBlogContext(
            DbContextOptions<GenericInheritanceBlogContext> options, bool isRequired)
            : base(options, isRequired) {}

        public DbSet<Blog> Blogs { get; set; }
        
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Blog>()
                .Property(blog => blog.Url)
                .IsRequired(_isRequired);
        }
    }
}

 

UserのDbContext

こちらも後で見返せるよう、名前を GenericInheritanceUserContext にしたくらいで、あとはBlogと同じような感じです。

using Microsoft.EntityFrameworkCore;

namespace MyApp.GenericInheritance
{
    public class GenericInheritanceUserContext
        : GenericInheritanceBaseContext<DbContextOptions<GenericInheritanceUserContext>>
    {
        public GenericInheritanceUserContext(
            DbContextOptions<GenericInheritanceUserContext> options, bool isRequired)
            : base(options, isRequired) {}

        public DbSet<User> Users { get; set; }
        
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<User>()
                .Property(user => user.Name)
                .IsRequired(_isRequired);
        }
    }
}

 

テストコード

こちらも、使用するDbContext名が変わるだけで、あとは同じです。

public class GenericInheritanceTest
{
    [InlineData("IsRequiredが true の場合", true)]
    // [InlineData("IsRequiredが false の場合", false)]
    [Theory]
    public void BlogのSQL確認(string title, bool isRequired)
    {
        var connection = new SqliteConnection("DataSource=:memory:");
        connection.Open();
        try
        {
            var options = new DbContextOptionsBuilder<GenericInheritanceBlogContext>()
                .EnableSensitiveDataLogging()
                .UseSqlite(connection)
                .Options;
            using var context = new GenericInheritanceBlogContext(options, isRequired);

            context.Database.EnsureCreated();
        }
        finally
        {
            connection.Close();
        }
    }
    
    // [InlineData("IsRequiredが true の場合", true)]
    // [InlineData("IsRequiredが false の場合", false)]
    // [Theory]
    public void UserのSQL確認(string title, bool isRequired)
    {
        var connection = new SqliteConnection("DataSource=:memory:");
        connection.Open();
        try
        {
            var options = new DbContextOptionsBuilder<GenericInheritanceUserContext>()
                .EnableSensitiveDataLogging()
                .UseSqlite(connection)
                .Options;
            using var context = new GenericInheritanceUserContext(options, isRequired);

            context.Database.EnsureCreated();
        }
        finally
        {
            connection.Close();
        }
    }
}

 

実行結果の比較

同じ結果が得られ、ジェネリック化できました。

# 修正前
$ dotnet test --filter "FullyQualifiedName~MyApp.Test.GenericInheritanceTest.OriginalTest" --logger:"console;verbosity=detailed"
...
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "Blog" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_Blog" PRIMARY KEY AUTOINCREMENT,
          "Url" TEXT NOT NULL
      );
...
√ MyApp.Test.GenericInheritanceTest.OriginalTest.BlogのSQL確認(title: "IsRequiredが true の場合", isRequired: True) [854ms]


# ジェネリック版
dotnet test --filter "FullyQualifiedName~MyApp.Test.GenericInheritanceTest.GenericInheritanceTest" --logger:"console;verbosity=detailed"
...
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "Blogs" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_Blogs" PRIMARY KEY AUTOINCREMENT,
          "Url" TEXT NOT NULL
      );
...
√ MyApp.Test.GenericInheritanceTest.GenericInheritanceTest.BlogのSQL確認(title: "IsRequiredが true の場合", isRequired: True) [920ms]
...

 

ソースコード

Githubに上げました*2MyApp/GenericInheritanceMyApp.Test/GenericInheritanceTest あたりが今回のディレクトリです。
https://github.com/thinkAmi-sandbox/EntityFramework_Core_Sample

*1:より良いタイトルがあれば教えていただけるとありがたいです

*2:今回のサンプルはEntityFramework Core を使ってるため、リポジトリもEntityFramework Coreのものを使っています