Entity Framework Core——6.更改跟踪(change tracker)工作原理

https://docs.microsoft.com/en-us/ef/core/change-tracking/

1. 什么时候跟踪、什么时候不跟踪?

每个dbcontext实例都会跟踪对实体的更改,当调用SaveChanges方法时把这些更改应用到数据库。这些跟踪操作由更改跟踪器完成。

什么时候跟踪?

  • 查数据库时返回的查询结果
  • 通过AddAttachUpdate等类似方法附加到context上的数据
  • 一个新实体关联到了已被跟踪实体上

什么时候不再跟踪?

  • 数据库上下文已被释放
  • 更改跟踪器(change tracker)被清除了(调用了ChangeTracker.Clear)
  • 手动分离(detach)实体

2. 实体的状态

每个实体都有5个状态:

  1. Detached:实体未被DbContext跟踪
  2. Added:是个新实体,并且没有插入到数据库中。执行SaveChanges时会被插入。
  3. Unchanged:实体从数据库查出来之后一直都没被更改过(实体从数据库查询出来之后默认的就是这个状态)。
  4. Modified:实体从数据库查出来之后有被更改过。执行SaveChanges时会更新。
  5. Deleted:实体存在于数据库中。执行SaveChanges时会从数据库删除此数据。

下图展示了不同的状态:

实体状态数据库上下文(DbContext)会跟踪存在于数据库中属性已修改SaveChanges 上的操作
Detached---
Added-插入
Unchanged-
Modified更新
Deleted-删除

这几个状态的关系如下:
在这里插入图片描述

3. 手动跟踪实体

实体可以手动附加(attached)到数据库上下文上。应用场景主要有:

  1. 创建了新实体需要插入到数据库中:主要是通过DbContext.Add方法(等效于DbSet<T>.Add)实现。
  2. 重新附加之前处于已分离(detached)状态的实体:当应用程序将实体发送给客户端,客户端更之后返回,此时应重新附加实体已响应更改。通过DbContext.AttachDbContext.Update实现。

3.1 附加现有实体

使用context.Attach方法

//此时Blog实体和Post子实体都处于Unchanged状态,无主键的实体被认为是Added
context.Attach(
    new Blog
    {
        Id = 1,
        Name = ".NET Blog",
        Posts =
        {
            new Post
            {
                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..."
            },
            new Post
            {
                Id = 2,
                Title = "Announcing F# 5",
                Content = "F# 5 is the latest version of F#, the functional programming language..."
            },
            new Post
            {
                //Added状态
                Title = "Announcing .NET 5.0",
                Content = ".NET 5.0 includes many enhancements, including single file applications, more..."
            },
        }
    });

如果附加的实体主键有值则被认为是Unchanged状态,如果主键没有赋值则认为是Added状态。当调用SaveChanges方法时,Added状态的实体会提交的数据库,Unchanged状态的实体会过滤掉。

3.2 更新现有实体

使用context.Update方法

//此时Blog实体和Post子实体处于Modfied状态,无主键的实体认为是Added状态
context.Update(
    new Blog
    {
        Id = 1,
        Name = ".NET Blog",
        Posts =
        {
            new Post
            {
                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..."
            },
            new Post
            {
                //Added状态
                Title = "Announcing F# 5",
                Content = "F# 5 is the latest version of F#, the functional programming language..."
            }
        }
    });

3.3 删除实体

3.3.1 删除子实体

一般都不会对新new出来的实体调用DbContext.Delete. 也不同于DbContext.Add,DbContext.Attach,DbContext.Update方法,DbContext.Delete常见于删除处于UnchangedModified状态的实体。

当delete一个实体时,如果这个实体处于detached状态,则自动会先attach它然后再删除,即:

context.Attach(post);
context.Remove(post);
//等同于
context.Remove(post);

3.3.2 删除父实体

删除父实体比删除子实体会麻烦一点.因为当删除父实体之后子实体的外键就会找不引用成为无效的状态,大多数数据库都会报错。解决方式有两种:

  1. 将子实体的外键属性设置可为null,表明断开子父关系。当ef检测到外键可空时,删除父实体就会默认将外键置空。
  2. 将子实体也一并删除。当ef检测到外键不可空时,删父实体时会一并把子实体删除。

3.4 自定义跟踪

