攔截器

Entity Framework Core (EF Core) 攔截器可讓您攔截、修改及/或隱藏 EF Core 作業。 這包括低階資料庫作業,例如執行命令,以及較高層級的作業,例如呼叫 SaveChanges。

攔截器與記錄和診斷不同,因為它們允許修改或抑制正在攔截的作業。 簡單記錄 或 Microsoft.Extensions.Logging 是進行記錄的更佳選擇。

設定 DbContext 時,會針對每個 DbContext 實例註冊攔截器。 使用 診斷接聽程式 來取得相同的資訊,但針對進程中的所有 DbContext 實例。

可用的攔截機

下表顯示可用的攔截器介面:

攔截器 攔截行動 Singleton
IDbCommandInterceptor 建立命令
執行命令
命令失敗
釋放命令的 DbDataReader
否
IDbConnectionInterceptor 開啟與關閉連接
建立連接
連接失敗
否
IDbTransactionInterceptor 建立交易 使用現有的交易

認可交易 回復交易

建立和使用儲存點
交易失敗
否
ISaveChangesInterceptor SavingChanges/SavedChanges
SaveChanges Optimistic 並行處理失敗
否
IMaterializationInterceptor 從查詢結果建立、初始化及最終化實體實例 Yes
IQueryExpressionInterceptor 在查詢編譯前修改 LINQ 表達式樹 Yes
IIdentityResolutionInterceptor 在追蹤實體時解決身份衝突 Yes

註冊攔截器

設定 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 會針對每個介面適當地導向事件。

單例攔截器

部分攔截器實作 ISingletonInterceptor (見上表);這些攔截器在 EF Core 內部服務提供者中註冊為單例服務,意即同一實例在所有 DbContext 使用同一服務提供者的實例間共享。

由於 singleton 攔截器成為 EF Core 內部服務配置的一部分,每個不同的攔截器實例都會促使構建新的內部服務提供者。 每次配置時(例如在DbContext中)傳遞一個AddDbContext單例攔截器,最終會觸發ManyServiceProvidersCreatedWarning並降低效能。

警告

所有實例都一定要重複使用同一個單例攔截器實例 DbContext 。 不要每次設定上下文時都建立新實例。

例如,以下說明 不正確 ,因為每個上下文配置都會建立一個新的攔截器實例:

// Don't do this! A new instance each time causes a new internal service provider to be built.
services.AddDbContext<CustomerContext>(
    b => b.UseSqlServer(connectionString)
          .AddInterceptors(new MyMaterializationInterceptor()));

相反地,重複使用同一個實例:

// Correct: reuse a single interceptor instance
var interceptor = new MyMaterializationInterceptor();
services.AddDbContext<CustomerContext>(
    b => b.UseSqlServer(connectionString)
          .AddInterceptors(interceptor));

或者使用靜態場:

