Entity Framework Core - 楽観的同時実行制御のサンプル

EF Coreでrowversionデータ型を使った楽観的同時実行制御(排他制御)を試してみました。EF6と大体同じかなと思います。

Concurrency Tokens - EF Core | Microsoft Docs

EF Coreで楽観的同時実行制御するには、Data Annotationsで指定する方法とFluent APIで指定する方法があります。

まずはデータを用意しましょう。

-- テーブル作成
drop table if exists dbo.Monster;
create table dbo.Monster(
    Id int,
    Name nvarchar(20) not null,
    Version rowversion not null,  -- バージョン情報(トークンとも言ったり)
    constraint PK_Monster primary key(Id)
);

-- データ投入
insert into dbo.Monster(Id, Name)
values(1, N'スライム');

続いてモデル。Data Annotationsの場合は、rowversionのプロパティにTimestamp属性を指定します。

// モンスター
public class Monster {
    public int Id { get; set; }
    public string Name { get; set; }

    // Data Annotationsの場合
    [Timestamp]
    public byte[] Version { get; set; }
}

Fluent APIの場合は、Timestamp属性の代わりにDBコンテキストのOnModelCreatingメソッド内で次のように指定します。

// DBコンテキストの一部
public class AppDbContext : DbContext {
    protected override void OnModelCreating(ModelBuilder modelBuilder) {
        // テーブルのマッピング
        modelBuilder.Entity<Monster>().ToTable(nameof(Monster));

        // Fluent APIの場合
        // Timestamp属性の代わりにこちらでも
        modelBuilder.Entity<Monster>()
            .Property(monster => monster.Version)
            .ValueGeneratedOnAddOrUpdate()
            .IsConcurrencyToken();
    }
}

準備が整ったので楽観的同時実行制御によって更新に失敗することを試してみましょう。

class Program {
    static void Main(string[] args) {
        using (var dbContext = new AppDbContext()) {
            // ロガープロバイダーを設定
            var serviceProvider = dbContext.GetInfrastructure();
            var loggerFactory = serviceProvider.GetService<ILoggerFactory>();
            loggerFactory.AddProvider(new AppLoggerProvider());
        }

        // 同時に更新する1つ目のエンティティ
        var monster1 = default(Monster);
        using (var dbContext = new AppDbContext()) {
            monster1 = dbContext.Set<Monster>().Find(1);
        }

        // 同時に更新する2つ目のエンティティ
        // 楽観的同時実行制御による更新失敗を確認したいため、コピーを作っておく
        var monster2 = new Monster {
            Id = monster1.Id,
            Name = monster1.Name,
            Version = monster1.Version.Clone() as byte[],
        };

        // 1つ目のエンティティを更新 => 成功する
        using (var dbContext = new AppDbContext(tracking: true)) {
            monster1.Name = "スライムベス";
            dbContext.Set<Monster>().Attach(monster1).State = EntityState.Modified;

            dbContext.SaveChanges();

            // 実行されるSQL
            // WHERE句にVersionカラムが含まれている
            /*
           Executed DbCommand (0ms) [Parameters=[@p1='?', @p0='?' (Size = 4000), @p2='?' (Size = 8)], CommandType='Text', CommandTimeout='30']
           SET NOCOUNT ON;
           UPDATE [Monster] SET [Name] = @p0
           WHERE [Id] = @p1 AND [Version] = @p2;
           SELECT [Version]
           FROM [Monster]
           WHERE @@ROWCOUNT = 1 AND [Id] = @p1;
           */
        }

        // 2つ目のエンティティを更新 => 失敗する(上記SaveChangesでVersionカラムが更新されているため)
        using (var dbContext = new AppDbContext(tracking: true)) {
            monster2.Name = "バブルスライム";
            dbContext.Set<Monster>().Attach(monster2).State = EntityState.Modified;

            try {
                // SaveChangesを呼び出すと例外が発生する
                dbContext.SaveChanges();
            } catch (DbUpdateConcurrencyException exception) {
                Console.WriteLine(exception.Message);
                // 例外メッセージ
                /*
               Database operation expected to affect 1 row(s) but actually affected 0 row(s).
               Data may have been modified or deleted since entities were loaded.
               See http://go.microsoft.com/fwlink/?LinkId=527962 for information on understanding and handling optimistic concurrency exceptions.
               */
            }
        }
    }
}