使用DbContext.ChangeTracker.TrackGraph方法可以自定义实体的状态,它在开始跟踪前会调用一个委托,可以在这个委托里定义自己的逻辑。

考虑以下功能代码,如果key为0则新增,如果为负则删除,如果为正则更新:

public static void UpdateBlog(Blog blog)
{
    using var context = new BlogsContext();
    context.ChangeTracker.TrackGraph(
        blog, node =>
        {
            var propertyEntry = node.Entry.Property("Id");
            var keyValue = (int)propertyEntry.CurrentValue;

            if (keyValue == 0)
            {
                node.Entry.State = EntityState.Added;
            }
            else if (keyValue < 0)
            {
                propertyEntry.CurrentValue = -keyValue;
                node.Entry.State = EntityState.Deleted;
            }
            else
            {
                node.Entry.State = EntityState.Modified;
            }

            Console.WriteLine($"Tracking {node.Entry.Metadata.DisplayName()} with key value {keyValue} as {node.Entry.State}");
        });

    context.SaveChanges();
}

4. 访问跟踪实体

对于每个跟踪实体,efcore会跟踪以下几个内容:

  1. 实体的状态,如UnchangedAdded、、、、等。
  2. 实体间的关系
  3. 实体属性当前的值
  4. 实体属性的原始值,一般是从数据库查询出来的值
  5. 哪些属性的值改变过了
  6. 属性的临时值(如新增是会给主键分配一个临时值)

4.1 访问实体(EntityEntry<T>

可以通过dbContxt.Entry(blog)方法获取跟踪实体的信息, 返回的是一个EntityEntry<T>类型的对象。

下面是使用EntityEntry来处理整个实体的几个常用方法:

EntityEntry 成员描述
EntityEntry.State获取和设置 EntityState 实体的。(与EF6不同的是,设置单个实体的状态并不会影响到其关联的实体的状态,与AddAttach之类的方法不一样)
EntityEntry.Entity获取实体实例。
EntityEntry.Context此实体对应的DbContext
EntityEntry.MetadataIEntityType 实体类型的元数据。
EntityEntry.IsKeySet实体是否已设置其键值。
EntityEntry.Reload()用从数据库中读取的值覆盖属性值。
EntityEntry.DetectChanges()强制检测此实体是否更改

4.2 访问实体的某个属性(PropertyEntry<TEntity,TProperty>

可以通过context.Entry(blog).Property(e => e.Name)方法获取跟踪实体的某个属性的值,返回类型是PropertyEntry<TEntity,TProperty>。该类型常用的方法有以下几种:

PropertyEntry 成员描述
PropertyEntry<TEntity,TProperty>.CurrentValue获取和设置属性的当前值。
PropertyEntry<TEntity,TProperty>.OriginalValue获取并设置属性的原始值(如果可用)。
原始值是从数据库中查出来的值,如果当前实体被断开连接,然后又附加到了其它数据库上下文上,则此值与CurrentValue一样。
PropertyEntry<TEntity,TProperty>.EntityEntry属性对应的 EntityEntry<TEntity>
PropertyEntry.MetadataIProperty 属性的元数据。
PropertyEntry.IsModified指示此属性是否被标记为已修改,并允许更改此状态。
可设置为false,让ef强制更新数据库,SaveChanges只会更新标记为已修改的属性
PropertyEntry.IsTemporary指示此属性是否标记为临时,并允许更改此状态。
临时值通常由efcore生成,如主键值。当手动给当前属性赋值只会,则IsTemporary为false。

4.2.1 访问实体的全部属性

  1. context.Entry(blog).Properties:获取所有属性
  2. context.Entry(blog).CurrentValues:获取所有属性的当前值
  3. context.Entry(blog).OriginalValues;:所有属性的原始值
  4. context.Entry(blog).GetDatabaseValues();:所有属性的数据库值

这几个方法组合起来,可以完成以下常见操作:

  1. 使用DTO的值,更新当前实体的值:context.Entry(blog).CurrentValues.SetValues(blogDto);,入参是object,所以DTO的类型不一定要与实体的类型保持一致,但是DTO属性名需要与实体属性名匹配。
  2. 根据Dictionary里的值设置跟踪实体的值:
var blogDictionary = new Dictionary<string, object> { ["Id"] = 1, ["Name"] = "1unicorn2" };
context.Entry(blog).CurrentValues.SetValues(blogDictionary);
  1. 从数据库值设置跟踪实体的值:
var databaseValues = context.Entry(blog).GetDatabaseValues();
context.Entry(blog).CurrentValues.SetValues(databaseValues);
context.Entry(blog).OriginalValues.SetValues(databaseValues);
  1. 创建一个包含当前值/原始值/数据库值数据的克隆对象
context.Entry(blog).CurrentValues.ToObject();
context.Entry(blog).GetDatabaseValues().ToObject();

这个克隆对象不会被dbcontext跟踪。

4.3 访问实体的导航属性

可使用以下三种方法获取实体的导航属性:

  1. EntityEntry<TEntity>.Reference:适用于导航属性是单个对象的情景。
  2. EntityEntry<TEntity>.Collection:导航属性是集合时的情景
  3. EntityEntry.Navigation :导航属性是单个对象或者集合都适用。
//适用于单个对象
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");
//适用于集合
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");
//都适用
NavigationEntry navigationEntry = context.Entry(blog).Navigation("Posts");

NavigationEntry常见成员有以下几种:

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

4.3.1 访问实体的全部导航属性

以下代码表示强制加载全部导航属性的数据:

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

4.4 访问跟踪实体的所有成员

常规属性和导航属性有不同的状态和行为,所以上面的章节介绍了二者的单独处理。但是有时候我们需要对成员做统一的处理,并不关心它是普通属性和导航属性。可以使用 EntityEntry.Member EntityEntry.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}");
}

