共用方式為


EF Core 中的身分識別解析

DbContext只能追蹤具有任何指定主鍵值的一個實體實例。 這表示必須解析具有相同索引鍵值之實體的多個實例到單一實例。 這稱為「身分識別解析」。 身分識別解析可確保 Entity Framework Core (EF Core) 追蹤一致的圖表,且實體的關聯性或屬性值沒有模棱兩可。

提示

本檔假設瞭解實體狀態和 EF Core 變更追蹤的基本概念。 如需這些主題的詳細資訊,請參閱 EF Core 中的 變更追蹤。

提示

您可以從 GitHub 下載範例程式碼,以執行並偵錯此文件中的所有程式碼。

簡介

下列程式代碼會查詢實體,然後嘗試附加具有相同主鍵值的不同實例:

using var context = new BlogsContext();

var blogA = context.Blogs.Single(e => e.Id == 1);
var blogB = new Blog { Id = 1, Name = ".NET Blog (All new!)" };

try
{
    context.Update(blogB); // This will throw
}
catch (Exception e)
{
    Console.WriteLine($"{e.GetType().FullName}: {e.Message}");
}

執行此程式代碼會導致下列例外狀況:

System.InvalidOperationException:無法追蹤實體類型 'Blog' 的實例,因為已追蹤具有索引鍵值 '{Id: 1}' 的另一個實例。 附加現有實體時,請確定只附加一個具有指定索引鍵值的實體實例。

EF Core 需要單一實例,因為:

  • 屬性值在多個實例之間可能不同。 更新資料庫時,EF Core 必須知道要使用的屬性值。
  • 與其他實體的關聯性在多個實例之間可能不同。 例如,“blogA” 可能與 “blogB” 不同的文章集合相關。

上述例外狀況常見於下列情況:

  • 嘗試更新實體時
  • 嘗試追蹤實體的串行化圖形時
  • 無法設定未自動產生的索引鍵值時
  • 重複使用多個工作單位的 DbContext 實例時

下列各節將討論上述每個情況。

更新實體

有數種不同的方法可更新具有新值的實體,如 EF Core明確追蹤實體 變更追蹤 中所述。 這些方法會在身分識別解析的內容中概述。 請注意,每個方法都會使用查詢或呼叫 其中UpdateAttach一個 或 ,但絕對不會同時使用這兩種方法。

呼叫更新

更新的實體通常不是來自我們將用於 SaveChanges 的 DbContext 查詢。 例如,在 Web 應用程式中,可以從 POST 要求中的資訊建立實體實例。 處理這個最簡單方式是使用 DbContext.UpdateDbSet<TEntity>.Update。 例如:

public static void UpdateFromHttpPost1(Blog blog)
{
    using var context = new BlogsContext();

    context.Update(blog);

    context.SaveChanges();
}

在此案例中:

  • 只會建立實體的單一實例。
  • 在進行更新時,不會從資料庫查詢實體實例
  • 不論屬性值是否已實際變更,都會更新資料庫中的所有屬性值。
  • 進行一次資料庫來回行程。

然後查詢套用變更

通常,當實體從 POST 要求或類似要求中的資訊建立實體時,通常不知道哪些屬性值實際上已經變更。 通常只要更新資料庫中的所有值,就像我們在上一個範例中所做的一樣。 不過,如果應用程式正在處理許多實體,而且只有少數實體有實際變更,則限制傳送的更新可能會很有用。 執行查詢來追蹤資料庫中目前存在的實體,然後將變更套用至這些追蹤實體,即可達成此目的。 例如:

public static void UpdateFromHttpPost2(Blog blog)
{
    using var context = new BlogsContext();

    var trackedBlog = context.Blogs.Find(blog.Id);

    trackedBlog.Name = blog.Name;
    trackedBlog.Summary = blog.Summary;

    context.SaveChanges();
}

在此案例中:

  • 只會追蹤實體的單一實例;查詢從資料庫傳回的 Find
  • Update使用、 Attach
  • 資料庫中只會更新實際變更的屬性值。
  • 進行兩次資料庫來回行程。