public class CustomerContext : DbContext
{
    private static readonly MyMaterializationInterceptor _interceptor = new();

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

由於這些攔截器是單件型,必須具備線程安全。 它們通常不應該保持可變的狀態。 如果你需要存取有範圍的服務(例如目前的 DbContext),請使用傳遞至每個攔截器方法的事件資料中的 Context 或類似的屬性。

資料庫攔截

備註

資料庫攔截僅適用於關係資料庫提供者。

低階資料庫攔截會分割成下表所示的三個介面。

攔截器 資料庫作業被攔截
IDbCommandInterceptor 建立命令
執行命令
命令失敗
釋放命令的 DbDataReader
IDbConnectionInterceptor 開啟與關閉連接
建立連接
連接失敗
IDbTransactionInterceptor 建立交易 使用現有的交易

認可交易 回復交易

建立和使用儲存點
交易失敗

基類 DbCommandInterceptor、 DbConnectionInterceptor和 DbTransactionInterceptor 包含對應介面中每個方法的 no-op 實作。 使用基類來避免需要實作未使用的攔截方法。

每個攔截器類型上的方法都會成對,第一個是在資料庫作業啟動之前呼叫,第二個是在作業完成之後呼叫。 例如, DbCommandInterceptor.ReaderExecuting 在執行查詢之前呼叫 ,並在 DbCommandInterceptor.ReaderExecuted 查詢傳送至資料庫之後呼叫。

每組方法都有同步和異步的變體。 這可讓異步 I/O,例如要求存取令牌,在攔截異步資料庫作業時發生。

範例:命令攔截以新增查詢提示

小提示

您可以從 GitHub 下載命令攔截器範例 。

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

範例: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 令牌提供者可能不會自動快取存取令牌。 根據您所要求的令牌類型,您可能需要在此自行實現快取功能。

範例:連接字串的延遲初始化

連接字串通常是從組態檔讀取的靜態資產。 設定UseSqlServer時,這些可以輕鬆地傳遞給DbContext或類似的專案。 不過,有時候連接字串會因每個內容實例而改變。 例如,多租用戶系統中的每個租戶可能有不同的連接字串。

IDbConnectionInterceptor可以用來處理動態連接和連接字串。 一開始便能夠在不需要任何連接字串的情況下設定 DbContext。 例如:

services.AddDbContext<CustomerContext>(
    b => b.UseSqlServer());

然後,可以實作其中 IDbConnectionInterceptor 一種方法來設定連接,然後再使用它。 ConnectionOpeningAsync是不錯的選擇,因為它可以執行異步操作來取得 連接字串、尋找存取令牌等等。 例如,假設服務是針對當前請求並能夠理解當前租戶的。

services.AddScoped<ITenantConnectionStringFactory, TestTenantConnectionStringFactory>();

警告

每次需要它時,執行連接字串、存取令牌或類似項目的異步查詢可能會非常慢。 考慮將這些項目快取,並只定期重新整理快取的字串或令牌。 例如,存取令牌通常可在需要重新整理之前使用一段相當長的時間。

這可以使用建構函式注入來注入每個 DbContext 實例:

public class CustomerContext : DbContext
{
    private readonly ITenantConnectionStringFactory _connectionStringFactory;

    public CustomerContext(
        DbContextOptions<CustomerContext> options,
        ITenantConnectionStringFactory connectionStringFactory)
        : base(options)
    {
        _connectionStringFactory = connectionStringFactory;
    }

    // ...
}

接著,此服務會在為上下文建構攔截器實作時使用:

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    => optionsBuilder.AddInterceptors(
        new ConnectionStringInitializationInterceptor(_connectionStringFactory));

最後,攔截器會使用此服務以非同步方式取得連接字串,並在第一次使用連接時進行設定:

public class ConnectionStringInitializationInterceptor : DbConnectionInterceptor
{
    private readonly ITenantConnectionStringFactory _connectionStringFactory;

    public ConnectionStringInitializationInterceptor(ITenantConnectionStringFactory connectionStringFactory)
    {
        _connectionStringFactory = connectionStringFactory;
    }

    public override InterceptionResult ConnectionOpening(
        DbConnection connection,
        ConnectionEventData eventData,
        InterceptionResult result)
        => throw new NotSupportedException("Synchronous connections not supported.");