4.5 访问所有的跟踪实体

可以使用ChangeTracker.Entries()方法获取数据库上下文中所有被跟踪的的实体:

context.ChangeTracker.Entries();//获取所有跟踪实体
context.ChangeTracker.Entries<Post>();//获取类型为Post的跟踪实体
context.ChangeTracker.Entries<ITest>();//获取实现了ITest接口的所有跟踪实体

4.6 FindFindAsync方法

DbContext.FindDbContext.FindAsyncDbSet<TEntity>.Find DbSet<TEntity>.FindAsync 方法用来根据主键值查找单个实体。查找时首先会查找已跟踪实体里存不存在,如果不存在则再访问数据库。

var orderline = context.OrderLines.Find(orderId);//单一主键
var orderline = context.OrderLines.Find(orderId, productId);//联合主键

4.7 使用DbSet访问本地视图中的跟踪实体

efcore的查询始终在数据库上执行,并且仅返回已保存到数据库的实体。但是可以通过DbSet<T>.Local方法,查询在DbContext的本地视图(LocalView)中的 跟踪数据。通过这个方法你可以查询到处于Added状态的实体,可以过滤掉处于Deleted状态的实体(一般的linq查询是获取不到这两种状态的实体,因为都没有保存到数据库)。

查看以下代码:

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方法去掉,会发现两次输出并没有什么区别:

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: Announcing the Release of EF Core 5.0
  Post: Announcing F# 5
  Post: Announcing .NET 5.0

DbSet<T>.Local方法返回的是LocalView<T>类,这个类实现了ICollection<T>接口,与ObservableCollection<T>类似,当增加或删除集合中的实体时会发出通知。

LocalView的通知会挂靠到DbContext上,使本地视图与DbContext保持同步,具体体现在:

  1. DbSet.Local添加新实体时,数据库上下文也会跟踪这个实体,并将其置为Added状态。
  2. 删除DbSet.Local中的实体时,会被标记为Deleted状态。
  3. 如果数据库上下文跟踪了一个实体,那么这个实体会自动出现在DbSet.Local里。
  4. 如果change tracker将一个实体标记为Deleted,那么这个实体不再出现在DbSet.Local

所以更改实体状态的话我们又多了LocalView这种方式。以下代码的输出结果与上面的例子一样:

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}");
}

将本地视图与WPF/WinForm中的绑定结合

LocalView可以返回ObservableCollectionBindingList供绑定使用:

  1. LocalView<TEntity>.ToObservableCollection() 返回适用于WPF的ObservableCollection<T>数据绑定。
  2. LocalView<TEntity>.ToBindingList() 返回适用于WinForm的BindingList<T>数据绑定。

5. 关系修正

