共用方式為


攔截器

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 可以在 建立實例以傳遞至 DbCoNtext 建構函式時呼叫 為 或 的一 DbContextOptions 部分 AddDbContext

提示

使用 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 下載命令攔截器範例

IDbCommandInterceptor可用來在 SQL 傳送至資料庫之前修改 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]

範例:使用 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 權杖提供者。 視所要求的權杖類型而定,您可能需要在這裡實作自己的快取。

範例:用於快取的進階命令攔截

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 秒來模擬新的一天。

攔截器狀態

此攔截器具狀態:它會儲存最近查詢每日訊息的識別碼和郵件內文,以及執行該查詢的時間。 由於這種狀態,我們也需要 鎖定 ,因為快取需要多個內容實例必須使用相同的攔截器。

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 的方法。 此時,如果結果尚未快取讀取器,則會從實際讀取器擷取新的訊息識別碼和字串,並快取以供下一次使用此查詢。

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 介面所定義。 至於其他攔截器, SaveChangesInterceptor 具有 no-op 方法的基類會以方便的方式提供。

提示

攔截器很強大。 不過,在許多情況下,覆寫 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.SavingChangesAsyncISaveChangesInterceptor.SavingChanges 覆寫傳送至資料庫之前處理。

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 實體,每個插入、更新或刪除各一個。 攔截器接著會將這些實體插入稽核資料庫中。

提示

ToString 會在每個 EF Core 事件資料類別中覆寫,以產生事件的對等記錄訊息。 例如,呼叫 ContextInitializedEventData.ToString 會產生 「Entity Framework Core 5.0.0 使用提供者 'Microsoft.EntityFrameworkCore.Sqlite' 來初始化 'BlogsCoNtext',且選項為:None」。

偵測成功

稽核實體會儲存在攔截器上,以便在 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.SaveChangesFailedAsync 方法中 ISaveChangesInterceptor.SaveChangesFailed 。 事件資料包含擲回的例外狀況。

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'.