侦听器

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 的一部分或在创建 DbContextOptions 实例以传递给 DbContext 构造函数时调用。

小窍门

在使用 AddDbContext 或将 DbContextOptions 实例传递给 DbContext 构造函数时,仍会调用 OnConfiguring。 这样,无论如何构造 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 打开和关闭连接
连接失败
IDbTransactionInterceptor
创建事务
使用现有事务
提交事务
回滚事务
创建和使用保存点事务失败

基类 DbCommandInterceptorDbConnectionInterceptorDbTransactionInterceptor 包含相应接口中每个方法的 no-op 实现。 使用基类可避免实现未使用的拦截方法。

每种拦截器类型的方法成对出现,第一个在数据库操作开始前被调用,第二个在操作完成后调用。 例如, DbCommandInterceptor.ReaderExecuting 在执行查询之前调用,并在 DbCommandInterceptor.ReaderExecuted 查询发送到数据库后调用。

每对方法都有同步和异步变体。 这允许异步 I/O(例如请求访问令牌)作为拦截异步数据库操作的一部分进行。

示例:用于添加查询提示的命令拦截

小窍门

可以从 GitHub 下载命令拦截器示例

在将 SQL 发送到数据库之前,可以使用 IDbCommandInterceptor 来修改 SQL。 此示例演示如何修改 SQL 以包含查询提示。

通常,截获最棘手的部分是确定命令何时对应于需要修改的查询。 分析 SQL 是一个选项,但往往很脆弱。 另一个选项是使用 EF Core 查询标记 标记应修改的每个查询。 例如:

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

然后,可以在侦听器中检测此标记,因为它始终作为注释包含在命令文本的第一行中。 在检测标记时,将修改查询 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]

示例:使用 AAD 进行 SQL Azure 身份验证的连接拦截

小窍门

可以从 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 收到无法正确处理的异常结果,则 EF Core 的行为可能出乎意料。 此外,此示例演示拦截器概念;它不用作可靠的二级缓存实现的模板。

在此示例中,应用程序经常执行查询以获取最新的“每日消息”:

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

此查询 已标记 ,以便可以轻松在拦截器中检测到它。 其思路是每天只查询数据库一次新消息。 在其他情况下,应用程序将使用缓存的结果。 (此示例使用样本中的延迟 10 秒来模拟新的一天。

侦听器状态

此侦听器是有状态的:它存储查询的最新每日消息的 ID 和消息文本,以及执行该查询的时间。 由于此状态,我们还需要一个 ,因为缓存要求多个上下文实例必须使用相同的侦听器。

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

请注意,在日志输出中,应用程序继续使用缓存的消息,直到超时过期,此时再次查询数据库以获取任何新消息。

保存更改拦截

小窍门

可以从 GitHub 下载 SaveChanges 拦截器示例

SaveChangesSaveChangesAsync 截距点由 ISaveChangesInterceptor 接口定义。 至于其他拦截器,已经作为便利提供了带有 no-op 方法的 SaveChangesInterceptor 基类。

小窍门

拦截器很强大。 但是,在许多情况下,重写 SaveChanges 方法或使用 DbContext 上提供的 用于 SaveChanges 的 .NET 事件 可能会更简单。

示例:用于审核的 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.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 操作。 你可能希望从同步 SavingChanges 方法引发,以确保所有数据库 I/O 都是异步的。 然后,这要求应用程序始终调用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 实体集合,每个插入、更新或删除操作对应一个 EntityAudit 实体。 然后,侦听器将这些实体插入审核数据库中。

小窍门

ToString 方法在每个 EF Core 事件数据类中被重定义,以生成事件的相应日志消息。 例如,调用 ContextInitializedEventData.ToString 会生成“Entity Framework Core 5.0.0 使用提供程序‘Microsoft.EntityFrameworkCore.Sqlite’和选项‘无’初始化‘BlogsContext’”。

检测成功

审核实体存储在侦听器上,以便在 SaveChanges 成功或失败后再次访问它。 对于成功, ISaveChangesInterceptor.SavedChangesISaveChangesInterceptor.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 = await context.Blogs.Include(e => e.Posts).SingleAsync();

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

    await context.SaveChangesAsync();
}

// 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 await context.SaveChangesAudits.Include(e => e.Entities).ToListAsync())
    {
        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'.