    public override async ValueTask<InterceptionResult> ConnectionOpeningAsync(
        DbConnection connection, ConnectionEventData eventData, InterceptionResult result,
        CancellationToken cancellationToken = new())
    {
        if (string.IsNullOrEmpty(connection.ConnectionString))
        {
            connection.ConnectionString = (await _connectionStringFactory.GetConnectionStringAsync(cancellationToken));
        }

        return result;
    }
}

備註

連接字串 只會在第一次使用連接時取得。 之後,儲存在 DbConnection 上的連接字串將會直接使用,不需要查閱新的連接字串。

小提示

此攔截器會覆寫要擲回的非異步ConnectionOpening方法,因為必須從異步程式代碼路徑呼叫服務以取得 連接字串。

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

小提示

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

攔截器狀態

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

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

請注意,從記錄輸出中,應用程式會繼續使用快取的訊息,直到逾時到期為止,此時會再次查詢資料庫以取得任何新訊息。

範例:記錄 SQL Server 查詢統計

此範例展示了兩個攔截器協同工作,將 SQL Server 查詢統計資料傳送至應用程式日誌。 若要產生統計數據,我們需要 IDbCommandInterceptor 執行兩件事。

首先,攔截器會使用 SET STATISTICS IO ON前置命令,這會告訴 SQL Server 在取用結果集之後,將統計數據傳送給用戶端:

public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
    DbCommand command,
    CommandEventData eventData,
    InterceptionResult<DbDataReader> result,
    CancellationToken cancellationToken = default)
{
    command.CommandText = "SET STATISTICS IO ON;" + Environment.NewLine + command.CommandText;

    return new(result);
}

其次,攔截者會實作 DataReaderClosingAsync 方法,此方法在 DbDataReader 完成結果消費後,且在其被關閉 之前 呼叫。 當 SQL Server 傳送統計數據時,它會將它們放在讀取器上的第二個結果中,因此此時攔截器會藉由呼叫 NextResultAsync 來將統計數據填入連線,來讀取該結果。

public override async ValueTask<InterceptionResult> DataReaderClosingAsync(
    DbCommand command,
    DataReaderClosingEventData eventData,
    InterceptionResult result)
{
    await eventData.DataReader.NextResultAsync();

    return result;
}

需要第二個攔截器,才能從連線取得統計數據,並將其寫出至應用程式的記錄器。 為此,我們將使用一個 IDbConnectionInterceptor,實作該 ConnectionCreated 方法。 ConnectionCreated 會在 EF Core 建立連線之後立即呼叫 ,因此可用來執行該連線的其他組態。 在此情況下,攔截器會取得 ILogger,然後掛鈎到事件 SqlConnection.InfoMessage 以記錄訊息。

public override DbConnection ConnectionCreated(ConnectionCreatedEventData eventData, DbConnection result)
{
    var logger = eventData.Context!.GetService<ILoggerFactory>().CreateLogger("InfoMessageLogger");
    ((SqlConnection)eventData.Connection).InfoMessage += (_, args) =>
    {
        logger.LogInformation(1, args.Message);
    };
    return result;
}

這很重要

只有在 EF Core 建立 ConnectionCreating 時,才會呼叫 ConnectionCreated 和 DbConnection 方法。 如果應用程式建立 DbConnection 並將它傳遞至 EF Core,則不會呼叫它們。

依指令來源篩選

CommandEventData提供給診斷的來源與攔截器包含CommandSource一個屬性,指示 EF 中哪個部分負責創建該指令。 這可作為攔截器中的濾波器使用。 例如,我們可能想要一個僅適用於來自 SaveChanges 的命令的攔截器:

public class CommandSourceInterceptor : DbCommandInterceptor
{
    public override InterceptionResult<DbDataReader> ReaderExecuting(
        DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
    {
        if (eventData.CommandSource == CommandSource.SaveChanges)
        {
            Console.WriteLine($"Saving changes for {eventData.Context!.GetType().Name}:");
            Console.WriteLine();
            Console.WriteLine(command.CommandText);
        }

        return result;
    }
}

SaveChanges 攔截

小提示

您可以從 GitHub 下載 SaveChanges 攔截器範例 。

SaveChanges 和 SaveChangesAsync 攔截點是由 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.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 都會進行稽核。 另請注意,異步多載本身能夠對稽核資料庫執行非封鎖的異步 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.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 = 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'.

範例:樂觀並行攔截

EF Core 支援 開放式並行模式 ,方法是檢查實際受更新或刪除影響的數據列數目與預期受影響的數據列數目相同。 這通常與併發令牌結合。也就是說,只有在讀取預期值後,列的值沒有被更新時,才能符合其預期值。

EF 會拋出 DbUpdateConcurrencyException來發出違反樂觀併發控制的訊號。 ISaveChangesInterceptor 擁有方法 ThrowingConcurrencyException 和 ThrowingConcurrencyExceptionAsync,這些方法會在拋出 DbUpdateConcurrencyException 之前被調用。 這些攔截點允許隱藏例外狀況,可能加上異步資料庫變更來解決違規。

例如,如果兩個要求幾乎同時嘗試刪除相同的實體,則第二個刪除可能會失敗,因為資料庫中的數據列已不存在。 這可能是可接受的--最終結果是實體已被刪除。 下列攔截器示範如何完成此動作:

public class SuppressDeleteConcurrencyInterceptor : ISaveChangesInterceptor
{
    public InterceptionResult ThrowingConcurrencyException(
        ConcurrencyExceptionEventData eventData,
        InterceptionResult result)
    {
        if (eventData.Entries.All(e => e.State == EntityState.Deleted))
        {
            Console.WriteLine("Suppressing Concurrency violation for command:");
            Console.WriteLine(((RelationalConcurrencyExceptionEventData)eventData).Command.CommandText);

            return InterceptionResult.Suppress();
        }

        return result;
    }

    public ValueTask<InterceptionResult> ThrowingConcurrencyExceptionAsync(
        ConcurrencyExceptionEventData eventData,
        InterceptionResult result,
        CancellationToken cancellationToken = default)
        => new(ThrowingConcurrencyException(eventData, result));
}

關於此攔截器,有幾件事值得注意:

  • 同時實作同步和異步攔截方法。 如果應用程式可以呼叫 SaveChanges 或 SaveChangesAsync,則這很重要。 不過,如果所有應用程式程式代碼都是異步的,則只需要 ThrowingConcurrencyExceptionAsync 實作。 同樣地,如果應用程式永遠不會使用異步資料庫方法,則只需要 ThrowingConcurrencyException 實作。 這通常適用於具有同步處理和異步方法的所有攔截器。
  • 攔截器可以存取要儲存之實體相關的EntityEntry物件。 在此情況下,這會用來檢查刪除作業是否發生並行違規。
  • 如果應用程式使用關係資料庫提供者,則可以將 ConcurrencyExceptionEventData 物件轉換成 RelationalConcurrencyExceptionEventData 物件。 這會提供有關所執行之資料庫作業的其他關係型特定資訊。 在此情況下,關係型命令文字會列印至主控台。
  • 傳回 InterceptionResult.Suppress() 告訴 EF Core 抑制其即將執行的動作,在此案例中,丟出 DbUpdateConcurrencyException。 這項能力來變更 EF Core 的行為,而不僅僅是觀察 EF Core 的操作,是攔截器最強大的功能之一。

實體化攔截

IMaterializationInterceptor 支援在建立實體實例前後攔截,以及該實例屬性初始化前後的攔截。 攔截器可以在每個階段變更或取代實體實例。 這讓:

  • 設定未映射的屬性或方法調用,以用於驗證、計算值或旗標。
  • 使用工廠建立實例。
  • 建立與 EF 通常建立的不同的實體實例,例如從快取中取得的實例或代理類型的實例。
  • 將服務插入實體實例。

備註

IMaterializationInterceptor 是單例攔截器,意即所有實例共用 DbContext 單一實例。

範例:實體建立時的簡單操作

想像我們想追蹤一個實體從資料庫中被檢索的時間,或許是為了讓編輯資料的使用者能夠看到它。 為了達成此目的,我們會先定義介面:

public interface IHasRetrieved
{
    DateTime Retrieved { get; set; }
}

使用介面與攔截器很常見,因為它允許相同的攔截器使用許多不同的實體類型。 例如:

public class Customer : IHasRetrieved
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public string? PhoneNumber { get; set; }

    [NotMapped]
    public DateTime Retrieved { get; set; }
}