efcore通过外键和导航属性来维护实体间的关系。如果更改了外键的值efcore则会自动更新导航属性以反映此更改。同理如果更改了导航也会同步更新外键的值,这种机制称为==“关系修正”==。修正经常发生在查询时:当从数据库查询实体,会首先进行修正。因为数据库里只存外键值,没有导航属性的值。

以下例子:Blog与BlogAssets是一对一,Blog与Post是一对多,Post与Tag是多对多

using var context = new BlogsContext();

var blogs = context.Blogs
    .Include(e => e.Posts)
    .Include(e => e.Assets)
    .ToList();

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

“关系修正”还会发生在已跟踪的实体与查询返回的新实体之间。考虑以下代码:

using var context = new BlogsContext();

var blogs = context.Blogs.ToList();
Console.WriteLine(context.ChangeTracker.DebugView.LongView);//此时Blog的Assets和Posts导航都为空
/*输出如下
Blog {Id: 1} Unchanged
  Id: 1 PK
  Name: '.NET Blog'
  Assets: <null>
  Posts: []
Blog {Id: 2} Unchanged
  Id: 2 PK
  Name: 'Visual Studio Blog'
  Assets: <null>
  Posts: []
*/

var assets = context.Assets.ToList();
Console.WriteLine(context.ChangeTracker.DebugView.LongView);//此时Blog的Assets导航已经有值了
/*
Blog {Id: 1} Unchanged
  Id: 1 PK
  Name: '.NET Blog'
  Assets: {Id: 1}
  Posts: []
Blog {Id: 2} Unchanged
  Id: 2 PK
  Name: 'Visual Studio Blog'
  Assets: {Id: 2}
  Posts: []
BlogAssets {Id: 1} Unchanged
  Id: 1 PK
  Banner: <null>
  BlogId: 1 FK
  Blog: {Id: 1}
BlogAssets {Id: 2} Unchanged
  Id: 2 PK
  Banner: <null>
  BlogId: 2 FK
  Blog: {Id: 2}
*/

var posts = context.Posts.ToList();
Console.WriteLine(context.ChangeTracker.DebugView.LongView);//都有值了
/*
Blog {Id: 1} Unchanged
  Id: 1 PK
  Name: '.NET Blog'
  Assets: {Id: 1}
  Posts: [{Id: 1}, {Id: 2}]
Blog {Id: 2} Unchanged
  Id: 2 PK
  Name: 'Visual Studio Blog'
  Assets: {Id: 2}
  Posts: [{Id: 3}, {Id: 4}]
BlogAssets {Id: 1} Unchanged
  Id: 1 PK
  Banner: <null>
  BlogId: 1 FK
  Blog: {Id: 1}
BlogAssets {Id: 2} Unchanged
  Id: 2 PK
  Banner: <null>
  BlogId: 2 FK
  Blog: {Id: 2}
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}
  Tags: []
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}
  Tags: []
Post {Id: 3} Unchanged
  Id: 3 PK
  BlogId: 2 FK
  Content: 'If you are focused on squeezing out the last bits of perform...'
  Title: 'Disassembly improvements for optimized managed debugging'
  Blog: {Id: 2}
  Tags: []
Post {Id: 4} Unchanged
  Id: 4 PK
  BlogId: 2 FK
  Content: 'Examine when database queries were executed and measure how ...'
  Title: 'Database Profiling with Visual Studio'
  Blog: {Id: 2}
  Tags: []
*/

关系修正不会从数据库查询相关数据。它仅仅是“连接”由DbContext跟踪的那些实体。

5.1 使用导航属性来更改关系

更改两个实体间关系最简单的方式就是操作导航,这也是建议的方式。efcore会适当的修正反向导航和对应的外键值。通过以下方式完成:

  1. 添加或者删除导航属性中的实体
  2. 将导航属性指向不同的实体或者置为null

5.1.1 添加或者删除集合导航中的实体

以下代码是将一个Blog中的某个Post移动到另外一个Blog中:

using var context = new BlogsContext();

var dotNetBlog = context.Blogs.Include(e => e.Posts).Single(e => e.Name == ".NET Blog");
var vsBlog = context.Blogs.Include(e => e.Posts).Single(e => e.Name == "Visual Studio Blog");

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

var post = vsBlog.Posts.Single(e => e.Title.StartsWith("Disassembly improvements"));
vsBlog.Posts.Remove(post);
dotNetBlog.Posts.Add(post);

