访问跟踪的实体

有四个用于访问 DbContext 跟踪的实体的主要 API:

以下部分详细介绍了其中每一个 API。

提示

本文档假设你已了解实体状态和 EF Core 更改跟踪的基础知识。 有关这些主题的详细信息,请参阅 EF Core 中的更改跟踪

提示

通过从 GitHub 下载示例代码,你可运行并调试到本文档中的所有代码。

使用 DbContext.Entry 和 EntityEntry 实例

对于每个跟踪的实体,Entity Framework Core (EF Core) 跟踪以下内容:

  • 实体的总体状态。 状态为 UnchangedModifiedAddedDeleted;有关这些详细信息,请参阅 EF Core 中的更改跟踪
  • 跟踪的实体之间的关系。 例如,一篇帖子所属的博客。
  • 属性的“当前值”。
  • 属性的“原始值”(如果此信息可用)。 原始值是从数据库中查询实体时存在的属性值。
  • 自查询后修改的属性值。
  • 有关属性值的其他信息,例如,值是否是临时的。

将实体实例传递到 DbContext.Entry 会导致 EntityEntry<TEntity> 为给定实体提供对此信息的访问权限。 例如:

using var context = new BlogsContext();

var blog = context.Blogs.Single(e => e.Id == 1);
var entityEntry = context.Entry(blog);

以下部分显示如何使用 EntityEntry 访问和操作实体状态以及实体的属性和导航状态。

使用实体

EntityEntry<TEntity> 最常见用途是访问实体的当前 EntityState。 例如:

var currentState = context.Entry(blog).State;
if (currentState == EntityState.Unchanged)
{
    context.Entry(blog).State = EntityState.Modified;
}

Entry 方法还可用于尚未跟踪的实体。 这不会使得开始跟踪实体;实体的状态仍为 Detached。 但是,随后可以使用返回的 EntityEntry 更改实体状态,此时将在给定状态下跟踪实体。 例如,以下代码将开始以 Added 状态跟踪 Blog 实例:

var newBlog = new Blog();
Debug.Assert(context.Entry(newBlog).State == EntityState.Detached);

context.Entry(newBlog).State = EntityState.Added;
Debug.Assert(context.Entry(newBlog).State == EntityState.Added);

提示

与在 EF6 中不同,设置单个实体的状态不会导致跟踪所有连接的实体。 这使得以这种方式设置状态成为比调用 AddAttachUpdate(在整个实体图上进行操作)的级别更低的操作。

下表总结了使用 EntityEntry 处理整个实体的方法:

EntityEntry 成员 说明
EntityEntry.State 获取并设置实体的 EntityState
EntityEntry.Entity 获取实体实例。
EntityEntry.Context 正在跟踪此实体的 DbContext
EntityEntry.Metadata 实体类型的 IEntityType 元数据。
EntityEntry.IsKeySet 实体是否已设置其键值。
EntityEntry.Reload() 使用从数据库中读取的值覆盖属性值。
EntityEntry.DetectChanges() 仅强制检测此实体的更改;请参阅更改检测和通知

使用单个属性

EntityEntry<TEntity>.Property 的多个重载允许访问关于实体的单个属性的信息。 例如,使用强类型的流畅 API:

PropertyEntry<Blog, string> propertyEntry = context.Entry(blog).Property(e => e.Name);

属性名称可以作为字符串传递。 例如:

PropertyEntry<Blog, string> propertyEntry = context.Entry(blog).Property<string>("Name");

然后可以将返回的 PropertyEntry<TEntity,TProperty> 用于访问属性相关信息。 例如,它可用于获取和设置该实体上的属性的当前值:

string currentValue = context.Entry(blog).Property(e => e.Name).CurrentValue;
context.Entry(blog).Property(e => e.Name).CurrentValue = "1unicorn2";