請注意, [NotMapped] 屬性是用來指出只有在使用實體時才會使用這個屬性,而且不應該保存至資料庫。

攔截器接著必須實作合適的方法IMaterializationInterceptor,並設定擷取時間。

public class SetRetrievedInterceptor : IMaterializationInterceptor
{
    public object InitializedInstance(MaterializationInterceptionData materializationData, object instance)
    {
        if (instance is IHasRetrieved hasRetrieved)
        {
            hasRetrieved.Retrieved = DateTime.UtcNow;
        }
        
        return instance;
    }
}

配置DbContext時,會註冊此攔截器的實例。

public class CustomerContext : DbContext
{
    private static readonly SetRetrievedInterceptor _setRetrievedInterceptor = new();
    
    public DbSet<Customer> Customers => Set<Customer>();

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) 
        => optionsBuilder
            .AddInterceptors(_setRetrievedInterceptor)
            .UseSqlite("Data Source = customers.db");
}

小提示

此攔截器是無狀態的,這是常見的,因此會建立單一實例,並在所有 DbContext 實例之間共用。

現在,每當從資料庫查詢 Customer 時,Retrieved 屬性將會自動設定。 例如:

await using (var context = new CustomerContext())
{
    var customer = await context.Customers.SingleAsync(e => e.Name == "Alice");
    Console.WriteLine($"Customer '{customer.Name}' was retrieved at '{customer.Retrieved.ToLocalTime()}'");
}

生成結果:

Customer 'Alice' was retrieved at '9/22/2022 5:25:54 PM'

範例:將服務注入實體

EF Core 已內建支援將一些特殊服務插入內容實例;例如,請參閱 不使用 Proxy 的延遲載入,其可藉由插入 ILazyLoader 服務來運作。

IMaterializationInterceptor可用來將此一般化為任何服務。 下列範例示範如何將ILogger插入實體中,以便它們可以執行自己的記錄。

備註

將服務插入實體會將這些實體類型與插入的服務結合,而有些人認為這是反模式。

和之前一樣,介面是用來定義可以執行的動作。

public interface IHasLogger
{
    ILogger? Logger { get; set; }
}

而將記錄的實體類型必須實作這個介面。 例如:

public class Customer : IHasLogger
{
    private string? _phoneNumber;

    public int Id { get; set; }
    public string Name { get; set; } = null!;

    public string? PhoneNumber
    {
        get => _phoneNumber;
        set
        {
            Logger?.LogInformation(1, $"Updating phone number for '{Name}' from '{_phoneNumber}' to '{value}'.");

            _phoneNumber = value;
        }
    }

    [NotMapped]
    public ILogger? Logger { get; set; }
}

這次,攔截器必須實作 IMaterializationInterceptor.InitializedInstance,該方法會在每個實體實例建立並初始化屬性值後被呼叫。 攔截器會從內容取得 ILogger ,並使用它初始化 IHasLogger.Logger :

public class LoggerInjectionInterceptor : IMaterializationInterceptor
{
    private ILogger? _logger;

    public object InitializedInstance(MaterializationInterceptionData materializationData, object instance)
    {
        if (instance is IHasLogger hasLogger)
        {
            _logger ??= materializationData.Context.GetService<ILoggerFactory>().CreateLogger("CustomersLogger");
            hasLogger.Logger = _logger;
        }

        return instance;
    }
}

這次針對每個 DbContext 實例使用攔截器的新實例,因為每個 ILogger 實例的 DbContext 可能不同,而且 ILogger 被快取在攔截器上。

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    => optionsBuilder.AddInterceptors(new LoggerInjectionInterceptor());

現在,每當 Customer.PhoneNumber 變更時,此變更就會記錄至應用程式的記錄檔。 例如:

info: CustomersLogger[1]
      Updating phone number for 'Alice' from '+1 515 555 0123' to '+1 515 555 0125'.

查詢表達式攔截

