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 和明確追蹤實體 變更追蹤 中所述。 這些方法會在身分識別解析的內容中概述。 請注意,每個方法都會使用查詢或呼叫 其中Update
Attach
一個 或 ,但絕對不會同時使用這兩種方法。
呼叫更新
更新的實體通常不是來自我們將用於 SaveChanges 的 DbContext 查詢。 例如,在 Web 應用程式中,可以從 POST 要求中的資訊建立實體實例。 處理這個最簡單方式是使用 DbContext.Update 或 DbSet<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 的運作方式就像 Add
,Update
Attach
不同之處在於它會在追蹤每個實體實例之前產生回呼。 此回呼可用來追蹤實體或忽略它。 例如:
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、字串串等)已經支持這些介面。 自訂金鑰類型可能會新增這些介面。