EF Core 有一些協助程式可傳輸這類屬性值。 例如, PropertyValues.SetValues 會從指定的物件複製所有值,並在追蹤的物件上設定這些值:

public static void UpdateFromHttpPost3(Blog blog)
{
    using var context = new BlogsContext();

    var trackedBlog = context.Blogs.Find(blog.Id);

    context.Entry(trackedBlog).CurrentValues.SetValues(blog);

    context.SaveChanges();
}

SetValues 接受各種物件類型,包括具有符合實體類型屬性的屬性名稱的數據傳輸物件(DTO)。 例如:

public static void UpdateFromHttpPost4(BlogDto dto)
{
    using var context = new BlogsContext();

    var trackedBlog = context.Blogs.Find(dto.Id);

    context.Entry(trackedBlog).CurrentValues.SetValues(dto);

    context.SaveChanges();
}

或具有屬性值名稱/值專案的字典:

public static void UpdateFromHttpPost5(Dictionary<string, object> propertyValues)
{
    using var context = new BlogsContext();

    var trackedBlog = context.Blogs.Find(propertyValues["Id"]);

    context.Entry(trackedBlog).CurrentValues.SetValues(propertyValues);

    context.SaveChanges();
}

如需使用這類屬性值的詳細資訊,請參閱 存取追蹤實體

使用原始值

到目前為止,無論它們是否已變更,每個方法都已在進行更新之前執行查詢,或更新所有屬性值。 若要只更新未在更新期間查詢而變更的值,需要哪些屬性值已變更的特定資訊。 取得此資訊的常見方式是將 HTTP Post 或類似的目前和原始值傳送回。 例如:

public static void UpdateFromHttpPost6(Blog blog, Dictionary<string, object> originalValues)
{
    using var context = new BlogsContext();

    context.Attach(blog);
    context.Entry(blog).OriginalValues.SetValues(originalValues);

    context.SaveChanges();
}

在此程式代碼中,已修改值的實體會先附加。 這會導致 EF Core 追蹤狀態中的 Unchanged 實體;也就是說,沒有標示為修改的屬性值。 原始值的字典接著會套用至這個追蹤的實體。 這會以不同的目前和原始值標示為已修改的屬性。 具有相同目前和原始值的屬性將不會標示為已修改。

在此案例中:

  • 使用 Attach 只會追蹤實體的單一實例。
  • 在進行更新時,不會從資料庫查詢實體實例
  • 套用原始值可確保資料庫中只會更新實際變更的屬性值。
  • 進行一次資料庫來回行程。

如同上一節中的範例,原始值不需要傳遞為字典;實體實例或 DTO 也會運作。

提示

雖然此方法具有吸引人的特性,但確實需要將實體的原始值傳送至 Web 用戶端和從 Web 用戶端傳送。 仔細考慮這個額外的複雜度是否值得優點:對於許多應用程式而言,其中一個較簡單的方法更為務實。

附加串行化圖形

EF Core 使用透過外鍵和導覽屬性連線的實體圖表,如變更外鍵和導覽中所述。 例如,如果這些圖表是在 EF Core 外部建立,例如從 JSON 檔案建立,則它們可以有多個相同實體的實例。 這些重複項目必須先解析成單一實例,才能追蹤圖形。

沒有重複項目的圖表

在進一步進行之前,請務必先認識到:

  • 串行化程式通常會有處理圖形中循環和重複實例的選項。
  • 做為圖形根目錄的物件選擇通常有助於減少或移除重複專案。

可能的話,請使用串行化選項並選擇不會導致重複的根目錄。 例如,下列程式代碼會使用 Json.NET 來串行化每個部落格清單及其相關聯的文章:

using var context = new BlogsContext();

var blogs = context.Blogs.Include(e => e.Posts).ToList();

var serialized = JsonConvert.SerializeObject(
    blogs,
    new JsonSerializerSettings { ReferenceLoopHandling = ReferenceLoopHandling.Ignore, Formatting = Formatting.Indented });

