インターセプター

Entity Framework Core (EF Core) インターセプターを使用すると、EF Core 操作をインターセプト、変更、抑制することができます。 これには、コマンド実行などの低レベルのデータベース操作と SaveChanges の呼び出しなどの上位レベルの操作が含まれます。

インターセプターは、インターセプトしている操作を変更または抑制できる点がログや診断と異なります。 ログ記録には、シンプルなログまたは Microsoft.Extensions.Logging の使用をお勧めします。

インターセプターは DbContext インスタンスごとに登録され、この登録はいつでも行うことができます。 プロセス内のすべての DbContext インスタンスで同じ情報を取得するには、診断リスナーを使用します。

インターセプターの登録

インターセプターは、DbContext インスタンスを構成するときに AddInterceptors を使って登録します。 これは、通常、DbContext.OnConfiguring のオーバーライドで行われます。 次に例を示します。

public class ExampleContext : BlogsContext
{
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder.AddInterceptors(new TaggedQueryCommandInterceptor());
}

または、AddInterceptors は、AddDbContext の一部として呼び出すことも、DbContext コンストラクターに渡す DbContextOptions インスタンスを作成するときに呼び出すこともできます。

ヒント

AddDbContext が使用される場合も、または DbContextOptions インスタンスが DbContext コンストラクターに渡される場合も、OnConfiguring が呼び出されます。 したがって、それは DbContext の構築方法に関係なく、コンテキスト構成を適用するのに理想的な場所となります。

インターセプターは、多くの場合、ステートレスであるため、1 つのインターセプター インスタンスをすべての DbContext インスタンスに使用できます。 次に例を示します。

public class TaggedQueryCommandInterceptorContext : BlogsContext
{
    private static readonly TaggedQueryCommandInterceptor _interceptor
        = new TaggedQueryCommandInterceptor();

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder.AddInterceptors(_interceptor);
}

インターセプターのすべてのインスタンスで、IInterceptor から派生したインターフェイスを 1 つ以上実装する必要があります。 各インスタンスは、それぞれに複数のインターセプト インターフェイスが実装される場合でも 1 回だけ登録します。各インターフェイスのイベントは、必要に応じて、EF Core によってルーティングされます。

データベース インターセプト

Note

データベース インターセプトは、リレーショナル データベース プロバイダーでのみ使用できます。

低レベルのデータベース インターセプトは、次の表に示す 3 つのインターフェイスに分かれています。

インターセプター インターセプトされるデータベース操作
IDbCommandInterceptor コマンドの作成
コマンドの実行
コマンドの失敗
コマンドの DbDataReader の破棄
IDbConnectionInterceptor 接続のオープンとクローズ
接続の失敗
IDbTransactionInterceptor トランザクションの作成
既存のトランザクションの使用
トランザクションのコミット
トランザクションのロールバック
セーブポイントの作成と使用
トランザクションの失敗

基底クラス DbCommandInterceptorDbConnectionInterceptorDbTransactionInterceptor には、対応するインターフェイスの各メソッドに対する no-op 実装が含まれています。 基本クラスを使用すれば、未使用のインターセプト メソッドを実装しなくても済みます。

各インターセプター型のメソッドはペアで提供されています。1 番目はデータベース操作が開始される前に呼び出され、2 番目は操作が完了した後に呼び出されます。 たとえば、DbCommandInterceptor.ReaderExecuting はクエリが実行される前に呼び出され、DbCommandInterceptor.ReaderExecuted はクエリがデータベースに送信された後で呼び出されます。

メソッドの各ペアには、同期と非同期の両方のバリエーションが用意されています。 これにより、アクセス トークンの要求などの非同期 I/O を、非同期データベース操作のインターセプトの一環として実行することができます。

例: クエリ ヒントを追加するためのコマンド インターセプト

IDbCommandInterceptor を使って、データベースに送信される前に SQL を変更できます。 この例では、クエリ ヒントを含めるための変更を SQL に加える方法を示します。