まとめ

rowversionとTimestamp属性(またはFluent API)を使うと、

  • SaveChangesメソッドで実行されるUPDATE文のWHERE句にVersionカラムが含まれる
  • すでにVersionカラムが更新されていた場合、SaveChangesメソッドを呼び出すと更新に失敗してDbUpdateConcurrencyExceptionがスローされる

ということを確認できました。

コードとクエリ全体はこちらに。

EF Coreで楽観的同時実行制御 · GitHub

Entity Framework Core - Owned typesのサンプル

EF Coreの「Owned types」を試してみました。日本語訳はどうなるんでしょう。所有型?かな?

What is new in EF Core 2.0 - EF Core | Microsoft Docs

「Owned types」はEF6の複合型に似た機能のようで、テーブルにある複数カラムをプロパティクラスにマッピングするものです。

ということで早速。次のテーブルがあるとします。

-- テーブル作成
drop table if exists dbo.Character;
create table dbo.Character(
    Id int,
    Name nvarchar(4) not null,   -- 名前
    Level int not null,   -- レベル
    Hp int not null,  -- HP
    Mp int not null,  -- MP
    constraint PK_Character primary key(Id)
);

次のモデルにマッピングしてみましょう。HpとMpのカラムは再利用したいという体でStatusクラスとしています。

// ステータス
public class Status {
    public int Hp { get; set; }
    public int Mp { get; set; }
}

// キャラクター
public class Character {
    public int Id { get; set; }
    public string Name { get; set; }
    public int Level { get; set; }
    // HPとMPを持つステータス
    public Status Status { get; set; }
}

マッピングするにはDBコンテキストのOnModelCreatingメソッド内でOwnsOneメソッドを使ってStatusプロパティを指定します。

OwnsOneメソッドの2つ目の引数では入れ子になったStatusクラスのマッピングも指定できます。マッピングを指定しないとStatusクラスの各プロパティは「Status_Hp」「Status_Mp」といったカラム名マッピングされるので、下記コードでは実際のカラム名に合うように調整しました。

// DBコンテキストの一部
public class AppDbContext : DbContext {
    protected override void OnModelCreating(ModelBuilder modelBuilder) {
        // Characterテーブルへのマッピング
        modelBuilder.Entity<Character>().ToTable(nameof(Character));

        // CharacterがStatusを所有する
        modelBuilder.Entity<Character>().OwnsOne(
            character => character.Status,
            statusBuilder => {
                // StatusをHpカラムとMpカラムへマッピング
                // (デフォルトだと「Status_Hp」「「Status_Mp」」といったカラムにマッピングされる)
                statusBuilder.Property(status => status.Hp).HasColumnName(nameof(Status.Hp));
                statusBuilder.Property(status => status.Mp).HasColumnName(nameof(Status.Mp));
            });
    }
}

これで準備が整ったので、うまくマッピングできているか確認してみましょう。

class Program {
    // 結果出力用
    private static void Dump(IEnumerable<Character> characters) {
        foreach (var character in characters) {
            Console.WriteLine($"{character.Name}");
            Console.WriteLine($"Lv {character.Level}");
            Console.WriteLine($"HP {character.Status.Hp}");
            Console.WriteLine($"MP {character.Status.Mp}");
            Console.WriteLine();
        }
    }