context.ChangeTracker.DetectChanges();//手动检测下,因为直接输出DebugView并不会触发自动检测
Console.WriteLine(context.ChangeTracker.DebugView.LongView);

context.SaveChanges();

尽管代码未显式更改 post.Blog 导航,但它已被自动修正,可指向 dotNetBlog 。 此外, post.BlogId 外键值已更新,以匹配dotNetBlog客的主键值。 保存时将会生成以下sql:

-- Executed DbCommand (0ms) [Parameters=[@p1='3' (DbType = String), @p0='1' (Nullable = true) (DbType = String)], CommandType='Text', CommandTimeout='30']
UPDATE "Posts" SET "BlogId" = @p0
WHERE "Id" = @p1;
SELECT changes();

5.1.2 将引用导航指向不同的实体

以上代码是通过更改集合导航实现,同样也可以直接修改子实体的引用导航来实现,效果完全一致:

var post = vsBlog.Posts.Single(e => e.Title.StartsWith("Disassembly improvements"));
post.Blog = dotNetBlog;

efcore检测到此项更改之后,会自动修正关系。

5.2 使用外键属性来更改关系

可以直接通过更改外键的方式来修改关系:

var post = vsBlog.Posts.Single(e => e.Title.StartsWith("Disassembly improvements"));
post.BlogId = dotNetBlog.Id;

efcore检测到更改之后,同样会自动修正关系。

5.3 AddedDeleted状态下实体关系的修正

5.3.1 Added状态的修正

efcore检测到向集合导航属性中添加一个新实体时会执行以下操作:

  • 如果实体未跟踪,则会跟踪。完了之后通常处于Added状态,如果设置了主键值就是Unchanged.
  • 如果该实体已于其他父实体进行了关联,则断开此关联。并与拥有这个导航属性的实体进行关联。
  • 对于所有涉及到的实体,导航和外键的值都是固定的。

基于以上逻辑我们发现把一个post从一个blog移动到另一个blog,其实不需要把post从原blog中删除。所以可以直接把以下代码

var post = vsBlog.Posts.Single(e => e.Title.StartsWith("Disassembly improvements"));
vsBlog.Posts.Remove(post);
dotNetBlog.Posts.Add(post);

更改为

var post = vsBlog.Posts.Single(e => e.Title.StartsWith("Disassembly improvements"));
dotNetBlog.Posts.Add(post);

5.3.2 Deleted状态下关系的修正

从集合导航中移除子实体将导致与父实体关系的断开。接下来发生的情况取决于此关系是必选的还是可选的

关系必选

适用于外键不可为null的情况。此时你应该给子实体指定一个新的外键,或者将子实体删除(删除孤立项)。默认情况下efcore会删除子实体。

var post = dotNetBlog.Posts.Single(e => e.Title == "Announcing F# 5");
dotNetBlog.Posts.Remove(post);

此时子实体post被标记为Deletedpost.Blog为null,但是post.BlogId保持原来的值(因为非空)。

关系可选

子实体post的外键值将被设置为null。子实体不会标记为Deleted而是为Modified,当保存时会将数据库字段置为null。

更改孤立项被标记为Deleted的时间点

通常当检测到关系改变时会立即将孤立项标记为Deleted。可以通过 ChangeTracker.DeleteOrphansTiming方法更改应何时将孤立项标记为Deleted。以下代码当SaveChanges时才会将孤立项标记:

context.ChangeTracker.DeleteOrphansTiming = CascadeTiming.OnSaveChanges;

var post = vsBlog.Posts.Single(e => e.Title.StartsWith("Disassembly improvements"));
vsBlog.Posts.Remove(post);//此时状态为Modified,不设置CascadeTiming.OnSaveChanges时为Deleted

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

dotNetBlog.Posts.Add(post);

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

context.SaveChanges();

(这个功能我也不知道具体有啥用)

5.3.3 更改父实体的引用导航

当关系是可选的一对一关系或者必选的一对一关系时,处理方式不同:


可选的一对一关系

对于一对一的关系,更改父实体的引用导航时将会断开与子实体的关系。子实体被标记为Modified

using var context = new BlogsContext();

var dotNetBlog = context.Blogs.Include(e => e.Assets).Single(e => e.Name == ".NET Blog");
dotNetBlog.Assets = new BlogAssets();

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

context.SaveChanges();