多くの場合、インターセプトの最も複雑な部分は、変更する必要があるクエリにコマンドを対応させるタイミングを決定するところです。 SQL の解析は 1 つのオプションですが、脆弱になる傾向があります。 もう 1 つのオプションは、EF Core クエリ タグを使用して、変更する必要がある各クエリにタグを付けるというものです。 次に例を示します。

var blogs1 = context.Blogs.TagWith("Use hint: robust plan").ToList();

このタグは、常にコマンド テキストの最初の行にコメントとして含められるので、インターセプターで検出することができます。 タグを検出すると、適切なヒントを追加するための変更がクエリ SQL に加えられます。

public class TaggedQueryCommandInterceptor : DbCommandInterceptor
{
    public override InterceptionResult<DbDataReader> ReaderExecuting(
        DbCommand command,
        CommandEventData eventData,
        InterceptionResult<DbDataReader> result)
    {
        ManipulateCommand(command);

        return result;
    }

    public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
        DbCommand command,
        CommandEventData eventData,
        InterceptionResult<DbDataReader> result,
        CancellationToken cancellationToken = default)
    {
        ManipulateCommand(command);

        return new ValueTask<InterceptionResult<DbDataReader>>(result);
    }

    private static void ManipulateCommand(DbCommand command)
    {
        if (command.CommandText.StartsWith("-- Use hint: robust plan", StringComparison.Ordinal))
        {
            command.CommandText += " OPTION (ROBUST PLAN)";
        }
    }
}

注意:

  • インターセプターのインターフェイスですべてのメソッドを実装する必要がないようにするため、インターセプターは DbCommandInterceptor から継承します。
  • インターセプターには同期と非同期の両方のメソッドが実装されます。 このため、同期および非同期のクエリに同じクエリ ヒントが確実に適用されます。
  • インターセプターには Executing メソッドが実装されます。これは、生成された SQL がデータベースに送信される前に、その SQL に対して EF Core によって呼び出されます。 このことは、データベース呼び出しが返された後に呼び出される Executed メソッドと対照的です。

この例のコードを実行すると、クエリがタグ付けされている場合、次が生成されます。

-- Use hint: robust plan

SELECT [b].[Id], [b].[Name]
FROM [Blogs] AS [b] OPTION (ROBUST PLAN)

一方、クエリがタグ付けされていない場合は、それは変更されないままデータベースに送信されます。

SELECT [b].[Id], [b].[Name]
FROM [Blogs] AS [b]

例: AAD を使用した SQL Azure 認証のための接続インターセプト

IDbConnectionInterceptor を使って、データベースへの接続に使われる前に DbConnection を操作できます。 これを使用して、Azure Active Directory (AAD) アクセス トークンを取得できます。 次に例を示します。

public class AadAuthenticationInterceptor : DbConnectionInterceptor
{
    public override InterceptionResult ConnectionOpening(
        DbConnection connection,
        ConnectionEventData eventData,
        InterceptionResult result)
        => throw new InvalidOperationException("Open connections asynchronously when using AAD authentication.");

    public override async ValueTask<InterceptionResult> ConnectionOpeningAsync(
        DbConnection connection,
        ConnectionEventData eventData,
        InterceptionResult result,
        CancellationToken cancellationToken = default)
    {
        var sqlConnection = (SqlConnection)connection;

        var provider = new AzureServiceTokenProvider();
        // Note: in some situations the access token may not be cached automatically the Azure Token Provider.
        // Depending on the kind of token requested, you may need to implement your own caching here.
        sqlConnection.AccessToken = await provider.GetAccessTokenAsync("https://database.windows.net/", null, cancellationToken);

        return result;
    }
}

ヒント

Microsoft.Data.SqlClient では、接続文字列を介した AAD 認証がサポートされるようになりました。 詳細については、「SqlAuthenticationMethod」を参照してください。

警告

接続を開くために同期呼び出しが行われた場合は、インターセプターからスローされることに注意してください。 これは、アクセス トークンを取得するための非同期でないメソッドが存在していない上に、デッドロックのリスクを冒さずに非同期でないコンテキストから非同期メソッドを呼び出す汎用的でシンプルな方法がないことによります。

