EntityFramework之DetectChanges's Secrets(三)(我为EF正名)

时间:2023-11-15 18:31:20

前言

对于应用程序开发者来说,通常不需要考虑太多对于Entity Framework中的变更追踪(change tracking),但是变更追踪和DetectChanges方法是栈的一部分,在这其中,易用性和性能就紧密关联。所以,鉴于此理由,对于你继续看接下来的内容那将是非常有帮助,转载地址:《https://blog.oneunicorn.com/2012/03/10/secrets-of-detectchanges-part-1-what-does-detectchanges-do》。

DetectChanges用途

接下来的内容将用下两个简单的Code First Model和Context(一个是Post类,一个是Blog类,一个博客可以发表多篇文章,但一篇文章只属于一个博客)

public class Blog
{
public int Id { get; set; }
public string Title { get; set; } public virtual ICollection Posts { get; set; }
} public class Post
{
public int Id { get; set; }
public string Title { get; set; }
public string Content { get; set; } public int BlogId { get; set; }
public virtual Blog Blog { get; set; }
}

EF上下文类:

    public class AnotherBlogContext : DbContext
{
public AnotherBlogContext()
: base("name=DBConnectionString")
{
}
public DbSet<Blog> Blogs { get; set; }
public DbSet<Post> Posts { get; set; }
}

变更追踪问题

大部分EF应用程序都利用快照式变更追踪,这就意味着在实体中我们不需要写任何代码来保持追踪或者说通知更改的上下文。

下面我们用代码来演示这点

using (var context = new AnotherBlogContext())

    var post = context.Posts
.Single(p => p.Title == "My First Post"); post.Title = "My Best Post";
context.SaveChanges();
}

上述代码中,我们查出一个满足条件的发表实体,同时修改其标题,最终改变被保存并返回到数据库中。

但是标题属性并没有发生什么特殊的改变,因为它仅仅是C#类中的一个自动属性而已,根据追踪的事实是,它确确实实发生了改变(类似于一个脏数据),或者说真实的事实是原始的属性值是 My First Post ,在实体中也没有什么去通知上下文已经发生了什么。

那么问题来了,SaveChanges怎么确定它需要作出行为来更改数据库中的标题呢?

答案就是使用快照式变更追踪和DetectChanges方法

快照式变更追踪和DetectChanges

当从数据库进行查询时,EF上下文便捕获了每个实体属性的快照,因此在上述例子中,当查询时,EF上下文中在快照中便记录了Post实体中的标题属性值My First Post。当SaveChanges时,将会自动调用DetectChanges方法,此方法将扫描上下文中所有实体,并比较当前属性值和存储在快照中的原始属性值。如果被找到的属性值发生了改变,此时EF将会与数据库进行交互,进行数据更新。在上述例子中,当前值My Best Post被检测到与原始值不相同,所以必然会进行相应的更新。

DetectChanges其他用途

事实上,DetectChanges用途远不止于此,为什么这样说呢?待我详细道来。此方法的大部分用途基本上是基于修改的范畴,那么到底修改什么呢?简而言之就是:更改实体之间的引用以及内部的状态和索引。似乎有点难懂,我们就实例来进行理解。

在上述的前提下,我们继续修改发表(Post)实体的外键,代码如下:

using (var context = new AnotherBlogContext())
{
context.Blogs.Load();
var post = context.Posts
.Single(p => p.Title == "My First Post"); post.Title = "My Best Post";
post.BlogId = ;
context.SaveChanges();
}

当在外键上做处修改时,当SaveChanges时会回调DetectChanges方法,此时该方法将作出以下行为:

(1)确保新添加的外键FK被保存到数据库中,就像任何属性值发生了改变一样。

(2)它将检测博客(Blog)实体中的主键是否匹配新添加的BogId(FK),同时上下文将会追踪新添加的BogId,如果匹配通过,将更改Blog导航属性来指向这个实体

(3)因为Blog实体对象里有一个相对应Posts的导航属性,此方法也就确保这些Posts会被更新。也就是说,这个查询出的Post将会从原有的Blog实体中的Post集合中被删除,并添加到Blog实体中的Posts集合中与现在的关联。

(4)更新Blog实体和所有属性的状态以及与此实体和属性相关联的状态。

(5)更新内部的索引。

【注意】如果不是外键而是导航属性发生了改变的话,也会同样如上作出相应的更新。

考虑性能

我们假设在上下文中跟踪成千个实体并且一旦实体发生改变,就得作出相应的行为,这样频繁的回调DetectChanges方法占用的资源和消耗的性能可想而知代价之大。即使你的应用程序没有成千个实体,这样一来将不会产生性能瓶颈,但是如果你尝试去优化DetectChanges,这样你就不需要担心在代码中出现一些你意想不到的细小错误,所以毋庸置疑优化将是一种不错的策略。请继续往下看!