此时dotNetBlog是Unchanged,新new出来的BlogAssets是Added,原来的BlogAssets是Modified

必选的一对一关系

唯一与上不同的是原来的BlogAssets标记为Deleted

5.4 删除父实体

可选关系

会将子实体的外键置null,子实体的导航置null


必选关系

会把子实体也一并删除,称为级联删除(efcore默认行为)。

级联删除的时间点

通常情况下当主实体标记为Deleted时,会立即执行级联删除(把子实体也标记为Deleted)。可以设置ChangeTracker.CascadeDeleteTiming 让保存时才标记为删除。与孤立项类似,我也没发现这个设置具体的使用场景时什么

级联删除和删除孤立项是密切相关的。
当断开与父实体之间的关系时,两者都将导致删除子实体。 对于级联删除,由于父实体本身已删除,因此发生了这种断开。 对于孤立项,父实体仍然存在,但不再与依赖实体/子实体相关。

5.5 多对多关系的处理

5.5.1 多对多关系的工作方式

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

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

    public IList<PostTag> PostTags { get; } = new List<PostTag>(); //集合导航
}

public class Tag
{
    public int Id { get; set; }
    public string Text { get; set; }

    public IList<PostTag> PostTags { get; } = new List<PostTag>(); //集合导航
}

public class PostTag
{
    public int PostId { get; set; } // 指向Post的外键
    public int TagId { get; set; } // 指向Tag的外键

    public Post Post { get; set; } //引用导航
    public Tag Tag { get; set; } //引用导航
}

考虑有以上PostTag的多对多关系,新增关系:

using var context = new BlogsContext();

var post = context.Posts.Single(e => e.Id == 3);
var tag = context.Tags.Single(e => e.Id == 1);

context.Add(new PostTag { PostId = post.Id, TagId = tag.Id });
//或 context.Add(new PostTag { Post = post, Tag = tag });

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

此时的跟踪状态如下,可以看到post与tag的导航属性都不再为空。

Post {Id: 3} Unchanged
  Id: 3 PK
  BlogId: 2 FK
  Content: 'If you are focused on squeezing out the last bits of perform...'
  Title: 'Disassembly improvements for optimized managed debugging'
  Blog: <null>
  PostTags: [{PostId: 3, TagId: 1}]
PostTag {PostId: 3, TagId: 1} Added
  PostId: 3 PK FK
  TagId: 1 PK FK
  Post: {Id: 3}
  Tag: {Id: 1}
Tag {Id: 1} Unchanged
  Id: 1 PK
  Text: '.NET'
  PostTags: [{PostId: 3, TagId: 1}]

5.5.2 跳跃导航(Skip Navigation)

msdn中文文档称为“跳过导航”,但是感觉有歧义,这里我就称为跳跃导航了。与集合导航、引用导航一样,跳跃导航也属于导航属性的一种。

使用场景:当要查询与Post相关的Tag时,需要关联中间表PostTag,比较麻烦。从efcore 5.0开始可以“Skip”掉中间表,在实体中直接操作多对多关系。

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

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

    public IList<Tag> Tags { get; } = new List<Tag>(); // 跳跃导航属性
    public IList<PostTag> PostTags { get; } = new List<PostTag>(); //集合导航属性
}

public class Tag
{
    public int Id { get; set; }
    public string Text { get; set; }

    public IList<Post> Posts { get; } = new List<Post>(); // 跳跃导航属性
    public IList<PostTag> PostTags { get; } = new List<PostTag>(); // 集合导航属性
}

public class PostTag
{
    public int PostId { get; set; } // First part of composite PK; FK to Post
    public int TagId { get; set; } // Second part of composite PK; FK to Tag

    public Post Post { get; set; } // Reference navigation
    public Tag Tag { get; set; } // Reference navigation
}

还需要进行以下配置:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(p => p.Tags)
        .WithMany(p => p.Posts)
        .UsingEntity<PostTag>(
            j => j.HasOne(t => t.Tag).WithMany(p => p.PostTags),
            j => j.HasOne(t => t.Post).WithMany(p => p.PostTags));
}

当进行查询时会自动关联:

using var context = new BlogsContext();
var post = context.Posts.Single(e => e.Id == 3);
var tag = context.Tags.Single(e => e.Id == 1);
post.Tags.Add(tag);
context.ChangeTracker.DetectChanges();
Console.WriteLine(context.ChangeTracker.DebugView.LongView);