警告

場合によっては、アクセス トークンが Azure トークン プロバイダーで自動的にキャッシュされない場合があります。 要求されたトークンの種類によっては、ここで独自のキャッシュを実装することが必要な場合があります。

例: キャッシュ用の高度なコマンド インターセプト

EF Core インターセプターでは、次のことができます。

  • インターセプトされている操作の実行を抑制するように EF Core に指示する
  • EF Core に報告する操作の結果を変更する

この例では、これらの機能を使用してプリミティブな第 2 レベルのキャッシュのように動作するインターセプターを示します。 キャッシュされたクエリの結果は、特定のクエリに対して返されるので、データベースのラウンドトリップが回避されます。

警告

この方法で EF Core の既定の動作を変更する場合は注意してください。 正しく処理できない異常な結果が EF Core に届いた場合、EF Core が予期できない動作をする場合があります。 また、この例ではインターセプターの概念を示します。これは、堅牢な第 2 レベル キャッシュ実装用のテンプレートとして意図されたのではありません。

この例では、最新の "毎日のメッセージ" を取得するために、アプリケーションによってクエリが頻繁に実行されます。

async Task<string> GetDailyMessage(DailyMessageContext context)
    => (await context.DailyMessages.TagWith("Get_Daily_Message").OrderBy(e => e.Id).LastAsync()).Message;

このクエリは、インター セプターで容易に検出できるようにタグ付けされます。 その考え方は、毎日 1 回だけ、新しいメッセージがないかデータベースに対してクエリを実行するというものです。 それ以外の時間で、アプリケーションによって使用されるのは、キャッシュされた結果です (このサンプルでは、10 秒の遅延をサンプル内で使用して新しい日をシミュレートします)。

インターセプターの状態

このインターセプターはステートフルです。クエリで取得された最新の毎日のメッセージの ID とメッセージ テキストに加えて、そのクエリが実行された時刻が格納されます。 この状態であるがゆえに、キャッシュを行う際は複数のコンテキスト インスタンスで同じインターセプターを使用する必要があるため、lock も必要になります。

private readonly object _lock = new object();
private int _id;
private string _message;
private DateTime _queriedAt;

実行前

Executing メソッド (つまり、データベース呼び出しを行う前) では、インターセプターによってタグ付けされたクエリを検出し、次に、キャッシュされた結果が存在するかどうかを確認します。 そのような結果が見つかった場合、クエリは抑制され、代わりにキャッシュされた結果が使用されます。

public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
    DbCommand command,
    CommandEventData eventData,
    InterceptionResult<DbDataReader> result,
    CancellationToken cancellationToken = default)
{
    if (command.CommandText.StartsWith("-- Get_Daily_Message", StringComparison.Ordinal))
    {
        lock (_lock)
        {
            if (_message != null
                && DateTime.UtcNow < _queriedAt + new TimeSpan(0, 0, 10))
            {
                command.CommandText = "-- Get_Daily_Message: Skipping DB call; using cache.";
                result = InterceptionResult<DbDataReader>.SuppressWithResult(new CachedDailyMessageDataReader(_id, _message));
            }
        }
    }

    return new ValueTask<InterceptionResult<DbDataReader>>(result);
}

コードで InterceptionResult<TResult>.SuppressWithResult を呼び出し、キャッシュされたデータを含む置換 DbDataReader を渡す方法に注意してください。 これにより、次に InterceptionResult が返され、クエリの実行が抑制されます。 代わりに、クエリの結果として置換リーダーが EF Core によって使用されます。

このインターセプターでは、コマンド テキストも操作します。 この操作は必須ではありませんが、ログ メッセージをわかりやすくすることができます。 クエリは実行されなくなるので、コマンド テキストを有効な SQL とする必要はありません。

実行後

キャッシュされたメッセージが使用できない場合、または有効期限切れになっている場合、上記のコードによって結果は抑制されません。 したがって、通常どおり、EF Core によってクエリが実行されます。 そして、実行後にインターセプターの Executed メソッドに制御が戻されます。 この時点で、まだ結果がキャッシュされたリーダーではない場合は、新しいメッセージ ID と文字列が実際のリーダーから抽出され、このクエリの次回の使用に備えてキャッシュされます。