    static void Main(string[] args) {
        // データ投入
        using (var dbContext = new AppDbContext(tracking: true)) {
            dbContext.Set<Character>().AddRange(
                new Character {
                    Id = 1,
                    Name = "エイト",
                    Level = 10,
                    Status = new Status { Hp = 59, Mp = 32 },
                },
                new Character {
                    Id = 2,
                    Name = "ゼシカ",
                    Level = 10,
                    Status = new Status { Hp = 47, Mp = 28 },
                });

            dbContext.SaveChanges();
        }

        // 確認
        using (var dbContext = new AppDbContext()) {
            var characters = dbContext.Set<Character>().ToList();
            Dump(characters);
            /*
           エイト
           Lv 10
           HP 59
           MP 32
           ゼシカ
           Lv 10
           HP 47
           MP 28
           */
        }

        // データ更新
        using (var dbContext = new AppDbContext(tracking: true)) {
            // エイトを取得
            var character = dbContext.Set<Character>().Find(1);

            // エイトはレベルがあがった!
            character.Level += 1;
            character.Status.Hp += 12;
            character.Status.Mp += 2;

            dbContext.SaveChanges();
        }

        // 確認
        using (var dbContext = new AppDbContext()) {
            var characters = dbContext.Set<Character>().ToList();
            Dump(characters);
            /*
           エイト
           Lv 11
           HP 71
           MP 34
           ゼシカ
           Lv 10
           HP 47
           MP 28
           */
        }
    }
}

コードとクエリはこちら。

EF CoreのOwned typesのサンプル · GitHub

Entity Framework Core - IncludeとThenIncludeを試す

IncludeメソッドとThenIncludeメソッドは、あわせて読み込む関連エンティティを指定するために使います。Eager loadingと呼ばれている機能ですね。

Loading Related Data - EF Core | Microsoft Docs

このIncludeメソッドとThenIncludeメソッドを使うとどんなSQLが実行されるのか気になったので試してみました。実行されたSQLのログをペタペタとはっていきたいと思います。

コード全体やクエリはこちらに。

EF CoreのIncludeとThenInclude · GitHub

モデルとかデータとか

まず次のようなモデルを用意してみました。1対多の関係が1つ、多対多の関係が2つあります。1対多の関係が全部で5つと言ったほうがいいのか。

// MonsterCategory - Monster : 1対多
// Monster - MonsterArea - Area : 多対多
// Monster - MonsterItem - Item : 多対多
// 地域
public class Area {
    public int Id { get; set; }
    public string Name { get; set; }
}

// アイテム
public class Item {
    public int Id { get; set; }
    public string Name { get; set; }
}

// モンスターカテゴリ
public class MonsterCategory {
    public int Id { get; set; }
    public string Name { get; set; }
    public List<Monster> Monsters { get; set; }
}

// モンスター
public class Monster {
    public int Id { get; set; }
    public string Name { get; set; }
    public int CategoryId { get; set; }
    public MonsterCategory Category { get; set; }
    public List<MonsterArea> Areas { get; set; }
    public List<MonsterItem> Items { get; set; }
}

// モンスター生息地
public class MonsterArea {
    public int MonsterId { get; set; }
    public int No { get; set; }
    public int AreaId { get; set; }
    public Monster Monster { get; set; }
    public Area Area { get; set; }
}

// モンスター保持アイテム
public class MonsterItem {
    public int MonsterId { get; set; }
    public int No { get; set; }
    public int ItemId { get; set; }
    public Monster Monster { get; set; }
    public Item Item { get; set; }
}

次にロガーとDBコンテキストを用意します。

// ロガープロバイダー
public class AppLoggerProvider : ILoggerProvider {
    public ILogger CreateLogger(string categoryName) {
        if (string.Equals(categoryName, DbLoggerCategory.Database.Command.Name)) {
            return new ConsoleLogger();
        }

        return NullLogger.Instance;
    }

    public void Dispose() {
    }

    // ロガー
    private class ConsoleLogger : ILogger {
        public IDisposable BeginScope<TState>(TState state) => null;

