更改检测和通知

每个 DbContext 实例跟踪对实体所做的更改。 在调用 SaveChanges 时,这些跟踪的实体会相应地驱动对数据库的更改。 EF Core 中的更改跟踪中对此进行了介绍,并且本文档假设你了解实体状态和 Entity Framework Core (EF Core) 更改跟踪的基础知识。

跟踪属性和关系更改要求 DbContext 能够检测到这些更改。 本文档介绍如何进行此检测,以及如何使用属性通知或更改跟踪代理来强制立即检测更改。

提示

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

快照更改跟踪

默认情况下,DbContext 实例首次跟踪每个实体时,EF Core 会创建这些实体的属性值的快照。 然后将此快照中存储的值与实体的当前值进行比较,以确定哪些属性值已更改。

在调用 SaveChanges 时,会进行更改检测,以确保在将更新发送到数据库之前检测到所有更改的值。 但是,更改检测也会发生在其他时间,以确保应用程序使用最新的跟踪信息。 可通过调用 ChangeTracker.DetectChanges(),随时强制执行更改检查。

需要更改检测的情况

当属性或导航已更改,而未使用 EF Core 进行此更改时,需要进行更改检测。 例如,假设加载博客和帖子,然后对这些实体进行更改:

using var context = new BlogsContext();
var blog = context.Blogs.Include(e => e.Posts).First(e => e.Name == ".NET Blog");

// Change a property value
blog.Name = ".NET Blog (Updated!)";

// Add a new entity to a navigation
blog.Posts.Add(
    new Post
    {
        Title = "What’s next for System.Text.Json?", Content = ".NET 5.0 was released recently and has come with many..."
    });

Console.WriteLine(context.ChangeTracker.DebugView.LongView);
context.ChangeTracker.DetectChanges();
Console.WriteLine(context.ChangeTracker.DebugView.LongView);

在调用 ChangeTracker.DetectChanges() 之前查看更改跟踪器调试视图表明未检测到所做的更改,因此这些更改不会反映在实体状态和修改后的属性数据中:

Blog {Id: 1} Unchanged
  Id: 1 PK
  Name: '.NET Blog (Updated!)' Originally '.NET Blog'
  Posts: [{Id: 1}, {Id: 2}, <not found>]
Post {Id: 1} Unchanged
  Id: 1 PK
  BlogId: 1 FK
  Content: 'Announcing the release of EF Core 5.0, a full featured cross...'
  Title: 'Announcing the Release of EF Core 5.0'
  Blog: {Id: 1}
Post {Id: 2} Unchanged
  Id: 2 PK
  BlogId: 1 FK
  Content: 'F# 5 is the latest version of F#, the functional programming...'
  Title: 'Announcing F# 5'
  Blog: {Id: 1}

具体而言,博客条目的状态仍为 Unchanged,并且新帖子不会显示为跟踪的实体。 (机敏的人会注意到属性报告了其新值,即使 EF Core 尚未检测到这些更改。这是因为调试视图直接从实体实例读取当前值。)

在调用 DetectChanges 后,将此内容与调试视图进行比较:

Blog {Id: 1} Modified
  Id: 1 PK
  Name: '.NET Blog (Updated!)' Modified Originally '.NET Blog'
  Posts: [{Id: 1}, {Id: 2}, {Id: -2147482643}]
Post {Id: -2147482643} Added
  Id: -2147482643 PK Temporary
  BlogId: 1 FK
  Content: '.NET 5.0 was released recently and has come with many...'
  Title: 'What's next for System.Text.Json?'
  Blog: {Id: 1}
Post {Id: 1} Unchanged
  Id: 1 PK
  BlogId: 1 FK
  Content: 'Announcing the release of EF Core 5.0, a full featured cross...'
  Title: 'Announcing the Release of EF Core 5.0'
  Blog: {Id: 1}
Post {Id: 2} Unchanged
  Id: 2 PK
  BlogId: 1 FK
  Content: 'F# 5 is the latest version of F#, the functional programming...'
  Title: 'Announcing F# 5'
  Blog: {Id: 1}

现在,博客已正确标记为 Modified,并且新帖子已被检测到并跟踪为 Added

在本部分开始时,我们指出在不使用 EF Core 做出更改时需要检测更改。 这就是上述代码所发生的情况。 也就是说,对属性和导航的更改是直接在实体实例上进行的,而不是使用任何 EF Core 方法进行更改

