DDD駆動でEF Core を導入した備忘録

投稿者: | 2021年2月28日

 業務でEntity Framework Coreを導入しました。ドメイン駆動で設計したので、その備忘録。

導入したライブラリ

データベースの環境を構築するには、下記3つのライブラリが必要です。

Microsoft.EntityFrameworkCore.Tools

 このライブラリをプロジェクトに追加すると以下のマイグレーションに関するライブラリ一式が導入されます。
・Microsoft.EntityFrameworkCore・・・・・・・本体
・Microsoft.EntityFrameworkCore.Design・・・・データの注釈によるマッピング定義
・Microsoft.EntityFrameworkCore.Relational・・・Fluent APIによるマッピング定義
.etc

Microsoft.EntityFrameworkCore.Sqlite

 MySqlなど他のデータベース用のライブラリがありますが、今回はSqliteを選択

Microsoft.EntityFrameworkCore.InMemory

 仮想データベース。Sqliteだとエンティティの構成を変更するたびにマイグレーションの準備をする必要がでて開発効率が落ちます。開発初期段階では、仮想データベースで開発を進めて、StarwayパターンでデータベースをSqliteとInMemoryを切り替える設計にしておきます。

クラスの作成

ここからコードの説明

Entityデータクラスの作成

データベースのカラム列に該当するデータクラスを作成します。

    public class TaskEntity
    {
        public int Id { get; protected set; }

        public string Title { get; protected set; }

        public string Description { get; protected set; }

        public TaskEntity(string title, string description)
        {
            Title = title ?? throw new ArgumentNullException(nameof(title));
            Description = description ?? throw new ArgumentNullException(nameof(description));
        }
    }

IEntityTypeConfigurationの実装クラス

エンティティの構成をFluentApiで作成します。後述のDbContextのOnModelCreatingメソッド内にてインラインで記述する例が多いですが、こうすると分解することが出来ます。

    public class TaskItemMap : IEntityTypeConfiguration<TaskItem>
    {
        public void Configure(EntityTypeBuilder<TaskItem> builder)
        {
            builder.ToTable("tasks");
            builder.HasKey(p => p.Id);
            builder.Property(p => p.Id).HasColumnName("id").ValueGeneratedOnAdd();
            builder.Property(p => p.UpdateData).HasColumnName("updateData").HasColumnType(nameof(SqlDbType.DateTime2));
            builder.Property(p => p.Title).HasColumnName("title");
            builder.Property(p => p.Description).HasColumnName("description");
        }
    }

DbSetプロパティのインターフェイス

IEntityTypeConfigurationで分解できるようにしておいたので、あとでインターフェイスを作って

public interface ITaskItemDbSet
{
    public DbSet<TaskItem> TaskItems { get; }
}

StoragePathクラスの作成

このクラスはデータベースのファイルパスを指定しています。インラインで記述しているサンプルコードが多いですが、この設計だとテストコードを実行する際に同じデータベースにアクセスしてしまうので、テストコードの実行順番によって、結果が変わって厄介な事になりました。変更できるようにおきます。

    public interface IStoragePath
    {
        string DatabaseFilePath { get; }
    }

    public class StoragePath : IStoragePath
    {
        // 適切なパスに変更してください。変更しないと、スタートアッププロジェクトにdatabse.dbファイルが生成されてしまいます。
        public string DatabaseFilePath => "database.db";
    }

DbContextの作成

データベースからクエリを実行するクラスを作成します。基底クラス、Sqlite実装クラス、InMemory実装クラスの3つ作成します。

    public class TaskNoteDbContext : DbContext, ITaskItemDbSet
    {
        public DbSet<TaskItem> TaskItems { get; }

        public TaskNoteDbContext(DbContextOptions options) : base(options)
        {
        }

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            base.OnConfiguring(optionsBuilder);

            // ここでロガーなどのオプションを設定する。
        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.ApplyConfiguration(new TaskItemMap());

            base.OnModelCreating(modelBuilder);
        }
    }
    public class TaskNoteSqliteContext : TaskNoteDbContext
    {
        private readonly IStoragePath _storagePath;

        public TaskNoteSqliteContext(IStoragePath storagePath, DbContextOptions options) : base(options)
        {
            _storagePath = storagePath ?? throw new System.ArgumentNullException(nameof(storagePath));
        }

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            optionsBuilder.UseSqlite($"Data Source={_storagePath.DatabaseFilePath}");
            base.OnConfiguring(optionsBuilder);
        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            base.OnModelCreating(modelBuilder);
        }
    }
    public class TaskNoteInMemoryContext : TaskNoteDbContext
    {
        private readonly IStoragePath _storagePath;

        public TaskNoteInMemoryContext(IStoragePath storagePath, DbContextOptions options) : base(options)
        {
            _storagePath = storagePath ?? throw new ArgumentNullException(nameof(storagePath));
        }

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            optionsBuilder.UseInMemoryDatabase(_storagePath.DatabaseFilePath);
            base.OnConfiguring(optionsBuilder);
        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            base.OnModelCreating(modelBuilder);
        }
    }

DatabaseExceptionクラス

