Поделиться через


Перехватчики

Перехватчики Entity Framework Core (EF Core) позволяют перехватывать, изменять и/или подавление операций EF Core. Сюда входят низкоуровневые операции с базами данных, такие как выполнение команды, а также операции более высокого уровня, такие как вызовы SaveChanges.

Перехватчики отличаются от ведения журнала и диагностики, так как они позволяют изменять или подавлять перехватываемую операцию. Для ведения журнала лучше всего использовать средство простого ведения журнала или Microsoft.Extensions.Logging.

Перехватчики регистрируются для каждого экземпляра DbContext во время настройки контекста. С помощью прослушивателя диагностики можно получить ту же информацию, но для всех экземпляров DbContext в процессе.

Регистрация перехватчиков

Перехватчики регистрируются при AddInterceptors настройке экземпляра DbContext. Обычно это делается в переопределении DbContext.OnConfiguring. Например:

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

Кроме того, AddInterceptors можно вызывать как часть AddDbContext или при создании экземпляра DbContextOptions для передачи в конструктор DbContext.

Совет

OnConfiguring по-прежнему вызывается при использовании AddDbContext или экземпляре DbContextOptions передается конструктору DbContext. Это делает его идеальным местом для применения конфигурации контекста независимо от того, как создается DbContext.

Перехватчики часто являются бессерверными, что означает, что для всех экземпляров DbContext можно использовать один экземпляр перехватчика. Например:

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

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

Каждый экземпляр перехватчика должен реализовать один или несколько интерфейсов, производных от IInterceptor. Каждый экземпляр должен быть зарегистрирован только один раз, даже если он реализует несколько интерфейсов перехвата; EF Core перенаправит события для каждого интерфейса соответствующим образом.

Перехват базы данных

Примечание.

Перехват базы данных доступен только для поставщиков реляционных баз данных.

Перехват базы данных низкого уровня разделен на три интерфейса, показанные в следующей таблице.

Перехватчик Операции базы данных перехватываются
IDbCommandInterceptor Создание команд, выполняющих команды
, сбои
команд
удаляет dbDataReader команды.
IDbConnectionInterceptor Сбои открытия и закрытия подключений
Подключение ion
IDbTransactionInterceptor
Создание транзакций с использованием существующих
транзакций фиксации транзакций

, отката транзакций и использование сбоев транзакций savepoints

Базовые классы DbCommandInterceptorDbConnectionInterceptorи DbTransactionInterceptor содержат реализации no-op для каждого метода в соответствующем интерфейсе. Используйте базовые классы, чтобы избежать необходимости реализации неиспользуемых методов перехвата.

Методы каждого типа перехватчика приходят в парах, при первом вызове перед началом операции базы данных и второй после завершения операции. Например, DbCommandInterceptor.ReaderExecuting вызывается перед выполнением запроса и DbCommandInterceptor.ReaderExecuted вызывается после отправки запроса в базу данных.

Каждая пара методов имеет синхронные и асинхронные вариации. Это позволяет асинхронным ввода-выводам, таким как запрос маркера доступа, выполняться в рамках перехвата асинхронной операции базы данных.

Пример. Перехват команд для добавления подсказок запроса

Совет

Вы можете скачать пример перехватчика команд из GitHub.

Можно IDbCommandInterceptor использовать для изменения SQL перед отправкой в базу данных. В этом примере показано, как изменить SQL для включения указания запроса.

Часто самая сложная часть перехвата определяет, когда команда соответствует запросу, который необходимо изменить. Анализ SQL является одним из вариантов, но, как правило, хрупким. Другим вариантом является использование тегов запросов 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 методы, которые вызываются EF Core с созданным SQL перед отправкой в базу данных. Сравните это с 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]

Пример: перехват Подключение для проверки подлинности SQL Azure с помощью AAD

Совет

Вы можете скачать пример перехватчика подключений из GitHub.

Можно 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. В зависимости от типа запрошенного маркера может потребоваться реализовать собственное кэширование здесь.

Пример. Расширенный перехват команд для кэширования

Совет

Вы можете скачать пример расширенного перехватчика команд из GitHub.

Перехватчики EF Core могут:

  • Сообщите EF Core, чтобы отключить выполнение операции, перехватываемой
  • Изменение результата операции, отчитаемой обратно в EF Core