此时的实体状态与之前的一样:

Post {Id: 3} Unchanged
  Id: 3 PK
  BlogId: 2 FK
  Content: 'If you are focused on squeezing out the last bits of perform...'
  Title: 'Disassembly improvements for optimized managed debugging'
  Blog: <null>
  PostTags: [{PostId: 3, TagId: 1}]
  Tags: [{Id: 1}]
PostTag {PostId: 3, TagId: 1} Added
  PostId: 3 PK FK
  TagId: 1 PK FK
  Post: {Id: 3}
  Tag: {Id: 1}
Tag {Id: 1} Unchanged
  Id: 1 PK
  Text: '.NET'
  PostTags: [{PostId: 3, TagId: 1}]
  Posts: [{Id: 3}]

5.2.3 仅含“跳跃”导航

仅仅包括跳跃导航属性,也是可以的,如之前文章的4.2章节所述无PostTag中间实体的情况。此时添加关系:

using var context = new BlogsContext();

var post = context.Posts.Single(e => e.Id == 3);
var tag = context.Tags.Single(e => e.Id == 1);

post.Tags.Add(tag);

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

各实体状态如下,efcore创建了一个Dictionary<string, object>用来连接两个实体:

Post {Id: 3} Unchanged
  Id: 3 PK
  BlogId: 2 FK
  Content: 'If you are focused on squeezing out the last bits of perform...'
  Title: 'Disassembly improvements for optimized managed debugging'
  Blog: <null>
  Tags: [{Id: 1}]
Tag {Id: 1} Unchanged
  Id: 1 PK
  Text: '.NET'
  Posts: [{Id: 3}]
PostTag (Dictionary<string, object>) {PostsId: 3, TagsId: 1} Added
  PostsId: 3 PK FK
  TagsId: 1 PK FK

5.2.4 设置中间实体

如果PostTag实体还有其它属性,我们又两种方式可以给它们赋值。假如PostTag成员如下, 当建立关系时我们需要给TaggedOn字段赋值:

public class PostTag
{
    public int PostId { get; set; } 
    public int TagId { get; set; }

    public DateTime TaggedOn { get; set; } 
}

自动生成值

可以这么配置:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(p => p.Tags)
        .WithMany(p => p.Posts)
        .UsingEntity<PostTag>(
            j => j.HasOne<Tag>().WithMany(),
            j => j.HasOne<Post>().WithMany(),
            j => j.Property(e => e.TaggedOn).HasDefaultValueSql("CURRENT_TIMESTAMP"));
}

执行

var post = context.Posts.Single(e => e.Id == 3);
var tag = context.Tags.Single(e => e.Id == 1);
post.Tags.Add(tag);

SaveChanges之后,就能看到值。


手动赋值

var post = context.Posts.Single(e => e.Id == 3);
var tag = context.Tags.Single(e => e.Id == 1);
//方式1
post.Tags.Add(tag);
var joinEntity = context.Set<PostTag>().Find(post.Id, tag.Id);
joinEntity.TaggedOn =DateTime.Now;

//方式2
context.Add(
    new PostTag { PostId = post.Id, TagId = tag.Id, TaggedOn =DateTime.Now });
    
context.SaveChanges();

当然还有方式3,直接重写数据库上下文的SaveChanges方法:

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

    return base.SaveChanges();
}

6. 更改检测与通知

这一小节介绍efcore是如何检测到属性值的更改以及通知。

6.1 实体快照

默认情况下,当efcore首次跟踪一个实体时会为其创建快照。然后调用SaveChanges时,会把实体当前的值与快照的值进行比对,以确定有哪些改变。不过也会在其他的某些时间点进行快照比对,以确保应用程序能够获取到最新的跟踪信息。也可以随时调用ChangeTracker.DetectChanges()方法执行强制更改检测,或使用EntityEntry.DetectChanges()方法对某个实体进行更改检测。

6.1.1 什么时候需要手动检测更改?

当不通过efcore的方法对实体进行赋值时,如果需要获取各个实体的状态则需要手动检测更改。如:


var blog = context.Blogs.Include(e => e.Posts).First(e => e.Name == ".NET Blog");
blog.Name = ".NET Blog (Updated!)";//没有通过efcore的方法进行赋值

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