将此内容与以下代码进行比较,以下代码采用相同方式修改实体,但这次使用 EF Core 方法:

using var context = new BlogsContext();
var blog = context.Blogs.Include(e => e.Posts).First(e => e.Name == ".NET Blog");

// Change a property value
context.Entry(blog).Property(e => e.Name).CurrentValue = ".NET Blog (Updated!)";

// Add a new entity to the DbContext
context.Add(
    new Post
    {
        Blog = blog,
        Title = "What’s next for System.Text.Json?",
        Content = ".NET 5.0 was released recently and has come with many..."
    });

Console.WriteLine(context.ChangeTracker.DebugView.LongView);

在这种情况下,更改跟踪器调试视图会显示所有实体状态和属性修改都是已知的,即使尚未进行更改检测。 这是因为 PropertyEntry.CurrentValue 是一种 EF Core 方法,这意味着 EF Core 会立即知道此方法所做的更改。 同样,调用 DbContext.Add 可使 EF Core 立即了解出现了新实体并适当地对其进行跟踪。

提示

请勿尝试通过始终使用 EF Core 方法进行实体更改来避免检测更改。 相比于以常规方式对实体进行更改,这样做通常更为麻烦且效果更差。 本文档的目的是告知何时需要检测更改以及何时不需要检测更改。 目的并不是鼓励避免更改检测。

自动检测更改的方法

DetectChanges() 由多种方法自动调用,这样做可能会影响结果。 这些方法包括:

在某些情况下,更改检测仅发生在单个实体实例上,而不是整个跟踪的实体图上。 具体情况如下:

  • 使用 DbContext.Entry 时,这样做来确保实体的状态和修改后的属性处于最新状态。
  • 使用 EntityEntry 方法(如 PropertyCollectionReferenceMember)时,这样做来确保属性修改和当前值等内容处于最新状态。
  • 由于已提供所需的关系,将要删除依赖/子实体时。 这会检测何时不应删除实体,因为它已重新成为父级。

可通过调用 EntityEntry.DetectChanges() 来显式触发单个实体的本地更改检测。

注意

本地更改检测可能会遗漏全面检测会发现的一些更改。 当未检测到的对其他实体的更改导致的级联操作对相关实体产生影响时,就会发生这种情况。 在这种情况下,应用程序可能需要通过显式调用 ChangeTracker.DetectChanges() 来强制对所有实体进行全面扫描。

禁用自动更改检测

对于大多数应用程序而言,检测更改的性能并不是瓶颈。 但对于跟踪上千个实体的某些应用程序而言,检测更改可能会成为一个性能问题。 (确切数字将取决于许多因素,如实体中属性的数量。)因此,可使用 ChangeTracker.AutoDetectChangesEnabled 来禁止对更改进行自动检测。 例如,假设处理与有效负载具有多对多关系的联接实体:

public override int SaveChanges()
{
    foreach (var entityEntry in ChangeTracker.Entries<PostTag>()) // Detects changes automatically
    {
        if (entityEntry.State == EntityState.Added)
        {
            entityEntry.Entity.TaggedBy = "ajcvickers";
            entityEntry.Entity.TaggedOn = DateTime.Now;
        }
    }

    try
    {
        ChangeTracker.AutoDetectChangesEnabled = false;
        return base.SaveChanges(); // Avoid automatically detecting changes again here
    }
    finally
    {
        ChangeTracker.AutoDetectChangesEnabled = true;
    }
}

如前一部分所述,ChangeTracker.Entries<TEntity>()DbContext.SaveChanges 会自动检测更改。 但是,调用条目后,代码不会实体或属性状态进行任何更改。 (对添加的实体设置常规属性值不会导致任何状态更改。)因此,代码在调用基本 SaveChanges 方法时会禁用不必要的自动更改检测。 此代码还利用 try/finally 块来确保即使在 SaveChanges 失败的情况下也会还原默认设置。

提示

切勿认为代码必须禁用自动更改检测才能良好运行。 仅当分析跟踪多个实体的应用程序表明更改检测性能存在问题时,才需要执行此操作。

检测更改和值转换

若要对实体类型使用快照更改跟踪,EF Core 必须能够:

  • 在跟踪实体时创建每个属性值的快照
  • 将此值与属性的当前值进行比较
  • 为该值生成哈希代码

对于可以直接映射到数据库的类型,EF Core 会自动处理此操作。 但是,当值转换器用于映射属性时,该转换器必须指定如何执行这些操作。 这是通过值比较器实现的,值比较器文档中对此进行了详细说明。