public override async ValueTask<DbDataReader> ReaderExecutedAsync(
    DbCommand command,
    CommandExecutedEventData eventData,
    DbDataReader result,
    CancellationToken cancellationToken = default)
{
    if (command.CommandText.StartsWith("-- Get_Daily_Message", StringComparison.Ordinal)
        && !(result is CachedDailyMessageDataReader))
    {
        try
        {
            await result.ReadAsync(cancellationToken);

            lock (_lock)
            {
                _id = result.GetInt32(0);
                _message = result.GetString(1);
                _queriedAt = DateTime.UtcNow;
                return new CachedDailyMessageDataReader(_id, _message);
            }
        }
        finally
        {
            await result.DisposeAsync();
        }
    }

    return result;
}

デモンストレーション

キャッシュ インターセプターのサンプルには、キャッシュをテストするために毎日のメッセージを照会するシンプルなコンソール アプリケーションが含まれています。

// 1. Initialize the database with some daily messages.
using (var context = new DailyMessageContext())
{
    await context.Database.EnsureDeletedAsync();
    await context.Database.EnsureCreatedAsync();

    context.AddRange(
        new DailyMessage { Message = "Remember: All builds are GA; no builds are RTM." },
        new DailyMessage { Message = "Keep calm and drink tea" });

    await context.SaveChangesAsync();
}

// 2. Query for the most recent daily message. It will be cached for 10 seconds.
using (var context = new DailyMessageContext())
{
    Console.WriteLine(await GetDailyMessage(context));
}

// 3. Insert a new daily message.
using (var context = new DailyMessageContext())
{
    context.Add(new DailyMessage { Message = "Free beer for unicorns" });

    await context.SaveChangesAsync();
}

// 4. Cached message is used until cache expires.
using (var context = new DailyMessageContext())
{
    Console.WriteLine(await GetDailyMessage(context));
}

// 5. Pretend it's the next day.
Thread.Sleep(10000);

// 6. Cache is expired, so the last message will not be queried again.
using (var context = new DailyMessageContext())
{
    Console.WriteLine(await GetDailyMessage(context));
}

async Task<string> GetDailyMessage(DailyMessageContext context)
    => (await context.DailyMessages.TagWith("Get_Daily_Message").OrderBy(e => e.Id).LastAsync()).Message;

次の出力が生成されます。

info: 10/15/2020 12:32:11.801 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      -- Get_Daily_Message

      SELECT "d"."Id", "d"."Message"
      FROM "DailyMessages" AS "d"
      ORDER BY "d"."Id" DESC
      LIMIT 1

Keep calm and drink tea

info: 10/15/2020 12:32:11.821 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[@p0='Free beer for unicorns' (Size = 22)], CommandType='Text', CommandTimeout='30']
      INSERT INTO "DailyMessages" ("Message")
      VALUES (@p0);
      SELECT "Id"
      FROM "DailyMessages"
      WHERE changes() = 1 AND "rowid" = last_insert_rowid();

info: 10/15/2020 12:32:11.826 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      -- Get_Daily_Message: Skipping DB call; using cache.

Keep calm and drink tea

info: 10/15/2020 12:32:21.833 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      -- Get_Daily_Message

      SELECT "d"."Id", "d"."Message"
      FROM "DailyMessages" AS "d"
      ORDER BY "d"."Id" DESC
      LIMIT 1

Free beer for unicorns

アプリケーションではキャッシュされたメッセージが、タイムアウトになるまで使用され、その時がくると、データベースに対してクエリが再度実行され、新しいメッセージが取得されることが、ログ出力からわかります。

SaveChanges インターセプト

SaveChangesSaveChangesAsync の各インターセプト ポイントは、ISaveChangesInterceptor インターフェイスによって定義されています。 その他のインターセプターについては、便宜のため、no-op メソッドを含む SaveChangesInterceptor 基底クラスが用意されています。

