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