自动调用DetectChanges的时机上下文什么时候知道?

当调用SaveChanges时,这一刻是上下文需要知道实体是否已经发生改变的最重要的一刻,那是很显然的。如果发生的改变是未知的,那么SaveChanges将无法确定向数据库是做出是增加、修改还是删除,这也就是为什么DetectChanges会被SaveChanges回调的原因。(当然,除非这种回调通过明确显式的禁用)即使你在EF4中使用ObjectContext。

然而上下文也需要知道在其他时刻的改变,例如,如果你要求上下文获得一个实体的状态,所以为了知道该实体是未发生改变(UnChanged)还是修改了(Modified),然后上下文就需要知道这个实体中任何属性的值是否已经发生了改变。

请看以下代码:

            using (var context = new AnotherBlogContext())
{
var post = context.Posts.Single(p => p.Title == "My First Post");
post.Title = "My Best Post"; Console.WriteLine(context.Entry(post).State); //或者
DbEntityEntry entry = context.Entry<Post>(post);
Console.WriteLine(entry.State); }

Post实体已经发生了改变,因此将有理有据的输出如下:

EntityFramework之DetectChanges's Secrets(三)(我为EF正名)

在上述中当调用SaveChanges时会回调DetectChanges,其实DetectChanges方法中的所谓的修改发生在各个时期,请看下面代码,在上下文中查找实体:

            using (var context = new AnotherBlogContext())
{
var post = context.Posts.First(p => p.BlogId == );
post.BlogId = ; var blog2 = context.Blogs.Find(); Assert.Same(blog2, post.Blog);
Assert.Contains(post, blog2.Posts);
}

上述代码一个带有外键为(BlogId)为1的Post实体,然后改变其外键值为2,接下来我们要找到Blog中主键为2的实体,当Post实体被带入到上下文中时,EF实体对其进行修改。这似乎是可能的,如果EF现在知道Post实体的外键为2,并且回调了DetectChanges方法的话,这种情况将是可以发生的。结果进行验证,断言通过,说明Find方法回调了DetectChanges方法。

会回调DetectChanges方法的方法

从以上显而易见,DetectChanges方法通常需要通过DbContext中的方法被回调同时其关联类也如预期一样相应的进行回调。这就是为什么DetectChanges方法会被如下方法回调:

DbSet.Find
DbSet.Local
DbSet.Remove
DbSet.Add
DbSet.Attach
DbContext.SaveChanges
DbContext.GetValidationErrors
DbContex.Entry
DbChangeTracker.Entries

特别注意SaveChanges和ValidateEntity

DetectChanges被调用是SaveChanges实现的一部分,这就意味着,如果你在上下文中重写了SaveChanges的话,那么在你的SaveChanges被调用之前DetectChanges不会被回调的。这让人很恼火,特别是当检测一个实体是被修改还是未被修改的时候,因为它的状态可能直到DetectChanges被回调的时候才被修改,可喜的是,像这种情况用DbContext中的SaveChanges比过去用ObjectContext中的SaveChanges发生的概率要低很多,因为Entry和Entry方法被设计用来访问实体中的状态并自动调用DetectChanges。

ValidateEntity是在GetValidationErrors或者SaveChanges期间的自定义验证,与SaveChanges不同的是,它会在DetectChanges方法调用之后再调用,道理很简单,验证肯定是在保存的基础上进行验证,这也就是在DetectChanges调用之后调用的原因。通常在验证类(ValidateEntity)上代码是不会修改实体上的属性值,仅仅是进行验证而已,但是如果改变了其属性值的话,因此必须手动再一次调用DetectChanges方法来保证修改之后的数据能正确的被保存。有一种可行的方式来对其实体属性值进行修改,请继续看接下来的内容。

为什么并不是上下文中所有方法调用DetectChanges方法呢?

在任何时候,只要一个POCO实体可能已经发生改变就调用DetectChanges方法,付出的代价是很显然的。例如,在每个实体实例化后就执行查询语句并返回其代码,换句话说,一个实体查询要执行很多次。当处理实体时,如果应用程序代码发生了改变,那么理论上就影响了修改下一个实体的行为。这种情况比较复杂,就不再演示。

看如下代码:

using (var context = new AnotherBlogContext())
{
foreach (var post in context.Posts)
{
Console.WriteLine(post.Title);
}
}

如果实体行为发生了改变那么在任何时候就会自动调用DetectChanges方法,如果Posts有1000个,那么将运行这个代码1000次,那么结果将是EF会调用DetectChanges方法至少1000次。