IQueryExpressionInterceptor 允許在查詢編譯前攔截查詢的 LINQ 表達樹 。 這可以用來動態修改查詢,並適用於整個應用程式。

備註

IQueryExpressionInterceptor 是單例攔截器,意即單一實例通常在所有 DbContext 實例間共享。

警告

攔截器很強大,但在使用表達樹時很容易出錯。 時常考慮是否有更簡單的方式達成你的需求,例如直接修改查詢。

範例:在查詢中注入排序以實現穩定排序

考慮一種回傳一頁客戶的方法:

Task<List<Customer>> GetPageOfCustomers(string sortProperty, int page)
{
    using var context = new CustomerContext();

    return context.Customers
        .OrderBy(e => EF.Property<object>(e, sortProperty))
        .Skip(page * 20).Take(20).ToListAsync();
}

小提示

此查詢會使用 EF.Property 方法來指定要排序的屬性。 這可讓應用程式動態傳入屬性名稱,允許依實體類型的任何屬性排序。 請注意,依非索引欄位排序可能會變慢。

只要用於排序的 屬性一律會傳回穩定的排序,這樣就能正常運作。 但情況可能並不總是如此。 例如,上述 LINQ 查詢在 SQLite 上依 Customer.City 排序時會產生下列結果:

SELECT "c"."Id", "c"."City", "c"."Name", "c"."PhoneNumber"
FROM "Customers" AS "c"
ORDER BY "c"."City"
LIMIT @__p_1 OFFSET @__p_0

如果有多個客戶具有相同 City,則此查詢的排序並不穩定。 當使用者在資料分頁時,這可能會導致遺漏或重複的結果。

修正此問題的常見方法是依主鍵執行次要排序。 然而,攔截器並非手動在每個查詢中加入此項,而是可以動態地加入次級排序。 為了促進此目標,我們為任何擁有整數主鍵的實體定義了介面:

public interface IHasIntKey
{
    int Id { get; }
}

此介面是由感興趣的實體類型所實作:

public class Customer : IHasIntKey
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public string? City { get; set; }
    public string? PhoneNumber { get; set; }
}

接著我們需要一個攔截器實現 IQueryExpressionInterceptor:

public class KeyOrderingExpressionInterceptor : IQueryExpressionInterceptor
{
    public Expression QueryCompilationStarting(Expression queryExpression, QueryExpressionEventData eventData)
        => new KeyOrderingExpressionVisitor().Visit(queryExpression);

    private class KeyOrderingExpressionVisitor : ExpressionVisitor
    {
        private static readonly MethodInfo ThenByMethod
            = typeof(Queryable).GetMethods()
                .Single(m => m.Name == nameof(Queryable.ThenBy) && m.GetParameters().Length == 2);

        protected override Expression VisitMethodCall(MethodCallExpression? methodCallExpression)
        {
            var methodInfo = methodCallExpression!.Method;
            if (methodInfo.DeclaringType == typeof(Queryable)
                && methodInfo.Name == nameof(Queryable.OrderBy)
                && methodInfo.GetParameters().Length == 2)
            {
                var sourceType = methodCallExpression.Type.GetGenericArguments()[0];
                if (typeof(IHasIntKey).IsAssignableFrom(sourceType))
                {
                    var lambdaExpression = (LambdaExpression)((UnaryExpression)methodCallExpression.Arguments[1]).Operand;
                    var entityParameterExpression = lambdaExpression.Parameters[0];

                    return Expression.Call(
                        ThenByMethod.MakeGenericMethod(
                            sourceType,
                            typeof(int)),
                        methodCallExpression,
                        Expression.Lambda(
                            typeof(Func<,>).MakeGenericType(entityParameterExpression.Type, typeof(int)),
                            Expression.Property(entityParameterExpression, nameof(IHasIntKey.Id)),
                            entityParameterExpression));
                }
            }

            return base.VisitMethodCall(methodCallExpression);
        }
    }
}