以上使用的两种属性方法均返回强类型的泛型 PropertyEntry<TEntity,TProperty> 实例。 使用此泛型类型是首选方法,因为此方法无需装箱值类型即可访问属性值。 但是,如果实体或属性的类型在编译时未知,则可以改为获取非泛型的 PropertyEntry

PropertyEntry propertyEntry = context.Entry(blog).Property("Name");

这样,无论属性是何种类型,都可以访问任何属性的属性信息,但需支付装箱值类型的费用。 例如:

object blog = context.Blogs.Single(e => e.Id == 1);

object currentValue = context.Entry(blog).Property("Name").CurrentValue;
context.Entry(blog).Property("Name").CurrentValue = "1unicorn2";

下表汇总了由 PropertyEntry 公开的属性信息:

PropertyEntry 成员 说明
PropertyEntry<TEntity,TProperty>.CurrentValue 获取并设置属性的当前值。
PropertyEntry<TEntity,TProperty>.OriginalValue 获取并设置属性的原始值(如果可用)。
PropertyEntry<TEntity,TProperty>.EntityEntry 对实体的 EntityEntry<TEntity> 的后向引用。
PropertyEntry.Metadata 属性的 IProperty 元数据。
PropertyEntry.IsModified 指示此属性是否被标记为已修改,并允许更改此状态。
PropertyEntry.IsTemporary 指示此属性是否被标记为临时,并允许更改此状态。

注意:

  • 属性的原始值是从数据库中查询实体时该属性具有的值。 但是,如果实体已断开连接,然后显式附加到另一个 DbContext(例如使用 AttachUpdate),则原始值不可用。 在这种情况下,返回的原始值将与当前值相同。
  • SaveChanges 将仅更新标记为已修改的属性。 将 IsModified 设置为 true 可强制 EF Core 更新给定的属性值,或将其设置为 false 可防止 EF Core 更新属性值。
  • 临时值通常由 EF Core 值生成器生成。 设置属性的当前值会将临时值替换为给定值,并将该属性标记为非临时。 将 IsTemporary 设置为 true 可强制将值设置为临时值,即使已对该值进行显式设置。

使用单个导航

EntityEntry<TEntity>.ReferenceEntityEntry<TEntity>.CollectionEntityEntry.Navigation 的多个重载允许访问关于单个导航的信息。

可通过 Reference 方法访问对单个相关实体的引用导航。 引用导航指向一对多关系的“一”侧,并指向一对一关系的两侧。 例如:

ReferenceEntry<Post, Blog> referenceEntry1 = context.Entry(post).Reference(e => e.Blog);
ReferenceEntry<Post, Blog> referenceEntry2 = context.Entry(post).Reference<Blog>("Blog");
ReferenceEntry referenceEntry3 = context.Entry(post).Reference("Blog");

当用于一对多关系和多对多关系的“多”侧时,导航也可以是相关实体的集合。 Collection 方法用于访问集合导航。 例如:

CollectionEntry<Blog, Post> collectionEntry1 = context.Entry(blog).Collection(e => e.Posts);
CollectionEntry<Blog, Post> collectionEntry2 = context.Entry(blog).Collection<Post>("Posts");
CollectionEntry collectionEntry3 = context.Entry(blog).Collection("Posts");

某些操作对于所有导航都是通用的。 可以使用 EntityEntry.Navigation 方法访问这些操作,以使用引用导航和集合导航。 请注意,同时访问所有导航时,只能使用非通用访问。 例如:

NavigationEntry navigationEntry = context.Entry(blog).Navigation("Posts");

下表概述了使用 ReferenceEntry<TEntity,TProperty>CollectionEntry<TEntity,TRelatedEntity>NavigationEntry 的方法:

NavigationEntry 成员 说明
MemberEntry.CurrentValue 获取并设置导航的当前值。 这是集合导航的整个集合。
NavigationEntry.Metadata 导航的 INavigationBase 元数据。
NavigationEntry.IsLoaded 获取或设置一个值,该值指示是否已从数据库完全加载相关实体或集合。
NavigationEntry.Load() 从数据库加载相关实体或集合;请参阅相关数据的显式加载
NavigationEntry.Query() 查询 EF Core 将用于将此导航加载为可进一步组合的 IQueryable;请参阅相关数据的显式加载