基底のExceptionとドメイン層でのエラーであることを示す独自Exceptionを作成します。後述のSessionクラスで発生したエラーはこの独自Exceptionを返すようにして、下層のクラスがドメイン層で発生したエラーであることを知れるようにします。

    [Serializable()]
    public class TaskNoteException : SystemException
    {

        public TaskNoteException()
        {
        }

        public TaskNoteException(string message) : base(message)
        {
        }

        public TaskNoteException(string message, Exception innerException) : base(message, innerException)
        {
        }

        protected TaskNoteException(SerializationInfo info, StreamingContext context) : base(info, context)
        {
        }

    }
    [Serializable()]
    public class DatabaseException : TaskNoteException
    {
        public DatabaseException()
        {
        }

        public DatabaseException(string message) : base(message)
        {
        }

        public DatabaseException(string message, Exception innerException) : base(message, innerException)
        {
        }

        protected DatabaseException(SerializationInfo info, StreamingContext context) : base(info, context)
        {
        }
    }

TaskItemSession

そのままDbContextの継承クラスをコンストラクタに渡しているサンプルコードが多いですが、これだとTaskテーブル以外にもアクセスできる上に、その継承クラスでしか使えないコードになっていまいます。ジェネリック型制約を使って、制限と拡張性を作ります。

余談ですが、この役割のクラス名に「Dao」と名付ける人が多いですが、Dao(DataAccessObject)は意味が広義するぎるので、Sessionという名前を付けています。

    public interface ITaskItemSession
    {
        ValueTask<TaskItem> GetById(int id);

        ValueTask<bool> AllDelete();
    }
    public class TaskItemSession<TDbContext> : ITaskItemSession where TDbContext : DbContext, ITaskItemDbSet
    {
        private readonly IDbContextFactory<TDbContext> _dbFactory;

        public TaskItemSession(IDbContextFactory<TDbContext> dbFactory)
        {
            _dbFactory = dbFactory ?? throw new ArgumentNullException(nameof(dbFactory));
        }

        public async ValueTask<TaskItem> GetById(int id)
        {
            try
            {
                using var db = _dbFactory.CreateDbContext();
                return await db.TaskItems.FindAsync(id);
            }
            catch (Exception e)
            {
                throw new DatabaseException(e);
            }
        }

        public async ValueTask<bool> AllDelete()
        {
            try
            {
                using var db = _dbFactory.CreateDbContext();
                db.TaskItems.RemoveRange(db.TaskItems);
                return await db.SaveChangesAsync() > 0;
            }
            catch (Exception e)
            {
                throw new DatabaseException(e);
            }
        }
    }

IDesignTimeDbContextFactory実装クラス

このクラスはスタートアッププロジェクトで選択して実行できるプロジェクトに配置して下さい。

    public class DbContextFactory : IDesignTimeDbContextFactory<TaskNoteSqliteContext>
    {
        public TaskNoteSqliteContext CreateDbContext(string[] args)
        {
            var optionsBuilder = new DbContextOptionsBuilder<TaskNoteSqliteContext>();

            return new TaskNoteSqliteContext(new StoragePath(), optionsBuilder.Options);
        }
    }

DIの注入

DIの注入コード例
DebugビルドならInMemory、それ以外はSqliteのクラスを注入します。こうすれば、開発中にデータベースの構成が変わってもマイグレーションするためにAdd-Migrationコマンドを実行する必要はなくなります。(スゲー開発が楽)

    public interface IDbContext : IContainer
    {
    }

    public abstract class BaseDbContextContainer<TDbContext> : IDbContext where TDbContext : TaskNoteDbContext
    {
        public BaseDbContextContainer(IServiceCollection services)
        {
            services.AddSingleton<IMigrate, DatabaseMigrate<TDbContext>>();
            services.AddDbContext<TDbContext>();
            services.AddDbContextFactory<TDbContext>();
            services.AddSingleton<ITaskItemSession, TaskItemSession<TDbContext>>();
        }
    }

    public class DbSqliteContainer : BaseDbContextContainer<TaskNoteDbContext>
    {
        public DbSqliteContainer(IServiceCollection services) : base(services)
        {
        }
    }

    public class DbMemoryContainer : BaseDbContextContainer<TaskNoteDbContext>
    {
        public DbMemoryContainer(IServiceCollection services) : base(services)
        {
            services.AddSingleton<IMigrate, SpyMigrate>();
        }
    }
    public class RootContainer : IContainer
    {
        public ServiceCollection Services { get; } = new ServiceCollection();

        public RootContainer()
        {
            IDbContext db;
#if DEBUG
            db = new DbMemoryContainer(Services);
#else
            db = new DbSqliteContainer(Services);
#endif
            Services.AddSingleton(db);
        }
    }

マイグレーションの実行

 マイグレーションの実行。このあたりの説明は公式や他サイトで詳しく載っているの簡単にだけ説明。

パッケージマネージャーコンソールを開く

スタートアッププロジェクトはIDesignTimeDbContextFactoryの実装クラスが存在するプロジェクトを選択
規定のプロジェクトはDbContextクラスが存在するプロジェクトを選択
その状態でnugetマネージャーで下記のコマンドを実行します。

コマンドの実行

下記の3つのコマンドを打ちます。

Enbale-Migrations

 マイグレーションを有効化します。新しいバージョンだと常に有効化にする仕様になったため打つ必要はなくなったので「enable-migrations is obsolete. use add-migration to start using migrations」とメッセージが出る場合があります。

Add-Migration IntialCreate

 IntialCreateの部分は任意の文字に置き換えられます。プロジェクトのMigrationsフォルダが生成され、そこに自動生成されたファイルが[日時]_IntialCreate.csで保存されます。

Update-Database

 マイグレーションが実行されデータベースが更新されます。ちなみに、クライアントアプリの場合はDbContext.Database.Migrateメソッドで実行して下さい。

参考文献

Microsoft Doc 移行の概要

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)