Console.WriteLine(serialized);

從此程式代碼產生的 JSON 為:

[
  {
    "Id": 1,
    "Name": ".NET Blog",
    "Summary": "Posts about .NET",
    "Posts": [
      {
        "Id": 1,
        "Title": "Announcing the Release of EF Core 5.0",
        "Content": "Announcing the release of EF Core 5.0, a full featured cross-platform...",
        "BlogId": 1
      },
      {
        "Id": 2,
        "Title": "Announcing F# 5",
        "Content": "F# 5 is the latest version of F#, the functional programming language...",
        "BlogId": 1
      }
    ]
  },
  {
    "Id": 2,
    "Name": "Visual Studio Blog",
    "Summary": "Posts about Visual Studio",
    "Posts": [
      {
        "Id": 3,
        "Title": "Disassembly improvements for optimized managed debugging",
        "Content": "If you are focused on squeezing out the last bits of performance for your .NET service or...",
        "BlogId": 2
      },
      {
        "Id": 4,
        "Title": "Database Profiling with Visual Studio",
        "Content": "Examine when database queries were executed and measure how long the take using...",
        "BlogId": 2
      }
    ]
  }
]

請注意,JSON 中沒有重複的部落格或文章。 這表示 對的簡單呼叫 Update 將可運作,以更新資料庫中的這些實體:

public static void UpdateBlogsFromJson(string json)
{
    using var context = new BlogsContext();

    var blogs = JsonConvert.DeserializeObject<List<Blog>>(json);

    foreach (var blog in blogs)
    {
        context.Update(blog);
    }

    context.SaveChanges();
}

處理重複專案

上一個範例中的程式碼會將其相關聯的文章串行化每個部落格。 如果這會變更為使用其相關聯的部落格來串行化每個文章,則會將重複專案導入串行化 JSON 中。 例如:

using var context = new BlogsContext();

var posts = context.Posts.Include(e => e.Blog).ToList();

var serialized = JsonConvert.SerializeObject(
    posts,
    new JsonSerializerSettings { ReferenceLoopHandling = ReferenceLoopHandling.Ignore, Formatting = Formatting.Indented });

Console.WriteLine(serialized);

串行化的 JSON 現在看起來像這樣:

[
  {
    "Id": 1,
    "Title": "Announcing the Release of EF Core 5.0",
    "Content": "Announcing the release of EF Core 5.0, a full featured cross-platform...",
    "BlogId": 1,
    "Blog": {
      "Id": 1,
      "Name": ".NET Blog",
      "Summary": "Posts about .NET",
      "Posts": [
        {
          "Id": 2,
          "Title": "Announcing F# 5",
          "Content": "F# 5 is the latest version of F#, the functional programming language...",
          "BlogId": 1
        }
      ]
    }
  },
  {
    "Id": 2,
    "Title": "Announcing F# 5",
    "Content": "F# 5 is the latest version of F#, the functional programming language...",
    "BlogId": 1,
    "Blog": {
      "Id": 1,
      "Name": ".NET Blog",
      "Summary": "Posts about .NET",
      "Posts": [
        {
          "Id": 1,
          "Title": "Announcing the Release of EF Core 5.0",
          "Content": "Announcing the release of EF Core 5.0, a full featured cross-platform...",
          "BlogId": 1
        }
      ]
    }
  },
  {
    "Id": 3,
    "Title": "Disassembly improvements for optimized managed debugging",
    "Content": "If you are focused on squeezing out the last bits of performance for your .NET service or...",
    "BlogId": 2,
    "Blog": {
      "Id": 2,
      "Name": "Visual Studio Blog",
      "Summary": "Posts about Visual Studio",
      "Posts": [
        {
          "Id": 4,
          "Title": "Database Profiling with Visual Studio",
          "Content": "Examine when database queries were executed and measure how long the take using...",
          "BlogId": 2
        }
      ]
    }
  },
  {
    "Id": 4,
    "Title": "Database Profiling with Visual Studio",
    "Content": "Examine when database queries were executed and measure how long the take using...",
    "BlogId": 2,
    "Blog": {
      "Id": 2,
      "Name": "Visual Studio Blog",
      "Summary": "Posts about Visual Studio",
      "Posts": [
        {
          "Id": 3,
          "Title": "Disassembly improvements for optimized managed debugging",
          "Content": "If you are focused on squeezing out the last bits of performance for your .NET service or...",
          "BlogId": 2
        }
      ]
    }
  }
]