通知实体

建议对大多数应用程序使用快照更改跟踪。 但是,跟踪多个实体和/或对这些实体进行众多更改的应用程序可能会受益于实现当其属性和导航值发生更改时自动通知 EF Core 的实体。 这些称为“通知实体”。

实现通知实体

通知实体使用 INotifyPropertyChangingINotifyPropertyChanged 接口,这些接口是 .NET 基类库 (BCL) 的一部分。 这些接口定义在更改属性值之前和之后必须触发的事件。 例如:

public class Blog : INotifyPropertyChanging, INotifyPropertyChanged
{
    public event PropertyChangingEventHandler PropertyChanging;
    public event PropertyChangedEventHandler PropertyChanged;

    private int _id;

    public int Id
    {
        get => _id;
        set
        {
            PropertyChanging?.Invoke(this, new PropertyChangingEventArgs(nameof(Id)));
            _id = value;
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Id)));
        }
    }

    private string _name;

    public string Name
    {
        get => _name;
        set
        {
            PropertyChanging?.Invoke(this, new PropertyChangingEventArgs(nameof(Name)));
            _name = value;
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Name)));
        }
    }

    public IList<Post> Posts { get; } = new ObservableCollection<Post>();
}

此外,任何集合导航都必须实现 INotifyCollectionChanged;在上面的示例中,通过使用帖子的 ObservableCollection<T> 来满足此要求。 EF Core 还附带了 ObservableHashSet<T> 实现,它提供更高的查找效率,但排序不稳定。

大多数此类通知代码通常会移到未映射的基类中。 例如:

public class Blog : NotifyingEntity
{
    private int _id;

    public int Id
    {
        get => _id;
        set => SetWithNotify(value, out _id);
    }

    private string _name;

    public string Name
    {
        get => _name;
        set => SetWithNotify(value, out _name);
    }

    public IList<Post> Posts { get; } = new ObservableCollection<Post>();
}

public abstract class NotifyingEntity : INotifyPropertyChanging, INotifyPropertyChanged
{
    protected void SetWithNotify<T>(T value, out T field, [CallerMemberName] string propertyName = "")
    {
        NotifyChanging(propertyName);
        field = value;
        NotifyChanged(propertyName);
    }

    public event PropertyChangingEventHandler PropertyChanging;
    public event PropertyChangedEventHandler PropertyChanged;

    private void NotifyChanged(string propertyName)
        => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));

    private void NotifyChanging(string propertyName)
        => PropertyChanging?.Invoke(this, new PropertyChangingEventArgs(propertyName));
}

配置通知实体

EF Core 无法验证 INotifyPropertyChangingINotifyPropertyChanged 是否已完全实现以与 EF Core 结合使用。 尤其是,这些接口的某些用途仅针对某些属性而非 EF Core 所需的所有属性(包括导航)发出通知。 因此,EF Core 不会自动连接到这些事件。

相反,必须将 EF Core 配置为使用这些通知实体。 对于所有实体类型,这通常是通过调用 ModelBuilder.HasChangeTrackingStrategy 来完成的。 例如:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.HasChangeTrackingStrategy(ChangeTrackingStrategy.ChangingAndChangedNotifications);
}

(对于不同的实体类型,还可使用 EntityTypeBuilder.HasChangeTrackingStrategy 以不同方式设置此策略,但这通常会适得其反,因为对于非通知实体的类型,仍然需要 DetectChanges。)

完全通知更改跟踪要求同时实现 INotifyPropertyChangingINotifyPropertyChanged。 这样就可以在属性值更改之前保存原始值,从而 EF Core 无需在跟踪实体时创建快照。 仅实现 INotifyPropertyChanged 的实体类型还可以与 EF Core 结合使用。 在这种情况下,EF 在跟踪实体时仍会创建快照以跟踪原始值,但随后使用通知立即检测更改,而无需调用 DetectChanges。

下表汇总了不同的 ChangeTrackingStrategy 值。

ChangeTrackingStrategy 所需的接口 需要 DetectChanges 快照原始值
快照
ChangedNotifications INotifyPropertyChanged
ChangingAndChangedNotifications INotifyPropertyChanged 和 INotifyPropertyChanging
ChangingAndChangedNotificationsWithOriginalValues INotifyPropertyChanged 和 INotifyPropertyChanging

使用通知实体