ヒント

インターセプターは強力です。 しかし、多くの場合、SaveChanges メソッドをオーバーライドするか、または DbContext で公開されている SaveChanges 用の .NET イベントを使用する方が簡単な場合があります。

例: 監査のための SaveChanges インターセプト

SaveChanges をインターセプトすることで、加えた変更の独立した監査レコードを作成できます。

Note

これは、監査ソリューションを堅牢にすることを意図したものではありません。 これはインターセプトの機能を実演するために使用するシンプルな例です。

アプリケーション コンテキスト

監査のサンプルでは、ブログと投稿を含むシンプルな DbContext を使用します。

public class BlogsContext : DbContext
{
    private readonly AuditingInterceptor _auditingInterceptor = new AuditingInterceptor("DataSource=audit.db");

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder
            .AddInterceptors(_auditingInterceptor)
            .UseSqlite("DataSource=blogs.db");

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

public class Blog
{
    public int Id { get; set; }
    public string Name { get; set; }

    public ICollection<Post> Posts { get; } = new List<Post>();
}

public class Post
{
    public int Id { get; set; }
    public string Title { get; set; }

    public Blog Blog { get; set; }
}

DbContext インスタンスごとにインターセプターの新しいインスタンスが登録されていることに注目してください。 これは、監査インターセプターに含まれる状態が、現在のコンテキスト インスタンスにリンクされているためです。

監査コンテキスト

このサンプルには、監査データベースに使用される 2 番目の DbContext とモデルも含まれています。

public class AuditContext : DbContext
{
    private readonly string _connectionString;

    public AuditContext(string connectionString)
    {
        _connectionString = connectionString;
    }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder.UseSqlite(_connectionString);

    public DbSet<SaveChangesAudit> SaveChangesAudits { get; set; }
}

public class SaveChangesAudit
{
    public int Id { get; set; }
    public Guid AuditId { get; set; }
    public DateTime StartTime { get; set; }
    public DateTime EndTime { get; set; }
    public bool Succeeded { get; set; }
    public string ErrorMessage { get; set; }

    public ICollection<EntityAudit> Entities { get; } = new List<EntityAudit>();
}

public class EntityAudit
{
    public int Id { get; set; }
    public EntityState State { get; set; }
    public string AuditMessage { get; set; }

    public SaveChangesAudit SaveChangesAudit { get; set; }
}

インターセプター

インターセプターを使用して監査する場合の一般的な考え方は次のとおりです。