請注意,圖形現在包含多個具有相同索引鍵值的 Blog 實例,以及具有相同索引鍵值的多個 Post 實例。 嘗試追蹤此圖表,就像我們在上一個範例中所做的一樣,將會擲回:

System.InvalidOperationException:無法追蹤實體類型 'Post' 的實例,因為已追蹤具有索引鍵值 '{Id: 2}' 的另一個實例。 附加現有實體時,請確定只附加一個具有指定索引鍵值的實體實例。

我們可以透過兩種方式修正此問題:

  • 使用保留參考的 JSON 串行化選項
  • 在追蹤圖表時執行身分識別解析

保留參考

Json.NET 提供 PreserveReferencesHandling 處理此作業的選項。 例如:

var serialized = JsonConvert.SerializeObject(
    posts,
    new JsonSerializerSettings
    {
        PreserveReferencesHandling = PreserveReferencesHandling.All, Formatting = Formatting.Indented
    });

產生的 JSON 現在看起來像這樣:

{
  "$id": "1",
  "$values": [
    {
      "$id": "2",
      "Id": 1,
      "Title": "Announcing the Release of EF Core 5.0",
      "Content": "Announcing the release of EF Core 5.0, a full featured cross-platform...",
      "BlogId": 1,
      "Blog": {
        "$id": "3",
        "Id": 1,
        "Name": ".NET Blog",
        "Summary": "Posts about .NET",
        "Posts": [
          {
            "$ref": "2"
          },
          {
            "$id": "4",
            "Id": 2,
            "Title": "Announcing F# 5",
            "Content": "F# 5 is the latest version of F#, the functional programming language...",
            "BlogId": 1,
            "Blog": {
              "$ref": "3"
            }
          }
        ]
      }
    },
    {
      "$ref": "4"
    },
    {
      "$id": "5",
      "Id": 3,
      "Title": "Disassembly improvements for optimized managed debugging",
      "Content": "If you are focused on squeezing out the last bits of performance for your .NET service or...",
      "BlogId": 2,
      "Blog": {
        "$id": "6",
        "Id": 2,
        "Name": "Visual Studio Blog",
        "Summary": "Posts about Visual Studio",
        "Posts": [
          {
            "$ref": "5"
          },
          {
            "$id": "7",
            "Id": 4,
            "Title": "Database Profiling with Visual Studio",
            "Content": "Examine when database queries were executed and measure how long the take using...",
            "BlogId": 2,
            "Blog": {
              "$ref": "6"
            }
          }
        ]
      }
    },
    {
      "$ref": "7"
    }
  ]
}

請注意,此 JSON 已將重複專案取代為參考,例如,參考 "$ref": "5" 圖形中已經存在的實例。 您可以使用對的簡單呼叫 Update來再次追蹤此圖表,如上所示。

System.Text.Json.NET 基類庫 (BCL) 中的支持有類似的選項,其會產生相同的結果。 例如:

var serialized = JsonSerializer.Serialize(
    posts, new JsonSerializerOptions { ReferenceHandler = ReferenceHandler.Preserve, WriteIndented = true });

解決重複專案

如果無法消除串行化程式中的重複專案,則 ChangeTracker.TrackGraph 提供處理這個方法。 TrackGraph 的運作方式就像 AddUpdateAttach不同之處在於它會在追蹤每個實體實例之前產生回呼。 此回呼可用來追蹤實體或忽略它。 例如:

public static void UpdatePostsFromJsonWithIdentityResolution(string json)
{
    using var context = new BlogsContext();

    var posts = JsonConvert.DeserializeObject<List<Post>>(json);

    foreach (var post in posts)
    {
        context.ChangeTracker.TrackGraph(
            post, node =>
            {
                var keyValue = node.Entry.Property("Id").CurrentValue;
                var entityType = node.Entry.Metadata;

                var existingEntity = node.Entry.Context.ChangeTracker.Entries()
                    .FirstOrDefault(
                        e => Equals(e.Metadata, entityType)
                             && Equals(e.Property("Id").CurrentValue, keyValue));

                if (existingEntity == null)
                {
                    Console.WriteLine($"Tracking {entityType.DisplayName()} entity with key value {keyValue}");

                    node.Entry.State = EntityState.Modified;
                }
                else
                {
                    Console.WriteLine($"Discarding duplicate {entityType.DisplayName()} entity with key value {keyValue}");
                }
            });
    }

    context.SaveChanges();
}

針對圖形中的每個實體,此程式代碼會:

  • 尋找實體的實體類型和索引鍵值
  • 在變更追蹤器中查閱具有此索引鍵的實體
    • 如果找到實體,則不會採取任何進一步的動作,因為實體是重複的
    • 如果找不到實體,則會藉由將狀態設定為 來追蹤該實體 Modified

執行此程式代碼的輸出如下:

Tracking EntityType: Post entity with key value 1
Tracking EntityType: Blog entity with key value 1
Tracking EntityType: Post entity with key value 2
Discarding duplicate EntityType: Post entity with key value 2
Tracking EntityType: Post entity with key value 3
Tracking EntityType: Blog entity with key value 2
Tracking EntityType: Post entity with key value 4
Discarding duplicate EntityType: Post entity with key value 4

重要

此程式代碼 假設所有重複專案都相同。 這可讓您放心地選擇其中一個重複項目來追蹤,同時捨棄其他複本。 如果重複專案可能不同,則程式代碼必須決定要使用哪一個,以及如何將屬性和導覽值結合在一起。

注意

為了簡單起見,此程式代碼假設每個實體都有一 Id個名為的主鍵屬性。 這可以編入抽象基類或介面。 或者,主鍵屬性或屬性可以從元數據取得 IEntityType ,讓此程式代碼能與任何類型的實體搭配使用。

無法設定索引鍵值

實體類型通常會設定為使用 自動產生的索引鍵值。 這是非複合索引鍵之整數和 GUID 屬性的預設值。 不過,如果實體類型未設定為使用自動產生的索引鍵值,則必須在追蹤實體之前設定明確的索引鍵值。 例如,使用下列實體類型:

public class Pet
{
    [DatabaseGenerated(DatabaseGeneratedOption.None)]
    public int Id { get; set; }

    public string Name { get; set; }
}

請考慮嘗試追蹤兩個新的實體實例而不設定索引鍵值的程式代碼:

using var context = new BlogsContext();

context.Add(new Pet { Name = "Smokey" });

try
{
    context.Add(new Pet { Name = "Clippy" }); // This will throw
}
catch (Exception e)
{
    Console.WriteLine($"{e.GetType().FullName}: {e.Message}");
}

此程式代碼會擲回:

System.InvalidOperationException:無法追蹤實體類型 'Pet' 的實例,因為已追蹤具有索引鍵值 '{Id: 0}' 的另一個實例。 附加現有實體時,請確定只附加一個具有指定索引鍵值的實體實例。

修正此問題的重點是明確設定索引鍵值,或將索引鍵屬性設定為使用產生的索引鍵值。 如需詳細資訊,請參閱 產生的值

過度使用單一 DbContext 實例