        // 情報レベル以上のログを有効にする
        public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Information;

        public void Log<TState>(
            LogLevel logLevel, EventId eventId,
            TState state, Exception exception,
            Func<TState, Exception, string> formatter) {
            Console.WriteLine(formatter(state, exception));
            Console.WriteLine("---");
        }
    }
}

// DBコンテキスト
public class AppDbContext : DbContext {
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) {
        var connectionString = new SqlConnectionStringBuilder {
            DataSource = ".",
            InitialCatalog = "Test",
            IntegratedSecurity = true,
        }.ToString();

        optionsBuilder.UseSqlServer(connectionString);
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder) {
        modelBuilder.Entity<Area>().ToTable(nameof(Area));
        modelBuilder.Entity<Item>().ToTable(nameof(Item));
        modelBuilder.Entity<MonsterCategory>().ToTable(nameof(MonsterCategory));
        modelBuilder.Entity<Monster>().ToTable(nameof(Monster));
        modelBuilder.Entity<MonsterArea>().ToTable(nameof(MonsterArea))
            // 複合主キーのマッピング
            .HasKey(area => new { area.MonsterId, area.No });
        modelBuilder.Entity<MonsterItem>().ToTable(nameof(MonsterItem))
            .HasKey(item => new { item.MonsterId, item.No });
    }
}

データを投入しておきましょう。

// 地域
dbContext.Set<Area>().AddRange(
    new Area { Id = 1, Name = "サンタローズ" },
    new Area { Id = 2, Name = "レヌール" },
    new Area { Id = 3, Name = "ラインハット" });

// アイテム
dbContext.Set<Item>().AddRange(
    new Item { Id = 1, Name = "やくそう" },
    new Item { Id = 2, Name = "どくけしそう" });

// モンスターカテゴリ
dbContext.Set<MonsterCategory>().AddRange(
    new MonsterCategory { Id = 1, Name = "スライム" },
    new MonsterCategory { Id = 2, Name = "悪魔" });

// モンスター(とモンスター生息地・モンスター保持アイテム)
dbContext.Set<Monster>().AddRange(
    new Monster {
        Id = 1,
        Name = "スライム",
        CategoryId = 1,    // スライム
        Areas = new List<MonsterArea> {
            new MonsterArea { No = 1, AreaId = 1 }, // サンタローズ
        },
        Items = new List<MonsterItem> {
            new MonsterItem { No = 1, ItemId = 1 }, // やくそう
        },
    },
    new Monster {
        Id = 2,
        Name = "ドラキー",
        CategoryId = 2,    // 悪魔
        Areas = new List<MonsterArea> {
            new MonsterArea { No = 1, AreaId = 1 }, // サンタローズ
            new MonsterArea { No = 2, AreaId = 2 }, // レヌール
        },
    },
    new Monster {
        Id = 3,
        Name = "バブルスライム",
        CategoryId = 1,    // スライム
        Areas = new List<MonsterArea> {
            new MonsterArea { No = 1, AreaId = 3 }, // ラインハット
        },
        Items = new List<MonsterItem> {
            new MonsterItem { No = 1, ItemId = 1 }, // やくそう
            new MonsterItem { No = 2, ItemId = 2 }, // どくけしそう
        },
    });

dbContext.SaveChanges();

Includeメソッドで1対多の1を読み込む

まずはIncludeメソッドを使って1対多の多から1を読み込んでみます。

// モンスターカテゴリを含めてモンスターを読み込む
var monsters = dbContext.Set<Monster>()
    .Include(monster => monster.Category)
    .ToList();
foreach (var monster in monsters) {
    Console.WriteLine($"#{monster.Id} {monster.Name} [{monster.Category.Name}]");
}
/*
#1 スライム [スライム]
#2 ドラキー [悪魔]
#3 バブルスライム [スライム]
*/