В этом примере показан перехватчик, использующий эти функции для поведения как примитивный кэш второго уровня. Кэшированные результаты запроса возвращаются для определенного запроса, избегая обхода базы данных.

Предупреждение

Обратите внимание на изменение поведения по умолчанию EF Core таким образом. EF Core может вести себя неожиданно, если он получает ненормальный результат, который он не может правильно обрабатывать. Кроме того, в этом примере демонстрируются понятия перехватчика; Он не предназначен в качестве шаблона для надежной реализации кэша второго уровня.

В этом примере приложение часто выполняет запрос, чтобы получить последнее "ежедневное сообщение":

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

Этот запрос помечается так, чтобы его можно было легко обнаружить в перехватчике. Идея заключается в том, чтобы запросить базу данных только для создания нового сообщения каждый день. В других случаях приложение будет использовать кэшированный результат. (В примере используется задержка в течение 10 секунд в примере для имитации нового дня.)

Состояние перехватчика

Этот перехватчик является отслеживанием состояния: он сохраняет идентификатор и текст сообщения последнего ежедневного запроса, а также время выполнения этого запроса. Из-за этого состояния также требуется блокировка, так как кэширование требует, чтобы один и тот же перехватчик использовался несколькими экземплярами контекста.

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 , содержащую кэшированные данные. Затем возвращается этот перехват, вызывая подавление выполнения запроса. Средство чтения замены используется EF Core в качестве результатов запроса.

Этот перехватчик также управляет текстом команды. Эта манипуляция не требуется, но улучшает ясность в сообщениях журнала. Текст команды не должен быть допустимым SQL, так как запрос теперь не будет выполнен.

После выполнения

Если кэшированное сообщение недоступно или истекло, приведенный выше код не подавляет результат. Следовательно, EF Core будет выполнять запрос как обычный. Затем он вернется к методу перехватчика Executed после выполнения. На этом этапе, если результат еще не является кэшируемым средством чтения, новый идентификатор сообщения и строка извлекаются из реального средства чтения и кэшируются для следующего использования этого запроса.

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

Совет

Вы можете скачать пример перехватчика SaveChanges из GitHub.

SaveChanges и SaveChangesAsync точки перехвата определяются интерфейсом ISaveChangesInterceptor . Что касается других перехватчиков, SaveChangesInterceptor базовый класс с методами no-op предоставляется в качестве удобства.

Совет

Перехватчики мощны. Однако во многих случаях может быть проще переопределить метод SaveChanges или использовать события .NET для SaveChanges , предоставляемые в DbContext.

Пример: перехват SaveChanges для аудита

SaveChanges можно перехватывать для создания независимой записи аудита внесенных изменений.

Примечание.

Это не предназначено для надежного решения аудита. Скорее это простой пример, используемый для демонстрации функций перехвата.

Контекст приложения

Пример аудита использует простой 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 зарегистрирован новый экземпляр перехватчика. Это связано с тем, что перехватчик аудита содержит состояние, связанное с текущим экземпляром контекста.

Контекст аудита

Пример также содержит вторую базу данных 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.SavingChanges и ISaveChangesInterceptor.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 вызывается. Обратите внимание, что асинхронная перегрузка может выполнять неблокирующие асинхронные операции ввода-вывода в базу данных аудита. Возможно, вы хотите создать исключение из метода синхронизации SavingChanges , чтобы убедиться, что все операции ввода-вывода базы данных являются асинхронным. Затем это требует, чтобы приложение всегда звонит SaveChangesAsync и никогда SaveChanges.

Сообщение аудита

Каждый метод перехватчика имеет 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 вставки, обновления или удаления. Затем перехватчик вставляет эти сущности в базу данных аудита.

Совет

ToString переопределяется в каждом классе данных событий EF Core, чтобы создать эквивалентное сообщение журнала для события. Например, при вызове ContextInitializedEventData.ToString возникает ошибка Entity Framework Core 5.0.0, инициализированная "BlogsContext" с помощью поставщика Microsoft.EntityFrameworkCore.Sqlite с параметрами: None.

Обнаружение успешного выполнения

Сущность аудита хранится на перехватчике, чтобы получить доступ к ней еще раз, как только 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;
}

Сущность аудита присоединена к контексту аудита, так как она уже существует в базе данных и должна быть обновлена. Затем мы задали Succeeded и EndTimeпомечаем эти свойства как измененные, поэтому 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'.