DbContext是設計來代表短期的工作單位,如 DbContext 初始化和設定中所述,並在 EF Core變更追蹤 中詳細說明。 若未遵循此指引,就很容易遇到嘗試追蹤相同實體的多個實例的情況。 常見範例包括:

  • 使用相同的 DbContext 實例來設定測試狀態,然後執行測試。 這通常會導致 DbContext 仍然從測試設定追蹤一個實體實例,同時嘗試在測試中適當附加新的實例。 請改用不同的 DbContext 實例來設定測試狀態和測試程序代碼。
  • 在存放庫或類似的程式代碼中使用共用 DbContext 實例。 請改為確定您的存放庫會針對每個工作單位使用單一 DbContext 實例。

身分識別解析和查詢

從查詢追蹤實體時,會自動進行身分識別解析。 這表示如果已追蹤具有指定索引鍵值的實體實例,則會使用這個現有的追蹤實例,而不是建立新的實例。 這有一個重要後果:如果資料庫中的數據已變更,則這不會反映在查詢的結果中。 這是針對每個工作單位使用新的 DbContext 實例的好理由,如 DbContext 初始化和設定中所述,並在 EF Core變更追蹤 中詳細說明。

重要

請務必瞭解 EF Core 一律對資料庫在 DbSet 上執行 LINQ 查詢,並且只會根據資料庫中的內容傳回結果。 不過,針對追蹤查詢,如果已追蹤傳回的實體,則會使用追蹤的實例,而不是從資料庫中的數據建立實例。

Reload() 或者,當追蹤的實體需要使用資料庫中的最新數據重新整理時,可以使用 或 GetDatabaseValues() 。 如需詳細資訊,請參閱 存取追蹤實體

與追蹤查詢相反,不追蹤查詢不會執行身分識別解析。 這表示不追蹤查詢可以傳回重複專案,就像稍早所述的 JSON 串行化案例一樣。 如果查詢結果要串行化並傳送至用戶端,這通常不是問題。

提示

請勿定期執行無追蹤查詢,然後將傳回的實體附加至相同的內容。 這會比使用追蹤查詢更慢且更難正確。

無追蹤查詢不會執行身分識別解析,因為這樣做會影響從查詢串流大量實體的效能。 這是因為身分識別解析需要追蹤傳回的每個實例,以便使用它,而不是稍後建立重複的實例。

不追蹤查詢可以使用 來強制執行身分識別解析 AsNoTrackingWithIdentityResolution<TEntity>(IQueryable<TEntity>)。 然後,查詢會追蹤傳回的實例(不以正常方式追蹤它們),並確保查詢結果中不會建立重複專案。

覆寫物件相等

EF Core 會在比較實體實例時使用 參考相等 。 即使實體類型覆寫 Object.Equals(Object) 或變更物件相等,也是如此。 不過,有一個地方可以覆寫相等會影響 EF Core 行為:當集合導覽使用覆寫的相等,而不是參考相等,因此報告多個實例時相同。

因此,建議您避免覆寫實體相等。 如果使用,請務必建立強制參考相等的集合導覽。 例如,建立使用參考相等比較子的相等比較子:

public sealed class ReferenceEqualityComparer : IEqualityComparer<object>
{
    private ReferenceEqualityComparer()
    {
    }

    public static ReferenceEqualityComparer Instance { get; } = new ReferenceEqualityComparer();

    bool IEqualityComparer<object>.Equals(object x, object y) => x == y;

    int IEqualityComparer<object>.GetHashCode(object obj) => RuntimeHelpers.GetHashCode(obj);
}

(從 .NET 5 開始,這包含在 BCL 中為 ReferenceEqualityComparer

然後,建立集合導覽時可以使用此比較子。 例如:

public ICollection<Order> Orders { get; set; }
    = new HashSet<Order>(ReferenceEqualityComparer.Instance);

比較索引鍵屬性

除了相等比較之外,還必須排序索引鍵值。 這在單一呼叫 SaveChanges 時避免死結很重要。 所有用於主鍵、替代或外鍵屬性的類型,以及用於唯一索引的類型,都必須實 IComparable<T> 作 和 IEquatable<T>。 通常用來做為索引鍵的類型(int、Guid、字串串等)已經支持這些介面。 自訂金鑰類型可能會新增這些介面。