這看起來可能相當複雜,而且是! 使用表示式樹通常來說不容易。 讓我們看看發生了什麼事:

  • 基本上,攔截器會封裝ExpressionVisitor。 訪客會覆寫 VisitMethodCall,每當查詢表達式樹中有方法被呼叫時,就會調用此方法。

  • 訪客會檢查這是否是我們感興趣的方法呼叫 OrderBy 。

  • 如果是,則訪問者會進一步檢查泛型方法的呼叫是否為實作我們的介面 IHasIntKey 的類型。

  • 此時,我們知道方法呼叫的格式 OrderBy(e => ...)為 。 我們會從這個呼叫擷取 Lambda 運算式,並取得該運算式中使用的參數,也就是 e。

  • 我們現在使用MethodCallExpression建立器方法來建置新的 Expression.Call 。 在這裡情況下,所呼叫的方法為 ThenBy(e => e.Id)。 我們會使用上述擷取的參數,以及介面屬性Id的屬性存取IHasIntKey來建置這個參數。

  • 這個呼叫的輸入是原始的 OrderBy(e => ...),因此最終結果是 OrderBy(e => ...).ThenBy(e => e.Id) 的表達式。

  • 這個修改過的表達式會從訪客傳回,這表示LINQ查詢現在已經過適當修改以包含 ThenBy 呼叫。

  • EF Core 會持續進行,並將查詢語句編譯為所用資料庫的相應 SQL。

註冊此攔截器並執行 GetPageOfCustomers 後,會產生以下 SQL:

SELECT "c"."Id", "c"."City", "c"."Name", "c"."PhoneNumber"
FROM "Customers" AS "c"
ORDER BY "c"."City", "c"."Id"
LIMIT @__p_1 OFFSET @__p_0

這現在一律會產生穩定的排序,即使有多個客戶具有相同的City也一樣。

在許多情況下,直接修改查詢也能更簡單地達成同樣的效果。 例如:

Task<List<Customer>> GetPageOfCustomers2(string sortProperty, int page)
{
    using var context = new CustomerContext();

    return context.Customers
        .OrderBy(e => EF.Property<object>(e, sortProperty))
        .ThenBy(e => e.Id)
        .Skip(page * 20).Take(20).ToListAsync();
}

在此情況下,只需將 ThenBy 新增至查詢。 是,可能需要單獨對每個查詢進行操作,但方法簡單、容易理解,並且肯定可行。

身份解析攔截

IIdentityResolutionInterceptor 允許攔截識別解析衝突,當 DbContext 開始追蹤新實體實例時。

備註

目前僅在使用DbContext.Update、DbContext.Attach和類似方法時,才會呼叫此攔截器,以追蹤已經透過相同鍵追蹤的實體。 查詢回傳的實體不會被調用此功能。 未來版本可能會改變;請參見本期。

DbContext只能追蹤具有任何指定主鍵值的一個實體實例。 這表示必須將具有相同鍵值的實體的多個實例整合為單一實例。 此類攔截器會同時呼叫既有追蹤實例與新實例,並必須將新實例的任何屬性值與關係變更套用到現有實例中。 新實例隨後被丟棄。

EF Core 提供內建實作, UpdatingIdentityResolutionInterceptor該實作會根據新實例的值更新已追蹤實體。 在配置上下文時可以進行註冊:

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    => optionsBuilder
        .AddInterceptors(new UpdatingIdentityResolutionInterceptor());

要實作自訂身份解析邏輯,請建立一個類別來實作 IIdentityResolutionInterceptor 並覆寫 UpdateTrackedInstance 方法:

public class CustomIdentityResolutionInterceptor : IIdentityResolutionInterceptor
{
    public void UpdateTrackedInstance(
        IdentityResolutionInterceptionData interceptionData,
        EntityEntry existingEntry,
        object newEntity)
    {
        // Custom logic to merge property values from newEntity into the existing tracked entity
        existingEntry.CurrentValues.SetValues(newEntity);
    }
}