  • 監査メッセージは、SaveChanges の先頭で作成され、監査データベースに書き込まれる
  • SaveChanges の続行が許可される
  • SaveChanges が成功した場合は、監査メッセージが更新されて成功したことを示す
  • SaveChanges が失敗した場合は、監査メッセージが更新されて失敗したことを示す

最初のステージは、ISaveChangesInterceptor.SavingChangesISaveChangesInterceptor.SavingChangesAsync のオーバーライドを使って、データベースに変更が送信される前に処理されます。

public async ValueTask<InterceptionResult<int>> SavingChangesAsync(
    DbContextEventData eventData,
    InterceptionResult<int> result,
    CancellationToken cancellationToken = default)
{
    _audit = CreateAudit(eventData.Context);

    using var auditContext = new AuditContext(_connectionString);

    auditContext.Add(_audit);
    await auditContext.SaveChangesAsync();

    return result;
}

public InterceptionResult<int> SavingChanges(
    DbContextEventData eventData,
    InterceptionResult<int> result)
{
    _audit = CreateAudit(eventData.Context);

    using var auditContext = new AuditContext(_connectionString);
    auditContext.Add(_audit);
    auditContext.SaveChanges();

    return result;
}

同期および非同期メソッドの両方をオーバーライドすると、SaveChanges または SaveChangesAsync が呼び出されたかどうかに関係なく、監査が確実に実行されます。 また、非同期オーバーロード自体が、監査データベースに対して非ブロッキング非同期 I/O を実行できることにも注目してください。 すべてのデータベース I/O を確実に非同期にするには、同期メソッド SavingChanges からスローすることをお勧めします。 これを行うには、常に、SaveChanges ではなく SaveChangesAsync を常にアプリケーションから呼び出す必要があります。

監査メッセージ

どのインターセプター メソッドにも、インターセプトするイベントに関するコンテキスト情報を指定する eventData パラメーターが用意されています。 この場合、現在のアプリケーション DbContext がイベント データに取り込まれると、それを使用して監査メッセージが作成されます。

private static SaveChangesAudit CreateAudit(DbContext context)
{
    context.ChangeTracker.DetectChanges();

    var audit = new SaveChangesAudit { AuditId = Guid.NewGuid(), StartTime = DateTime.UtcNow };

    foreach (var entry in context.ChangeTracker.Entries())
    {
        var auditMessage = entry.State switch
        {
            EntityState.Deleted => CreateDeletedMessage(entry),
            EntityState.Modified => CreateModifiedMessage(entry),
            EntityState.Added => CreateAddedMessage(entry),
            _ => null
        };

        if (auditMessage != null)
        {
            audit.Entities.Add(new EntityAudit { State = entry.State, AuditMessage = auditMessage });
        }
    }

    return audit;

    string CreateAddedMessage(EntityEntry entry)
        => entry.Properties.Aggregate(
            $"Inserting {entry.Metadata.DisplayName()} with ",
            (auditString, property) => auditString + $"{property.Metadata.Name}: '{property.CurrentValue}' ");

    string CreateModifiedMessage(EntityEntry entry)
        => entry.Properties.Where(property => property.IsModified || property.Metadata.IsPrimaryKey()).Aggregate(
            $"Updating {entry.Metadata.DisplayName()} with ",
            (auditString, property) => auditString + $"{property.Metadata.Name}: '{property.CurrentValue}' ");

    string CreateDeletedMessage(EntityEntry entry)
        => entry.Properties.Where(property => property.Metadata.IsPrimaryKey()).Aggregate(
            $"Deleting {entry.Metadata.DisplayName()} with ",
            (auditString, property) => auditString + $"{property.Metadata.Name}: '{property.CurrentValue}' ");
}

結果は SaveChangesAudit エンティティと、EntityAudit エンティティ (挿入、更新、または削除ごとに 1 つずつある) のコレクションとで構成されます。 次に、これらのエンティティがインターセプターによって監査データベースに挿入されます。

ヒント

すべての EF Core イベント データ クラスで ToString をオーバーライドすると、イベントに対して同等のログ メッセージが生成されます。 たとえば、ContextInitializedEventData.ToString を呼び出すと、"Entity Framework Core 5.0.0 では、プロバイダー 'Microsoft.EntityFrameworkCore.Sqlite' (オプション: None を指定) を使用して 'BlogsContext' を初期化しました" が生成されます。

成功の検出

監査エンティティはインターセプター上に格納されるので、SaveChanges が成功しても失敗しても再度アクセスできます。 成功した場合、ISaveChangesInterceptor.SavedChanges または ISaveChangesInterceptor.SavedChangesAsync が呼び出されます。

public int SavedChanges(SaveChangesCompletedEventData eventData, int result)
{
    using var auditContext = new AuditContext(_connectionString);

    auditContext.Attach(_audit);
    _audit.Succeeded = true;
    _audit.EndTime = DateTime.UtcNow;

    auditContext.SaveChanges();

    return result;
}

public async ValueTask<int> SavedChangesAsync(
    SaveChangesCompletedEventData eventData,
    int result,
    CancellationToken cancellationToken = default)
{
    using var auditContext = new AuditContext(_connectionString);

    auditContext.Attach(_audit);
    _audit.Succeeded = true;
    _audit.EndTime = DateTime.UtcNow;

    await auditContext.SaveChangesAsync(cancellationToken);

    return result;
}

監査エンティティは監査コンテキストにアタッチされます。その理由は、それがデータベース内に既に存在し、更新が必要であることです。 次に、SucceededEndTime を設定します。そうすることで、これらのプロパティは変更済みとしてマークされ、SaveChanges から監査データベースに更新が送信されます。

失敗の検出

失敗は成功とほぼ同じ方法で処理されますが、ISaveChangesInterceptor.SaveChangesFailed または ISaveChangesInterceptor.SaveChangesFailedAsync メソッドで行われます。 イベント データには、スローされた例外が含まれます。

public void SaveChangesFailed(DbContextErrorEventData eventData)
{
    using var auditContext = new AuditContext(_connectionString);

    auditContext.Attach(_audit);
    _audit.Succeeded = false;
    _audit.EndTime = DateTime.UtcNow;
    _audit.ErrorMessage = eventData.Exception.Message;

    auditContext.SaveChanges();
}

public async Task SaveChangesFailedAsync(
    DbContextErrorEventData eventData,
    CancellationToken cancellationToken = default)
{
    using var auditContext = new AuditContext(_connectionString);

    auditContext.Attach(_audit);
    _audit.Succeeded = false;
    _audit.EndTime = DateTime.UtcNow;
    _audit.ErrorMessage = eventData.Exception.InnerException?.Message;

    await auditContext.SaveChangesAsync(cancellationToken);
}

デモンストレーション

監査サンプルには、ブログ データベースに変更を加え、作成された監査を表示するシンプルなコンソール アプリケーションが含まれています。

// Insert, update, and delete some entities

using (var context = new BlogsContext())
{
    context.Add(
        new Blog { Name = "EF Blog", Posts = { new Post { Title = "EF Core 3.1!" }, new Post { Title = "EF Core 5.0!" } } });

    await context.SaveChangesAsync();
}

using (var context = new BlogsContext())
{
    var blog = context.Blogs.Include(e => e.Posts).Single();

    blog.Name = "EF Core Blog";
    context.Remove(blog.Posts.First());
    blog.Posts.Add(new Post { Title = "EF Core 6.0!" });

    context.SaveChanges();
}

// Do an insert that will fail

using (var context = new BlogsContext())
{
    try
    {
        context.Add(new Post { Id = 3, Title = "EF Core 3.1!" });

        await context.SaveChangesAsync();
    }
    catch (DbUpdateException)
    {
    }
}

// Look at the audit trail

using (var context = new AuditContext("DataSource=audit.db"))
{
    foreach (var audit in context.SaveChangesAudits.Include(e => e.Entities).ToList())
    {
        Console.WriteLine(
            $"Audit {audit.AuditId} from {audit.StartTime} to {audit.EndTime} was{(audit.Succeeded ? "" : " not")} successful.");

        foreach (var entity in audit.Entities)
        {
            Console.WriteLine($"  {entity.AuditMessage}");
        }

        if (!audit.Succeeded)
        {
            Console.WriteLine($"  Error: {audit.ErrorMessage}");
        }
    }
}

その結果には、監査データベースの内容が表示されます。

Audit 52e94327-1767-4046-a3ca-4c6b1eecbca6 from 10/14/2020 9:10:17 PM to 10/14/2020 9:10:17 PM was successful.
  Inserting Blog with Id: '-2147482647' Name: 'EF Blog'
  Inserting Post with Id: '-2147482647' BlogId: '-2147482647' Title: 'EF Core 3.1!'
  Inserting Post with Id: '-2147482646' BlogId: '-2147482647' Title: 'EF Core 5.0!'
Audit 8450f57a-5030-4211-a534-eb66b8da7040 from 10/14/2020 9:10:17 PM to 10/14/2020 9:10:17 PM was successful.
  Inserting Post with Id: '-2147482645' BlogId: '1' Title: 'EF Core 6.0!'
  Updating Blog with Id: '1' Name: 'EF Core Blog'
  Deleting Post with Id: '1'
Audit 201fef4d-66a7-43ad-b9b6-b57e9d3f37b3 from 10/14/2020 9:10:17 PM to 10/14/2020 9:10:17 PM was not successful.
  Inserting Post with Id: '3' BlogId: '' Title: 'EF Core 3.1!'
  Error: SQLite Error 19: 'UNIQUE constraint failed: Post.Id'.