访问已跟踪的实体

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

下面各节将更详细地介绍其中每个内容。

小窍门

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

小窍门

可以通过 从 GitHub 下载示例代码来运行和调试本文档中的所有代码。

使用 DbContext.Entry 和 EntityEntry 实例

对于每个跟踪实体,Entity Framework Core(EF Core)将跟踪:

  • 实体的总体状态。 这是其中一个:Unchanged, Modified, AddedDeleted; 有关详细信息,请参阅EF Core中的更改跟踪
  • 跟踪实体之间的关系。 例如,文章所属的博客。
  • 属性的“当前值”。
  • 属性的“原始值”(当此信息可用时)。 原始值是从数据库查询实体时存在的属性值。
  • 自查询这些属性值以来,已修改了哪些属性值。
  • 有关属性值的其他信息,例如该值是否为 临时值。

将实体实例传递给 DbContext.Entry 会产生一个 EntityEntry<TEntity>,它提供对给定实体信息的访问权限。 例如:

using var context = new BlogsContext();

var blog = await context.Blogs.SingleAsync(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

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

使用单个属性

多个重载 EntityEntry<TEntity>.Property 允许访问有关实体单个属性的信息。 例如,使用强类型、类似于 fluent 的 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 = await context.Blogs.SingleAsync(e => e.Id == 1);

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

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

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

处理实体的所有属性

EntityEntry.Properties返回一个IEnumerable<T>,用于实体的每个属性PropertyEntry。 这可用于对实体的每个属性执行操作。 例如,若要将任何 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 = await context.Entry(blog).GetDatabaseValuesAsync();

这些 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 = await context.Entry(blog).GetDatabaseValuesAsync();
context.Entry(blog).CurrentValues.SetValues(databaseValues);
context.Entry(blog).OriginalValues.SetValues(databaseValues);

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

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

var clonedBlog = (await context.Entry(blog).GetDatabaseValuesAsync()).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 生成的。

查找和 FindAsync

DbContext.FindDbContext.FindAsyncDbSet<TEntity>.FindDbSet<TEntity>.FindAsync被设计用于在已知主键时高效查询单个实体。 查找第一次检查实体是否已跟踪,如果是,则立即返回实体。 仅当实体未在本地跟踪时,才会进行数据库查询。 例如,考虑这段代码,它对同一实体调用了两次 Find 方法:

using var context = new BlogsContext();

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

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

Console.WriteLine();
Console.WriteLine("Second call to Find...");
var blog2 = await context.Blogs.FindAsync(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.

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

如果未在本地跟踪具有给定键的实体并且数据库中不存在,则查找返回 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 = await context.OrderLines.FindAsync(orderId, productId);

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

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

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

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属性进行类似于Fluent访问的原因。

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

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();

await context.Blogs.Include(e => e.Posts).LoadAsync();

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 查询中同样不适用,因为数据库中仍然存在实体,因此这些实体会被数据库查询返回。

所有这些意味着 DbSet.Local 提供一个视图,反映实体图当前的概念状态,其中包括 Added 实体,而 Deleted 实体被排除在外。 这与数据库在调用 SaveChanges 后的预期状态匹配。

通常情况下,这是进行数据绑定的理想视图,因为它根据应用程序所做的更改,按照用户的理解来呈现数据。

以下代码演示了这一点,方法是将一个帖子 Deleted 标记为后再添加新帖子,并将其 Added标记为:

using var context = new BlogsContext();

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

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 = await context.Posts.Include(e => e.Blog).ToListAsync();

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 Forms 或 WPF 数据绑定中使用局部视图

数据绑定到 EF Core 实体的基础由 DbSet<TEntity>.Local 形成。 但是,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 实例时,会延迟创建给定 DbSet 实例的本地视图。 LocalView 创建本身很快,它不使用大量内存。 但是,它确实调用 DetectChanges,这对于大量实体来说可能很慢。 由ToObservableCollectionToBindingList创建的集合也是惰性创建的,然后缓存。 这两种方法都创建新的集合,当涉及数千个实体时,这些集合可能会很慢,并且使用大量内存。