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
调用再次跟踪该图形,如上所示。
.NET 基类库 (BCL) 中的 System.Text.Json 支持具有类似的选项,可产生相同的结果。 例如:
var serialized = JsonSerializer.Serialize(
posts, new JsonSerializerOptions { ReferenceHandler = ReferenceHandler.Preserve, WriteIndented = true });
解析重复项
如果无法在序列化过程中消除重复项,则 ChangeTracker.TrackGraph 会提供一种方法来处理这种情况。 TrackGraph 的工作方式与 Add
、Attach
和 Update
类似,不同之处在于它会在跟踪前为每个实体实例生成回叫。 此回叫可用于跟踪实体或忽略它。 例如:
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>) 使 no-tracking 查询强制执行标识解析。 查询随后将跟踪返回的实例(无需按正常方式跟踪它们),并确保在查询结果中没有创建重复项。
替代对象相等性
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 开始,这将作为 ReferenceEqualityComparer 包含在 BCL 中。)
然后可以在创建集合导航时使用此比较器。 例如:
public ICollection<Order> Orders { get; set; }
= new HashSet<Order>(ReferenceEqualityComparer.Instance);
比较键属性
除了比较相等性,还需要对键值进行排序。 这对于在对 SaveChanges 的单个调用中更新多个实体时避免死锁非常重要。 用于主键、备用键或外键属性的所有类型,以及用于唯一索引的类型,都必须实现 IComparable<T> 和 IEquatable<T>。 通常用作键(int、Guid、string 等)的类型已经支持这些接口。 自定义键类型可能会添加这些接口。