更改检测和通知

每个 DbContext 实例跟踪对实体所做的更改。 当调用 SaveChanges 时,这些跟踪实体反过来会驱动对数据库的更改。 这在EF Core 中的更改跟踪中有介绍,本文档假定已理解实体状态和 Entity Framework Core(EF Core)更改跟踪的基础知识。

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

小窍门

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

快照变更跟踪

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

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

当需要进行更改检测时

在属性或导航已更改但没有使用 EF Core 进行更改的情况下,需要检测更改。 例如,请考虑加载博客和文章,然后对这些实体进行更改:

using var context = new BlogsContext();
var blog = await context.Blogs.Include(e => e.Posts).FirstAsync(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 = await context.Blogs.Include(e => e.Posts).FirstAsync(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时,为了确保实体的状态和修改的属性 up-to-date。
  • 在使用EntityEntry方法时,例如PropertyCollectionReferenceMember,以确保属性的修改、当前值等是up-to-date。
  • 将删除依赖/子实体时,因为已切断必需的关系。 这会检测何时不应删除实体,因为它已被重新父级。

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

注释

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

禁用自动更改检测

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

public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
    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 await base.SaveChangesAsync(cancellationToken); // Avoid automatically detecting changes again here
    }
    finally
    {
        ChangeTracker.AutoDetectChangesEnabled = true;
    }
}

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

小窍门

不要假设代码必须禁用自动更改检测才能正常运行。 仅当分析应用程序跟踪许多实体时,才需要这样做,这表示更改检测的性能是个问题。

检测变化和数值转换

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

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

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

通知实体

建议对大多数应用程序使用快照变更跟踪。 然而,跟踪许多实体和/或对这些实体进行许多更改的应用程序可以通过实现这样一种实体来获益:当属性和导航值发生变化时,该实体会自动通知 EF Core。 这些实体称为“通知实体”。

实现通知实体

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

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 值。

更改跟踪策略 所需的接口 需要检测更改 (DetectChanges) 快照原始值
快照 没有 是的 是的
修改通知 INotifyPropertyChanged (属性更改通知接口) 是的
变化和已变通知 INotifyPropertyChanged 和 INotifyPropertyChanging
更改和已更改通知与原始值 INotifyPropertyChanged 和 INotifyPropertyChanging 是的

使用通知实体

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

using var context = new BlogsContext();
var blog = await context.Blogs.Include(e => e.Posts).FirstAsync(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 包,并启用更改跟踪代理。例如:

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 = await context.Blogs.Include(e => e.Posts).FirstAsync(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以获取所有相关通知。