ログを確認してみます。実行されるSQLはINNER JOINされてます。

Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [monster].[Id], [monster].[CategoryId], [monster].[Name], [monster.Category].[Id], [monster.Category].[Name]
FROM [Monster] AS [monster]
INNER JOIN [MonsterCategory] AS [monster.Category] ON [monster].[CategoryId] = [monster.Category].[Id]
---

Includeメソッドで1対多の多を読み込む

今度はIncludeメソッドを使って1対多の1から多を読み込んでみます。

var monsters = dbContext.Set<Monster>()
    .Include(monster => monster.Areas)
    .Include(monster => monster.Items)
    .ToList();
foreach (var monster in monsters) {
    var areas = string.Join(", ", monster.Areas.Select(area => area.AreaId));
    var items = string.Join(", ", monster.Items.Select(item => item.ItemId));
    Console.WriteLine($"#{monster.Id} {monster.Name} in {areas} has {items}");
}
/*
#1 スライム in 1 has 1
#2 ドラキー in 1, 2 has
#3 バブルスライム in 3 has 1, 2
*/

ログを確認すると、SELECT文が3つ実行されていることがわかります。どうもIncludeするごとにSELECT文が増える様子。こんなもの?

Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [monster].[Id], [monster].[CategoryId], [monster].[Name]
FROM [Monster] AS [monster]
ORDER BY [monster].[Id]
---
Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [monster.Areas].[MonsterId], [monster.Areas].[No], [monster.Areas].[AreaId]
FROM [MonsterArea] AS [monster.Areas]
INNER JOIN (
    SELECT [monster0].[Id]
    FROM [Monster] AS [monster0]
) AS [t] ON [monster.Areas].[MonsterId] = [t].[Id]
ORDER BY [t].[Id]
---
Executed DbCommand (2ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [monster.Items].[MonsterId], [monster.Items].[No], [monster.Items].[ItemId]
FROM [MonsterItem] AS [monster.Items]
INNER JOIN (
    SELECT [monster1].[Id]
    FROM [Monster] AS [monster1]
) AS [t0] ON [monster.Items].[MonsterId] = [t0].[Id]
ORDER BY [t0].[Id]
---

Includeメソッドで多を読み込み、さらにThenIncludeメソッドで1を読み込む

ThenIncludeメソッドを使うと、Includeメソッドで指定した関連エンティティのさらに関連エンティティを読み込むことができます。

前述のサンプルにさらにThenIncludeメソッドを加えると、多対多のエンティティを一度に取得できます。確認してみましょう。

var monsters = dbContext.Set<Monster>()
    .Include(monster => monster.Areas)
        .ThenInclude(monsterArea => monsterArea.Area)
    .Include(monster => monster.Items)
        .ThenInclude(monsterItem => monsterItem.Item)
    .ToList();
foreach (var monster in monsters) {
    var areas = string.Join(", ", monster.Areas.Select(area => area.Area.Name));
    var items = string.Join(", ", monster.Items.Select(item => item.Item.Name));
    Console.WriteLine($"#{monster.Id} {monster.Name} in {areas} has {items}");
}
/*
#1 スライム in サンタローズ has やくそう
#2 ドラキー in サンタローズ, レヌール has
#3 バブルスライム in ラインハット has やくそう, どくけしそう
*/

多対1の多から1をThenIncludeしているので、SQLも前述のサンプルにThenIncludeした分のINNER JOINが加わった感じです。

Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [monster].[Id], [monster].[CategoryId], [monster].[Name]
FROM [Monster] AS [monster]
ORDER BY [monster].[Id]
---
Executed DbCommand (2ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [monster.Areas].[MonsterId], [monster.Areas].[No], [monster.Areas].[AreaId], [m.Area].[Id], [m.Area].[Name]
FROM [MonsterArea] AS [monster.Areas]
INNER JOIN [Area] AS [m.Area] ON [monster.Areas].[AreaId] = [m.Area].[Id]
INNER JOIN (
    SELECT [monster0].[Id]
    FROM [Monster] AS [monster0]
) AS [t] ON [monster.Areas].[MonsterId] = [t].[Id]
ORDER BY [t].[Id]
---
Executed DbCommand (2ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [monster.Items].[MonsterId], [monster.Items].[No], [monster.Items].[ItemId], [m.Item].[Id], [m.Item].[Name]
FROM [MonsterItem] AS [monster.Items]
INNER JOIN [Item] AS [m.Item] ON [monster.Items].[ItemId] = [m.Item].[Id]
INNER JOIN (
    SELECT [monster1].[Id]
    FROM [Monster] AS [monster1]
) AS [t0] ON [monster.Items].[MonsterId] = [t0].[Id]
ORDER BY [t0].[Id]
---

Includeメソッドで多を読み込み、さらにThenIncludeメソッドで多を読み込む

ThenIncludeメソッドでの読み込みをもう1つ試してみましょう。多を読み込んでさらに多を読み込みます。

// モンスターとモンスター生息地を含めて、モンスターカテゴリを読み込む
var categories = dbContext.Set<MonsterCategory>()
    .Include(category => category.Monsters)
        .ThenInclude(monster => monster.Areas)
    .ToList();
foreach (var monster in categories.SelectMany(category => category.Monsters)) {
    var areas = string.Join(", ", monster.Areas.Select(area => area.AreaId));
    Console.WriteLine($"#{monster.Id} {monster.Name} [{monster.Category.Name}] in {areas}");
}
/*
#1 スライム [スライム] in 1
#3 バブルスライム [スライム] in 3
#2 ドラキー [悪魔] in 1, 2
*/

実行されるSQLは、多をIncludeまたはThenIncludeするごとにSELECT文が増えていく感じでしょうか。

Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [category].[Id], [category].[Name]
FROM [MonsterCategory] AS [category]
ORDER BY [category].[Id]
---
Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [category.Monsters].[Id], [category.Monsters].[CategoryId], [category.Monsters].[Name]
FROM [Monster] AS [category.Monsters]
INNER JOIN (
    SELECT [category0].[Id]
    FROM [MonsterCategory] AS [category0]
) AS [t] ON [category.Monsters].[CategoryId] = [t].[Id]
ORDER BY [t].[Id], [category.Monsters].[Id]
---
Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [category.Monsters.Areas].[MonsterId], [category.Monsters.Areas].[No], [category.Monsters.Areas].[AreaId]
FROM [MonsterArea] AS [category.Monsters.Areas]
INNER JOIN (
    SELECT DISTINCT [category.Monsters0].[Id], [t0].[Id] AS [Id0]
    FROM [Monster] AS [category.Monsters0]
    INNER JOIN (
        SELECT [category1].[Id]
        FROM [MonsterCategory] AS [category1]
    ) AS [t0] ON [category.Monsters0].[CategoryId] = [t0].[Id]
) AS [t1] ON [category.Monsters.Areas].[MonsterId] = [t1].[Id]
ORDER BY [t1].[Id0], [t1].[Id]
---

IncludeメソッドやThenIncludeメソッドを使うとSELECT文が複数実行される場合があるということがなんだか気になるところでした。

Entity Framework Core - SQLをログで確認する

EF Coreでやってみたいことは色々ありますが、まずは実行されるSQLを確認できるようにしておきたいのでログまわりを少し試します。

ドキュメントだとこのあたりですね。

Logging - EF Core | Microsoft Docs

ログを出力する

上記ドキュメントそのままなんですが、ログを出力するためにまずはILoggerProviderILoggerを実装します。

// ロガープロバイダー
public class AppLoggerProvider : ILoggerProvider {
    // ロガーを生成
    public ILogger CreateLogger(string categoryName) {
        return new ConsoleLogger();
    }

    public void Dispose() {
    }

    // ロガー
    private class ConsoleLogger : ILogger {
        public IDisposable BeginScope<TState>(TState state) => null;
        public bool IsEnabled(LogLevel logLevel) => true;

        // ログを出力
        public void Log<TState>(
            LogLevel logLevel, EventId eventId,
            TState state, Exception exception,
            Func<TState, Exception, string> formatter) {
            // コンソールに出力
            Console.WriteLine(formatter(state, exception));
        }
    }
}

コンソールアプリでは実装したロガープロバイダーをDBコンテキストに設定します。(エンティティとDBコンテキストのコードは前回の記事を参考にしてもらえると。)

using (var dbContext = new AppDbContext()) {
    // ロガープロバイダーを設定する
    var serviceProvider = dbContext.GetInfrastructure();
    var loggerFactory = serviceProvider.GetService<ILoggerFactory>();
    loggerFactory.AddProvider(new AppLoggerProvider());

    // データを取得
    var monsters = dbContext.Monsters.ToList();
    foreach (var monster in monsters) {
        Console.WriteLine($"#{monster.Id} {monster.Name}");
    }
    //#1 スライム
    //#2 ドラキー
}

実行すると次のようなログがわっさーと出てきます。ちょっと見づらいですが、コネクションを開いたり閉じたりしたログ、SELECT文を実行したログなどが確認できますね。

Compiling query model:
'from Monster <generated>_0 in DbSet<Monster>
select [<generated>_0]'
Optimized query model:
'from Monster <generated>_0 in DbSet<Monster>
select [<generated>_0]'
(QueryContext queryContext) => IEnumerable<Monster> _InterceptExceptions(
    source: IEnumerable<Monster> _TrackEntities(
        results: IEnumerable<Monster> _ShapedQuery(
            queryContext: queryContext,
            shaperCommandContext: SelectExpression:
                SELECT [m].[Id], [m].[Name]
                FROM [Monster] AS [m],
            shaper: UnbufferedEntityShaper<Monster>),
        queryContext: queryContext,
        entityTrackingInfos: { itemType: Monster },
        entityAccessors: List<Func<Monster, object>>
        {
            Func<Monster, Monster>,
        }
    ),
    contextType: ConsoleApp.AppDbContext,
    logger: DiagnosticsLogger<Query>,
    queryContext: queryContext)
Opening connection to database 'Test' on server '.'.
Opened connection to database 'Test' on server '.'.
Executing DbCommand [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [m].[Id], [m].[Name]
FROM [Monster] AS [m]
Executed DbCommand (15ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [m].[Id], [m].[Name]
FROM [Monster] AS [m]
A data reader was disposed.
Closing connection to database 'Test' on server '.'.
Closed connection to database 'Test' on server '.'.

ログをフィルタする

このままではちょっと情報が多いので、実行されるSQLだけを確認したいという体でログをフィルタしたいと思います。

ILoggerProvider.CreateLoggerメソッドの引数にcategoryNameがあります。ログにはいくつかカテゴリがあるようで、おそらくDbLoggerCategoryで定義されているのがそれです。

ソースを見た+試してみたところ、DbLoggerCategory.Database.CommandSQLの実行に関係してそうです。これを使ってフィルタするには次のようなコードになるのかなと思います。

// ロガープロバイダー
public class AppLoggerProvider : ILoggerProvider {
    // ロガーを生成
    public ILogger CreateLogger(string categoryName) {
        // SQLの実行に関するログだけ出力する
        if (string.Equals(categoryName, DbLoggerCategory.Database.Command.Name)) {
            return new ConsoleLogger();
        }

        // 何も出力しないロガー
        return NullLogger.Instance;
    }

    public void Dispose() {
    }

    // ロガー
    private class ConsoleLogger : ILogger {
        public IDisposable BeginScope<TState>(TState state) => null;
        public bool IsEnabled(LogLevel logLevel) => true;

        // ログを書き込む
        public void Log<TState>(
            LogLevel logLevel, EventId eventId,
            TState state, Exception exception,
            Func<TState, Exception, string> formatter) {
            // ログをコンソールに出力
            // LogLevelとEventIdも出力してみる
            Console.WriteLine($"{nameof(logLevel)}: {logLevel}");
            Console.WriteLine($"{nameof(eventId)}: {eventId}");
            Console.WriteLine(formatter(state, exception));
            Console.WriteLine("---");
        }
    }
}

このロガーで再度実行すると次のようなログが出力されます。

logLevel: Debug
eventId: Microsoft.EntityFrameworkCore.Database.Command.CommandExecuting
Executing DbCommand [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [m].[Id], [m].[Name]
FROM [Monster] AS [m]
---
logLevel: Information
eventId: Microsoft.EntityFrameworkCore.Database.Command.CommandExecuted
Executed DbCommand (43ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [m].[Id], [m].[Name]
FROM [Monster] AS [m]
---
logLevel: Debug
eventId: Microsoft.EntityFrameworkCore.Database.Command.DataReaderDisposing
A data reader was disposed.
---

ILogger.Logメソッドの引数にあるLogLevelEventIdも気になったので出力してみましたが、このパラメータを使ってフィルタしたり、出力先を変えたりすることもできそうですね。

ちなみにEventIdRelationalEventIdで定義されています。

今回はこんなところで。

Entity Framework Coreはじめました

EF Coreを試しはじめました。先はあまり考えていませんが、色々試したことを書き残していけるといいなと思います。とりあえず今回は最初なのでSQL Serverにあるデータを取得してみます。

データの用意

まずはSQL Server Management Studioでデータを用意しておきます。

-- テーブルを作る
drop table if exists dbo.Monster;
create table dbo.Monster(
    Id int,
    Name nvarchar(20),
    constraint PK_Monster primary key(Id)
);

-- データを投入
insert into dbo.Monster(Id, Name)
output inserted.*
values
    (1, N'スライム'),
    (2, N'ドラキー');
/*
Id          Name
----------- --------------------
1           スライム
2           ドラキー
*/

EF Coreのインストール

ここからはVisual Studioです。.NET Coreのコンソールアプリプロジェクトを作成してNugetで必要なものを取得しましょう。パッケージマネージャーコンソールで以下を実行です。

Install-Package Microsoft.EntityFrameworkCore.SqlServer

エンティティとDBコンテキストの用意

エンティティとDBコンテキストを用意します。

EF6とは違って接続文字列はOnConfiguringメソッド内で指定するみたいです。

個人的な好みでテーブル名は単数形にしたいのですが、EF6にあったPluralizingTableNameConventionのようなクラスはどうもなさそうな?(しっかり調べていない)ので、愚直にToTableメソッドでエンティティをテーブルにマッピングしています。

// エンティティ
public class Monster {
    public int Id { get; set; }
    public string Name { get; set; }
}

// DBコンテキスト
public class AppDbContext : DbContext {
    public DbSet<Monster> Monsters { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) {
        // 接続文字列を指定する
        var connectionString = new SqlConnectionStringBuilder {
            DataSource = ".",
            InitialCatalog = "Test",
            IntegratedSecurity = true,
        }.ToString();
        optionsBuilder.UseSqlServer(connectionString);
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder) {
        // テーブルにマッピングする
        modelBuilder.Entity<Monster>().ToTable("Monster");
    }
}

データの取得

データを取得してみます。このあたりはEF6と同じですね。

using(var dbContext = new AppDbContext()) {
    // データを取得
    var monsters = dbContext.Monsters.ToList();
    foreach (var monster in monsters) {
        Console.WriteLine($"#{monster.Id} {monster.Name}");
    }
    //#1 スライム
    //#2 ドラキー
}

次の環境で確認しています。

今回はこのへんで。

参考