使用实体的所有属性

EntityEntry.Properties 为实体的每个属性返回 PropertyEntryIEnumerable<T>。 这可用于对实体的每个属性都执行一项操作。 例如,将任何 DateTime 属性设置为 DateTime.Now

foreach (var propertyEntry in context.Entry(blog).Properties)
{
    if (propertyEntry.Metadata.ClrType == typeof(DateTime))
    {
        propertyEntry.CurrentValue = DateTime.Now;
    }
}

此外,EntityEntry 包含用于同时获取和设置所有属性值的多个方法。 这些方法使用 PropertyValues 类,该类表示属性及其值的集合。 可为当前值或原始值或者为当前存储在数据库中的值获取 PropertyValues。 例如:

var currentValues = context.Entry(blog).CurrentValues;
var originalValues = context.Entry(blog).OriginalValues;
var databaseValues = context.Entry(blog).GetDatabaseValues();

这些 PropertyValues 对象本身并没有多大用处。 但是,可以将它们进行组合以执行操作实体时所需的通用操作。 在使用数据传输对象和解决乐观并发冲突时,这非常有用。 以下部分显示了一些示例。

设置实体或 DTO 中的当前值或原始值

可以通过复制另一个对象中的值来更新实体的当前值或原始值。 例如,假设 BlogDto 数据传输对象 (DTO) 具有与实体类型相同属性的:

public class BlogDto
{
    public int Id { get; set; }
    public string Name { get; set; }
}

此对象可用于使用 PropertyValues.SetValues 设置跟踪的实体的当前值:

var blogDto = new BlogDto { Id = 1, Name = "1unicorn2" };

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

使用通过服务调用或 n 层应用程序中的客户端获取的值更新实体时,有时会使用此方法。 请注意,只要使用的对象具有与实体名称匹配的属性,该对象的类型就不必与实体的类型相同。 在以上示例中,DTO BlogDto 的实例用于设置跟踪的 Blog 实体的当前值。

请注意,只有当值集与当前值不同时,才会将属性标记为已修改。

设置字典中的当前值或原始值

上一示例设置实体或 DTO 实例中的值。 当属性值作为名称/值对存储在字典中时,可执行相同的行为。 例如:

var blogDictionary = new Dictionary<string, object> { ["Id"] = 1, ["Name"] = "1unicorn2" };

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

设置数据库中的当前值或原始值

可通过调用 GetDatabaseValues()GetDatabaseValuesAsync 并使用返回的对象设置当前值和/或原始值,使用数据库中的最新值来更新实体的当前值或原始值。 例如:

var databaseValues = context.Entry(blog).GetDatabaseValues();
context.Entry(blog).CurrentValues.SetValues(databaseValues);
context.Entry(blog).OriginalValues.SetValues(databaseValues);

创建包含当前值、原始值或数据库值的克隆对象

从 CurrentValues、OriginalValues 或 GetDatabaseValues 中返回的 PropertyValues 对象可用于使用 PropertyValues.ToObject() 创建实体克隆。 例如:

var clonedBlog = context.Entry(blog).GetDatabaseValues().ToObject();

请注意,ToObject 返回 DbContext 未跟踪的新实例。 返回的对象也未设置与其他实体的任何关系。

克隆的对象有助于解决与数据库的并发更新相关的问题,尤其是在数据绑定到特定类型的对象时。 请参阅乐观并发以了解详细信息。

使用实体的所有导航

EntityEntry.Navigations 为实体的每个导航返回 NavigationEntryIEnumerable<T>EntityEntry.ReferencesEntityEntry.Collections 执行相同的操作,但分别限于引用导航或集合导航。 这可用于对实体的每个导航都执行一项操作。 例如,强制加载所有相关实体:

foreach (var navigationEntry in context.Entry(blog).Navigations)
{
    navigationEntry.Load();
}

使用实体的所有成员

常规属性和导航属性具有不同的状态和行为。 因此,通常分别处理导航和非导航,如上述部分所示。 但是,无论实体的任何成员是常规属性还是导航,对该成员执行某些操作有时是很有用的。 为此提供了 EntityEntry.MemberEntityEntry.Members。 例如:

foreach (var memberEntry in context.Entry(blog).Members)
{
    Console.WriteLine(
        $"Member {memberEntry.Metadata.Name} is of type {memberEntry.Metadata.ClrType.ShortDisplayName()} and has value {memberEntry.CurrentValue}");
}

对示例中的博客运行此代码会生成以下输出:

Member Id is of type int and has value 1
Member Name is of type string and has value .NET Blog
Member Posts is of type IList<Post> and has value System.Collections.Generic.List`1[Post]

提示

更改跟踪器调试视图显示此类信息。 整个更改跟踪器的调试视图从每个跟踪实体的单个 EntityEntry.DebugView 中生成。

Find 和 FindAsync

DbContext.FindDbContext.FindAsyncDbSet<TEntity>.FindDbSet<TEntity>.FindAsync 设计为在已知主键时高效查找单个实体。 Find 首先检查实体是否已被跟踪,如果是,则立即返回该实体。 只有当未在本地跟踪实体时,才执行数据库查询。 例如,假设此代码对同一实体调用两次 Find:

using var context = new BlogsContext();

Console.WriteLine("First call to Find...");
var blog1 = context.Blogs.Find(1);

Console.WriteLine($"...found blog {blog1.Name}");

Console.WriteLine();
Console.WriteLine("Second call to Find...");
var blog2 = context.Blogs.Find(1);
Debug.Assert(blog1 == blog2);

Console.WriteLine("...returned the same instance without executing a query.");

使用 SQLite 时,此代码的输出(包括 EF Core 日志记录)为:

First call to Find...
info: 12/29/2020 07:45:53.682 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (1ms) [Parameters=[@__p_0='1' (DbType = String)], CommandType='Text', CommandTimeout='30']
      SELECT "b"."Id", "b"."Name"
      FROM "Blogs" AS "b"
      WHERE "b"."Id" = @__p_0
      LIMIT 1
...found blog .NET Blog

Second call to Find...
...returned the same instance without executing a query.

请注意,第一次调用未在本地找到实体,因此执行数据库查询。 相反,第二次调用在不查询数据库的情况下返回相同的实例,因为它已被跟踪。

如果具有给定键的实体未在本地进行跟踪并且不存在于数据库中,则 Find 将返回 NULL。

组合键

Find 还可以与组合键结合使用。 例如,假设 OrderLine 实体具有一个由订单 ID 和产品 ID 组成的组合键:

public class OrderLine
{
    public int OrderId { get; set; }
    public int ProductId { get; set; }

    //...
}

必须在 DbContext.OnModelCreating 中配置该组合键,以定义键部分及其顺序。 例如:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder
        .Entity<OrderLine>()
        .HasKey(e => new { e.OrderId, e.ProductId });
}

请注意,OrderId 是键的第一部分,ProductId 是键的第二部分。 将键值传递给 Find 时,必须使用此顺序。 例如:

var orderline = context.OrderLines.Find(orderId, productId);

使用 ChangeTracker.Entries 访问所有跟踪的实体

到目前为止,我们一次只访问了一个 EntityEntryChangeTracker.Entries() 为 DbContext 当前跟踪的每一个实体都返回一个 EntityEntry。 例如:

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

foreach (var entityEntry in context.ChangeTracker.Entries())
{
    Console.WriteLine($"Found {entityEntry.Metadata.Name} entity with ID {entityEntry.Property("Id").CurrentValue}");
}

此代码生成以下输出:

Found Blog entity with ID 1
Found Post entity with ID 1
Found Post entity with ID 2

请注意,会返回博客和帖子的条目。 可以使用 ChangeTracker.Entries<TEntity>() 泛型重载将结果筛选为特定实体类型:

foreach (var entityEntry in context.ChangeTracker.Entries<Post>())
{
    Console.WriteLine(
        $"Found {entityEntry.Metadata.Name} entity with ID {entityEntry.Property(e => e.Id).CurrentValue}");
}

此代码的输出显示仅返回帖子:

Found Post entity with ID 1
Found Post entity with ID 2

此外,使用泛型重载返回泛型 EntityEntry<TEntity> 实例。 在此示例中,这就是允许流畅访问 Id 属性的原因。

用于筛选的泛型类型不必为映射实体类型;可以改为使用非映射基类型或接口。 例如,如果模型中的所有实体类型都实现定义其键属性的接口:

public interface IEntityWithKey
{
    int Id { get; set; }
}

然后,此接口可用于以强类型方式处理任何跟踪的实体的键。 例如:

foreach (var entityEntry in context.ChangeTracker.Entries<IEntityWithKey>())
{
    Console.WriteLine(
        $"Found {entityEntry.Metadata.Name} entity with ID {entityEntry.Property(e => e.Id).CurrentValue}");
}

使用 DbSet.Local 查询跟踪的实体

始终对数据库执行 EF Core 查询,并且此查询仅返回已保存到数据库的实体。 DbSet<TEntity>.Local 提供一种机制,用于查询 DbContext 的本地跟踪的实体。

由于 DbSet.Local 用于查询跟踪的实体,因此通常将实体加载到 DbContext 中,然后使用这些加载的实体。 这尤其适用于数据绑定,但在其他情况下也很有用。 例如,在下面的代码中,首先查询数据库中的所有博客和帖子。 Load 扩展方法用于通过上下文跟踪的结果执行此查询,而无需将结果直接返回到应用程序。 (使用 ToList 或类似的方法具有相同的效果,但具有创建返回列表的开销,这里不需要使用这些方法。)然后,该示例使用 DbSet.Local 访问本地跟踪的实体:

using var context = new BlogsContext();

context.Blogs.Include(e => e.Posts).Load();

foreach (var blog in context.Blogs.Local)
{
    Console.WriteLine($"Blog: {blog.Name}");
}

foreach (var post in context.Posts.Local)
{
    Console.WriteLine($"Post: {post.Title}");
}

请注意,与 ChangeTracker.Entries() 不同,DbSet.Local 直接返回实体实例。 当然,始终可以通过调用 DbContext.Entry 为返回的实体获取 EntityEntry。

本地视图

DbSet<TEntity>.Local 返回本地跟踪的实体的视图,该视图反映这些实体的当前 EntityState。 具体而言,这表示:

  • Added 实体包含在内。 请注意,对于普通 EF Core 查询,情况并非如此,因为 Added 实体尚不存在于数据库中,因此数据库查询永远不会返回此实体。
  • Deleted 实体排除在外。 请注意,对于普通 EF Core 查询,情况同样并非如此,因为 Deleted 实体仍存在于数据库中,因此数据库查询会返回此实体

所有这些都意味着 DbSet.Local 是关于反映实体图当前概念状态的数据的视图,其中 Added 实体包含在内且 Deleted 实体排除在外。 这与调用 SaveChanges 后预期的数据库状态一致。

这通常是数据绑定的理想视图,因为它根据应用程序所做的更改向用户呈现他们所了解的数据。

以下代码将一个帖子标记为 Deleted,然后添加一个新帖子并标记为 Added 来演示这一点:

using var context = new BlogsContext();

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

Console.WriteLine("Local view after loading posts:");

foreach (var post in context.Posts.Local)
{
    Console.WriteLine($"  Post: {post.Title}");
}

context.Remove(posts[1]);

context.Add(
    new Post
    {
        Title = "What’s next for System.Text.Json?",
        Content = ".NET 5.0 was released recently and has come with many...",
        Blog = posts[0].Blog
    });

Console.WriteLine("Local view after adding and deleting posts:");

foreach (var post in context.Posts.Local)
{
    Console.WriteLine($"  Post: {post.Title}");
}

此代码的输出为:

Local view after loading posts:
  Post: Announcing the Release of EF Core 5.0
  Post: Announcing F# 5
  Post: Announcing .NET 5.0
Local view after adding and deleting posts:
  Post: What’s next for System.Text.Json?
  Post: Announcing the Release of EF Core 5.0
  Post: Announcing .NET 5.0

请注意,已删除的帖子会从本地视图中删除,并且会将添加的帖子包含在其中。

使用 Local 添加和删除实体

DbSet<TEntity>.Local 返回 LocalView<TEntity> 的实例。 这是 ICollection<T> 的实现,可在从集合中添加和删除实体时生成并响应通知。 (这与 ObservableCollection<T> 的概念相同,但实现为对现有 EF Core 更改跟踪条目的投影,而不是实现为独立的集合。)

本地视图的通知连接到 DbContext 更改跟踪,以便本地视图与 DbContext 保持同步。 具体而言:

  • DbSet.Local 添加新实体会导致该实体被 DbContext 跟踪,这通常发生在 Added 状态下。 (如果该实体已具有生成的键值,则其跟踪状态为 Unchanged。)
  • DbSet.Local 中删除实体会导致将该实体标记为 Deleted
  • 由 DbContext 跟踪的实体将自动显示在 DbSet.Local 集合中。 例如,如果执行查询以引入更多实体,会自动更新本地视图。
  • 标记为 Deleted 的实体将自动从本地集合中删除。

这意味着,只需通过从集合中进行添加和删除,本地视图就可用于操作跟踪的实体。 例如,让我们修改前面的示例代码,以在本地集合中添加和删除帖子:

using var context = new BlogsContext();

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

Console.WriteLine("Local view after loading posts:");

foreach (var post in context.Posts.Local)
{
    Console.WriteLine($"  Post: {post.Title}");
}

context.Posts.Local.Remove(posts[1]);

context.Posts.Local.Add(
    new Post
    {
        Title = "What’s next for System.Text.Json?",
        Content = ".NET 5.0 was released recently and has come with many...",
        Blog = posts[0].Blog
    });

Console.WriteLine("Local view after adding and deleting posts:");

foreach (var post in context.Posts.Local)
{
    Console.WriteLine($"  Post: {post.Title}");
}

输出与前面的示例相同,因为对本地视图所做的更改与 DbContext 保持同步。

将本地视图用于 Windows 窗体或 WPF 数据绑定

DbSet<TEntity>.Local 构成了将数据绑定到 EF Core 实体的基础。 但是,Windows 窗体和 WPF 与预期的特定通知集合类型结合使用时效果最佳。 本地视图支持创建这些特定的集合类型:

例如:

ObservableCollection<Post> observableCollection = context.Posts.Local.ToObservableCollection();
BindingList<Post> bindingList = context.Posts.Local.ToBindingList();

有关使用 EF Core 进行 WPF 数据绑定的详细信息,请参阅 WPF 入门,有关使用 EF Core 进行 Windows 窗体数据绑定的详细信息,请参阅 Windows 窗体入门

提示

给定 DbSet 实例的本地视图是在首次访问时延迟创建的,然后进行缓存。 创建 LocalView 本身速度很快,并且不会占用大量内存。 但是,它需调用 DetectChanges,这对于大量的实体而言,速度可能很慢。 由 ToObservableCollectionToBindingList 创建的集合也会延迟创建,然后进行缓存。 这两种方法都会创建新的集合,当涉及数千个实体时,创建速度可能会很慢,并且会占用大量内存。