第一次各个实体的状态为,可以看到即使更改了blog.Name的值但它的状态还是Unchanged,也没有发现新增的实体:

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}

经过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}

各状态都已是最新。以下代码使用了efcore自带的方法实现了同样的操作,但是不需要手动检测更改,各个状态就是最新的:

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..."
    });

但是不建议你使用efcore自带的方法更改实体。因为使用起来比较繁琐而且perform less well,这一节仅仅是为了展示什么时候需要手动检测更改什么时候不需要。

当执行以下方法时,会自动调用DetectXChanges()方法:

  1. DbContext.SaveChangesDbContext.SaveChangesAsync ,以确保在更新数据库之前检测到所有更改。
  2. ChangeTracker.Entries()ChangeTracker.Entries<TEntity>() ,以确保实体状态和修改的属性是最新的。
  3. ChangeTracker.HasChanges(),以确保结果准确无误。
  4. ChangeTracker.CascadeChanges(),用于在级联之前确保父实体的状态正确。
  5. DbSet<TEntity>.Local,以确保跟踪的本地视图是最新的。

当调用以下方法时只会对单个实体进行检测更改,而不是对所有实体:

  1. 使用DbContext.Entry时,确保实体的状态和修改的属性是最新的。
  2. 使用EntityEntryProperty, Collection, ReferenceMember这些方法时

6.1.2 禁用自动更改检测

对于大多数程序来说,efcore的更改检机制都不是性能瓶颈。但是当efcore需要跟踪上千个实体的时候,就有可能引起性能问题。通过设置ChangeTracker.AutoDetectChangesEnabled = false可以避免自动检测更改。考虑有以下代码:

public override int SaveChanges()
{
    foreach (var entityEntry in ChangeTracker.Entries<PostTag>()) //会自动触发一次变更检测
    {
        if (entityEntry.State == EntityState.Added)
        {
            entityEntry.Entity.TaggedBy = "ajcvickers";
            entityEntry.Entity.TaggedOn = DateTime.Now;
        }
    }

    try
    {
        ChangeTracker.AutoDetectChangesEnabled = false;//禁用变更检测
        return base.SaveChanges(); 
    }
    finally
    {
        ChangeTracker.AutoDetectChangesEnabled = true;//恢复自动检测
    }
}

前面我们讲过 ChangeTracker.EntriesSaveChanges会触发自动检测变更,所以在调用SaveChanges之前把自动检测禁用了,因为ChangeTracker.Entries时已经触发了一次而且接下来实体的状态并不会改变。

个人感觉大多数情况下这个功能是没什么用的,除非你需要跟踪的实体有很多。如果有很多实体需要跟踪,那么可能你需要优化代码了。

6.2 通知实体

基于快照对比的变更检测适用于大多数的应用程序。但有些情况下应用程序可能希望当实体改变时就立即通知efcore。具有此功能的实体就是“通知实体”。

6.2.1 实现通知实体

首先实现INotifyPropertyChanging, INotifyPropertyChanged接口。

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

然后进行配置:

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

下标汇总了不同的ChangeTrackingStrategy值。

ChangeTrackingStrategy需要实现的接口需要手动DetectChanges快照原始值
Snapshot
ChangedNotificationsINotifyPropertyChanged
ChangingAndChangedNotificationsINotifyPropertyChanged
INotifyPropertyChanging
ChangingAndChangedNotificationsWithOriginalValuesINotifyPropertyChanged
INotifyPropertyChanging

然后使用:

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各个实体的状态也是最新的。

7. 标识解析

efcore根据主键值来跟踪一个实体。这意味着同一个实体的多个实例如果具有相同的主键值,则必须解析为一个实例,这就是“标识解析”。如果解析为多个实例,那么多个实例间的属性值可能不同,保存到数据库的时候,efcore不知道用哪个。以下代码就会产生异常:

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)
{
    //System.InvalidOperationException: The instance of entity type 'Blog' cannot be tracked because another instance with the key value '{Id: 1}' is already being tracked. When attaching existing entities, ensure that only one entity instance with a given key value is attached.
    Console.WriteLine($"{e.GetType().FullName}: {e.Message}");
}

8. 调试更改跟踪器

使用context.ChangeTracker.DebugView输入各个实体的状态。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

JimCarter

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值