因此,在每个可能场合调用DetectChanges代价将是非常高的(消耗性能),但是一直不自动调用它,在常见场景下将会导致意想不到和很不直观的结果,这也就是为什么在常见必须的地方自动调用它,但是不是每个地方都是必须要调用的。因此接下来我们将学习关闭自动调用的DetectChanges以及如何去确定在什么场景下来手动调用它。

关闭自动调用的DetectChanges

如果你的上下文不跟踪许多实体,你根本就不需要关闭DetectChanges。对于大部分应用程序也是如此,特别是应用程序利用短周期的上下文来作为最佳方式,例如,在每个请求的上下文的web应用程序中。

如果你的上下文跟踪数千个实体,只要你的应用程序不回调DetectChanges方法很多次,那么通常你也可以不需要关闭自动调用的DetectChanges方法。当你的应用程序正追踪许多实体并且重复调用要调用DetectChanges方法的方法之一时,这个时候你就得考虑关闭自动调用的DetectChanges了。

最常见的例子就是在一个上下文中循环添加一个集合,如下:

public void AddPosts(List posts)
{
using (var context = new AnotherBlogContext())
{
posts.ForEach( p => context.Posts.Add(p));
context.SaveChanges();
}
}

在上述例子中,每一次循环添加一次数据都要造成一次调用DetectChanges,这也就造成了操作执行O(N^2),N为集合中Posts的数量,如果N足够大,可想而知,付出的代价是很惨淡的,为了避免了此种情况发生,在其潜在一段时间内一直持续某个操作时来手动关闭DetectChanges。如下:

public void AddPosts(List posts)
{
using (var context = new AnotherBlogContext())
{
try
{
context.Configuration.AutoDetectChangesEnabled = false;
posts.ForEach(p => context.Posts.Add(p));
}
finally
{
context.Configuration.AutoDetectChangesEnabled = true;
}
context.SaveChanges();
}
}

*通常使用try/finally来确保自动DetectChanges能重新启动即使是在添加实体过程中抛出异常。

需要打开或关闭DetectChanges的规则

对于上述情况,你又是怎样知道不调用DetectChanges是好的呢?进一步讲,如果你长时间关闭DetectChanges,你又是怎么确定那是好还是不好呢?

答案是EF需要遵守以下两条规则

(规则1) 如果之前未被调用,在没有调用EF代码将离开上下文状态的情况下DetectChanges需要被调用

(规则2)在任何时候,如果非EF代码更改了一个实体或者复杂对象的属性值,那么此时DetectChanges可能需要被调用

根据规则1上述中的Add将不会导致DetectChanges被调用并且根据规则2在post实体上没有做出任何改变,因此也不需要调用DetectChanges。事实上,这也意味着,在自动DetectChanges被重新启动之前,调用SaveChanges也是安全的。

有效利用规则1

如果通过代码改变了实体上的属性值而不仅仅是调用Add或者Attach,通过规则2,DetectChanges将会被调用,至少作为SaveChanges的一部分和也有可能在此之前。

然而,通过规则1能被有效避免,当结合DbContext上的属性API一起使用时,规则1是非常强大的。例如:你想设置一个属性的值,但是你又不想调用DetectChanges,这个时候你可以通过属性API来实现,如下:

public void AttachAndMovePosts(Blog efBlog, List posts)
{
using (var context = new AnotherBlogContext())
{
try
{
context.Configuration.AutoDetectChangesEnabled = false; context.Blogs.Attach(efBlog); posts.ForEach(
p =>
{
context.Posts.Attach(p);
if (p.Title.StartsWith("Entity Framework:"))
{
context.Entry(p)
.Property(p2 => p2.Title)
.CurrentValue = p.Title.Replace("Entity Framework:", "EF:"); context.Entry(p)
.Reference(p2 => p2.Blog)
.CurrentValue = efBlog;
}
}); context.SaveChanges();
}
finally
{
context.Configuration.AutoDetectChangesEnabled = true;
}
}
}

上述方法将给出的所有posts集合附加(Attach)到上下文容器中,另外,用EF:开头的代替了Entity Framework:并且将post转移到不同的blog。

如果通过代码直接改变Post实体的属性值,那么EF将调用DetectChanges来获取发生的更改并进行修改并且更改的能正确的保存到数据库。

代替的是,使用上下文中的Property和Reference方法并且设置两个标题的属性以及通过使用CurrentValue来设置Blog的导航属性。意思就是依据规则1,EF确保即使不调用DetectChanges,一切依然正常运行。也就是说,在打开自动DetectChanges之前,会调用SaveChanges使数据都能准确无误地被保存。

建议

(1)除非你真的需要,建议不要关闭自动DetectChanges,否则将会出现你意想不到的问题

(2)如果你想关闭DetectChanges,请在局部使用try/finally来完成

(3)使用DbContext属性中的APIs来对实体进行更改而无需调用DetectChanges

