如何从EF导航集合属性中删除元素
Entity Framework 的性能一直被开发人员诟病。但我认为开发人员对EF一知半解、不求甚解才是问题的根源。EF中的上下文管理、延迟加载、变更追踪、并发冲突、事务等主题是我们熟练掌握EF的基础。我不敢说自己对这些主题也十分了解,抱着查缺补漏的心态,趁着双十一打折,我入手了汪鹏的《你必须掌握的Entity Framework 6.x与Core 2.0》。读下来有所收获,但我不得不说书中很多地方有的叙述不通畅,有的则显得过于冗长。同为程序员,我不能苛责太多,对作者还是很钦佩的。在看这本书的过程中,我想起了自己的项目中遇到的一个EF性能问题,但这本书中没有提到,于是想写下来和大家分享。
案例
我们以学生选课这个场景来展示我遇到的这个性能问题。一个学生可以选多门课,一门课程可以被多名学生选择,因此它们之间是多对多的关系。
1 | public class Student |
在OnModelCreating
方法中,我们为这两个表创建多对多关系并指定左右外键和连接表(associative/junction table)的名称。
1 | modelBuilder.Entity<Student>() |
上述代码使用EF 6.3,运行在.NET 4.5.2上,文章末尾有全部代码的链接。EF会根据实体模型的定义推断出表之间的关系。上述代码是可选的,只是创建出来的左右键和表名是根据约定(Convention)来命名的。
associative/junction table
应该翻译成连接表,而不是链接表。
将上述Code First实体模型应用到数据库后,我们可以得到下面的三张表:
现在问题来了。例如我们需要为Id为1的学生取消Id为1的已经选择的课程,该怎么做呢?你会想这很简单:
1 | using (var context = new EFDemoContext()) |
没错,首先找到这两个实体,然后使用第14行的代码,从该学生的课程集合中删除指定的课程对象就可以了。这有什么问题吗?大部分的EF教程不也是这样教我们的吗?
要看到潜在的问题,我们需要分析EF生成的SQL语句。上述第3行代码可以将EF生成的SQL语句输出到Visual Studio的Output窗口。
可以看到,在执行完第14行的代码后,EF先将该学生所有选择的课程查询出来(导航集合属性的延迟加载),加载到内存,然后将要删除的那个课程对象标记为EntityState.Deleted
。在执行context.SaveChanges()
时才会生成delete语句。
读到这里,你也许还没明白我所想要表达的问题。我的问题是,使用实体对象的导航集合属性来对集合元素进行增、删、改操作会导致所有的集合元素被被延迟加载,因为在这种情况无法使用where条件进行事先的过滤。如果导航集合属性的数据集很大,那么应用程序的性能会受到显著的影响。这不仅包括将数据集从数据库传输到应用程序的内存,还包括EF要在这么大的数据集上应用变更追踪检查所带来的损耗。在我的真实场景中,实体对象的导航集合属性大约有2万多条记录,使用上述的方法删除某一个元素,花费了将近20秒!
解决方案
直接手写SQL语句
最容易的办法应该是手写SQL语句,但这样就违背了使用EF的初衷。既然使用了ORM框架,就不应该再直接操作数据库了。1
2
3context.Database.ExecuteSqlCommand("DELETE [dbo].[StudentCourse] WHERE StudentId = @p0 AND CourseId = @p1",
new SqlParameter("@p0", 1),
new SqlParameter("@p1", 1));这个方案简单直接,但对追求卓越代码的程序员而言不完美。
使用
ObjectContext.ObjectStateManager.ChangeRelationshipState
方法
这个方案我是在Stack Overflow上找到的。ObjectContext
是EF 4.0及以下版本中用来进行数据访问的类。EF 4.1及以上的版本就开始使用DbContext
来进行数据访问了。但是DbContext
的底层还是基于ObjectContext
,可以看成是ObjectContext
的一个包装器,提供了简化和方便易用的API。如果要使用复杂的功能,则需要从DbContext
中获取ObjectContext
并且使用基于它的API。1
2
3
4((IObjectContextAdapter)context)
.ObjectContext
.ObjectStateManager
.ChangeRelationshipState(student, course, s => s.Courses, EntityState.Deleted);这个方案使用了一个比较底层和陌生的API,虽然是基于EF自身的方法,但总觉得有些取巧(tricky)。
显式声明连接表
本质上我们遇到的问题是无法直接操作连接表,因为它是EF自动生成的。那么我们可以显式地声明这个连接表吗?答案是肯定的。我们定义如下的连接表:1
2
3
4
5
6
7
8public class StudentCourse
{
public int StudentId { get; set; }
public virtual Student Student { get; set; }
public int CourseId { get; set; }
public virtual Course Course { get; set; }
}此时,Student与Course之间的多对多的关系,就转换成了Student和Course分别与StudentCourse之间的两个一对多关系。在
OnModelCreating
方法中,我们配置如下的关系:1
2
3
4
5
6
7
8
9modelBuilder.Entity<Student>()
.HasMany(s => s.StudentCourse)
.WithRequired(j => j.Student)
.HasForeignKey(j => j.StudentId);
modelBuilder.Entity<Course>()
.HasMany(s => s.StudentCourse)
.WithRequired(j => j.Course)
.HasForeignKey(j => j.CourseId);与此同时,我们需要为连接表定义复合主键:
1
2modelBuilder.Entity<StudentCourse>()
.HasKey(j => new { j.StudentId, j.CourseId });完成上述的准备工作后,我们就可以用如下的代码来完成同样的任务而不用担心性能问题了。
1
2
3
4
5
6
7
8using (var context = new EFDemoContext())
{
context.Database.Log = log => Debug.WriteLine(log);
var studentCourse = context.StudentCourse.Find(1, 1);
context.StudentCourse.Remove(studentCourse);
context.SaveChanges();
}这样EF产生的SQL语句就会非常干净、清爽。
你还可以用使用基于
ObjectContext
的API来删除这个连接关系实体,而不用先从数据库中将其查询出来,从而节省一次数据库访问。这里就不展开叙述了,具体的细节可以参考这里。
总结
本文分析了使用Entity Framework在构建多对多关系模型中潜在的性能问题,并给出了解决问题的不同方案。需要特别指出的是,Entity Framework Core 不存在这样的问题。原因在于EF Core不支持自动生成连接表,在配置多对多的关系时,必须显式地声明连接表。本文相关的代码已分享到Github。