通知实体的行为类似于任何其他实体,不同之处在于,对实体实例进行更改不要求调用 ChangeTracker.DetectChanges() 来检测这些更改。 例如:

using var context = new BlogsContext();
var blog = context.Blogs.Include(e => e.Posts).First(e => e.Name == ".NET Blog");

// Change a property value
blog.Name = ".NET Blog (Updated!)";

// Add a new entity to a navigation
blog.Posts.Add(
    new Post
    {
        Title = "What’s next for System.Text.Json?", Content = ".NET 5.0 was released recently and has come with many..."
    });

Console.WriteLine(context.ChangeTracker.DebugView.LongView);

对于普通实体,更改跟踪器调试视图显示这些更改在调用 DetectChanges 之后才被检测到。 使用通知实体时查看调试视图表明已立即检测到这些更改:

Blog {Id: 1} Modified
  Id: 1 PK
  Name: '.NET Blog (Updated!)' Modified
  Posts: [{Id: 1}, {Id: 2}, {Id: -2147482643}]
Post {Id: -2147482643} Added
  Id: -2147482643 PK Temporary
  BlogId: 1 FK
  Content: '.NET 5.0 was released recently and has come with many...'
  Title: 'What's next for System.Text.Json?'
  Blog: {Id: 1}
Post {Id: 1} Unchanged
  Id: 1 PK
  BlogId: 1 FK
  Content: 'Announcing the release of EF Core 5.0, a full featured cross...'
  Title: 'Announcing the Release of EF Core 5.0'
  Blog: {Id: 1}
Post {Id: 2} Unchanged
  Id: 2 PK
  BlogId: 1 FK
  Content: 'F# 5 is the latest version of F#, the functional programming...'
  Title: 'Announcing F# 5'
  Blog: {Id: 1}

更改跟踪代理

EF Core 可动态地生成实现 INotifyPropertyChangingINotifyPropertyChanged 的代理类型。 这需要安装 Microsoft.EntityFrameworkCore.Proxies NuGet 包,并使用 UseChangeTrackingProxies 来启用更改跟踪代理。例如:

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    => optionsBuilder.UseChangeTrackingProxies();

创建动态代理涉及到创建一种新的动态 .NET 类型(使用 Castle.Core 代理实现),该类型从实体类型继承,然后覆盖所有属性资源库。 因此,代理的实体类型必须是可从中继承的类型,并且必须具有可覆盖的属性。 此外,显式创建的集合导航必须实现 INotifyCollectionChanged。例如:

public class Blog
{
    public virtual int Id { get; set; }
    public virtual string Name { get; set; }

    public virtual IList<Post> Posts { get; } = new ObservableCollection<Post>();
}

public class Post
{
    public virtual int Id { get; set; }
    public virtual string Title { get; set; }
    public virtual string Content { get; set; }

    public virtual int BlogId { get; set; }
    public virtual Blog Blog { get; set; }
}

更改跟踪代理的一个明显缺点是,EF Core 必须始终跟踪代理的实例,而不是跟踪基础实体类型的实例。 这是因为基础实体类型的实例不会生成通知,这意味着将遗漏对这些实体所做的更改。

EF Core 在查询数据库时自动创建代理实例,因此该缺点一般仅限于跟踪新实体实例。 必须使用 CreateProxy 扩展方法创建这些实例,而不是使用 new 通过常规方式进行创建。 这意味着前面示例中的代码现在必须使用 CreateProxy

using var context = new BlogsContext();
var blog = context.Blogs.Include(e => e.Posts).First(e => e.Name == ".NET Blog");

// Change a property value
blog.Name = ".NET Blog (Updated!)";

// Add a new entity to a navigation
blog.Posts.Add(
    context.CreateProxy<Post>(
        p =>
        {
            p.Title = "What’s next for System.Text.Json?";
            p.Content = ".NET 5.0 was released recently and has come with many...";
        }));

Console.WriteLine(context.ChangeTracker.DebugView.LongView);

更改跟踪事件

首次跟踪实体时,EF Core 会触发 ChangeTracker.Tracked 事件。 将来的实体状态更改会导致 ChangeTracker.StateChanged 事件。 有关详细信息,请参阅 EF Core 中的 .NET 事件

注意

首次跟踪实体时不会触发 StateChanged 事件,即使状态已从 Detached 更改为其他状态之一。 请确保侦听StateChangedTracked 事件,以获取所有相关通知。