此外使用DetectChanges还有一些需要注意的地方,请继续往下看。

二进制属性以及复杂类型

DetectChanges和二进制属性

EF支持二进制属性来存储二进制数据。例如,我们在Blog中存储一张标志图片,我们通过添加一个属性到我们的Blog类中。如下:

public byte[] BannerImage { get; set; }

如果你想更改这张图片,那么你必须通过设置一个新的byte[]实例来实现。不要尝试去改变已存在二进制数组的内容,因为DetectChanges不会进入到二进制数组里面来看其内容是否已发生了改变。

换言之,将二进制数组当做是不变的,只有重新设置新的二进制数组实例才能进行数据更新。

复杂类型

不懂复杂类型?请看此链接Complex Type

实体有一个属性,该属性是复杂类型,也就是说有一个复杂的属性,在此复杂属性上建立的对象当然也就是复杂对象。

EF允许复杂类型发生突变,也就是说,你能够改变复杂对象中的属性值并且EF将检测这些更改同时与数据库进行交互,作出适当的更新。

例如:如下,Person实体类带有一个复杂的属性Address,同时Address本身包含一个PhoneNumbers的复杂属性

public class Person
{
public virtual int Id { get; set; }
public virtual string Name { get; set; }
public virtual Address Address { get; set; }
} public class Address
{
public virtual string Street { get; set; }
public virtual string City { get; set; }
public virtual string State { get; set; }
public virtual PhoneNumbers PhoneNumbers { get; set; }
} public class PhoneNumbers
{
public virtual string Home { get; set; }
public virtual string Work { get; set; }
}

如果我们按照如下添加数据,那么你觉得DetectChanges是不必须的?

using (var context = new PeopleContext())
{
context.Configuration.AutoDetectChangesEnabled = false; var person = context.People
.Single(p => p.Name == "Frans"); person.Address.Street = "1 Tall Street";
person.Address.City = "Fairbanks";
person.Address.State = "AK";
person.Address.PhoneNumbers.Home = "555-555-5555";
person.Address.PhoneNumbers.Work = "555-555-5556"; context.SaveChanges();
}

当你尝试进行如上操作后,你会发现数据库中数据没有任何变化!理由是即使Person被代理了,Addres和PhontNumbers未被代理,即使都有Virtual修饰,EF依然不会为复杂类型创建变更跟踪代理,但是,快照式变更追踪和DeteChanges总是应用在复杂类型上。

一切都不会丢失,当使用复杂类型和变更追踪代理时,你仍然可以避免使用DetectChanges或者说避免需要DetectChanges。只要你将对象当做是不变的,在实际应用中应当认为总是设置一个新的复杂类型的实例,而不是改变已存在实例的属性的值,例如,修改如下才是正确的

using (var context = new PeopleContext())
{
context.Configuration.AutoDetectChangesEnabled = false; var person = context.People
.Single(p => p.Name == "Frans"); person.Address =
new Address
{
Street = "1 Tall Street",
City = "Fairbanks",
State = "AK",
PhoneNumbers =
new PhoneNumbers
{
Home = "555-555-5555",
Work = "555-555-5556"
} }; context.SaveChanges();
}

所以注意:将复杂类型看做是不变的。总是设置一个新的复杂类型的实例,而不是改变已存在复杂类型实例的值。

突变复杂类型而无需DetectChanges

如果你想突变一个复杂对象并且无需调用DetectChanges,你可以通过规则1来进行如下操作:

using (var context = new PeopleContext())
{
context.Configuration.AutoDetectChangesEnabled = false; var person = context.People
.Single(p => p.Name == "Frans"); var addressEntry = context.Entry(person)
.ComplexProperty(p => p.Address); addressEntry
.Property(a => a.Street)
.CurrentValue = "1 Tall Street"; addressEntry
.Property(a => a.City)
.CurrentValue = "Fairbanks"; addressEntry
.Property(a => a.State)
.CurrentValue = "AK"; addressEntry
.ComplexProperty(a => a.PhoneNumbers)
.Property(p => p.Home)
.CurrentValue = "555-555-5555"; addressEntry
.ComplexProperty(a => a.PhoneNumbers)
.Property(p => p.Work)
.CurrentValue = "555-555-5556"; context.SaveChanges();
}

总结

(1)如果在开发中,实体对象为数不对无需过多考虑变更追踪(DetectChanges)所带来的细微影响。,但是了解其基本原理和一些注意事项对写出性能高的代码也是大有裨益的。

(2)当实体对象较多时,熟悉并运用上面的策略,对其性能的提高可想而知。

(3)吐槽:用EF觉得性能弱爆了的人,其实是自己打自己脸,仅仅关注EF表面实现,而不去了解基本原理就妄下结论。

(4)非常感谢您耐心读完这一长篇大论。