Entity Framework 中数据库关系的基本处理和类型
本文旨在通过介绍基本的数据库概念和 Entity Framework 的实际处理技巧,来满足不同水平读者的需求。
目录
引言
Entity Framework 支持关系数据库的一对多和多对多概念。通常,这个主题并不容易讲得清晰和完整,特别是对于那些数据库知识不足的人来说。本文旨在通过介绍基本的数据库概念和 Entity Framework 的实际处理技巧,来满足不同水平读者的需求。如果您精通数据库,可以跳过数据库部分。如果您精通 Entity Framework,可以跳过大部分内容,先阅读最后的结论和总结,看看能从中获得什么。本文基于 Entity Framework 4.0 ~ 4.3.1。4.3.1 版本是当前发布的版本。现在,让我们先回顾一下基本的数据库关系概念,以便更好地理解 Entity Framework 中的相关功能。
使用代码
示例代码项目包含本文中提到的 `DbContext` 的一些典型 CRUD 操作及其 `UnitTest`。在使用示例代码项目之前,请完成以下两件事:
- 通过在 SQL Server Management Studio 中打开脚本文件 `\DbRelationshipInDbContext\db.sql` 并点击执行来安装数据库。您需要数据库登录的管理员权限才能执行此操作。确保您的计算机上有“`c:\temp\`”文件夹,因为数据库文件将保存在此文件夹中。
- 更新项目配置文件中的数据源部分,将其指向您的数据库服务器。
免责声明:示例代码仅用于解释本文的目的。在 Entity Framework 上实现通用的持久化层是处理数据访问操作的更好方法。
数据库中的关系
一对多关系:实体 A 可以拥有多个实体 B,但实体 B 只能拥有一个实体 A,那么 A 和 B 是一对多关系。例如,一个亲生父亲可以有多个孩子,但一个孩子只能有一个亲生父亲。因此,亲生父亲和孩子是一对多关系。在关系数据库中,一对多关系不需要匹配表,而是通过外键来处理。实际上,我们可以将“一对”端的表中的主键作为“多”端表中的外键。对于亲生父亲和孩子表的示例,我们可以在 `Child` 表中添加一个名为 `FatherId` 的列,以便与 `Father` 表中的父亲匹配。`Father` 表中的 `FatherId` 是主键;`Child` 表中的 `FatherId` 是外键。因此,父亲和孩子之间的一对多关系可以通过主键和外键关系得到很好的表示。
多对多关系:实体 A 可以拥有多个实体 B;实体 B 可以拥有多个实体 A。这种关系通常需要在关系数据库中使用匹配表进行建模。例如,`Student` 和 `Club` 是多对多关系,因为一个学生可以加入多个俱乐部;一个俱乐部可以有很多学生。在关系数据库中,一个名为“`StudentClubMatch`”的匹配表用于表示这种多对多关系。至少,该表应该包含 `StudentId` 和 `ClubId` 列,以构成一个复合主键。
从技术上讲,在添加匹配表后,多对多关系已被分解为两个一对多关系。例如,由于匹配表 `StudentClubMatch`,`Student` 和 `Club` 的多对多关系已被分解为以下两个一对多关系:
`Student` 和 `StudentClubMatch`:一对多,因为一个 `student` 可以有多个 `StudentClubMatch` 记录,但一个 `StudentClubMatch` 记录只能有一个 `student`。
`Club` 和 `StudentClubMatch`:一对多,因为一个 `Club` 可以有多个 `StudentClubMatch` 记录,但一个 `StudentClubMatch` 记录只能有一个 `Club`。
Entity Framework 中的数据库关系处理
概述
为了让 Entity Framework 了解这些关系,您需要借助图表功能首先在 SQL Server 或 Sql Server Express 中创建关系:下面是使用 SQL Server Express 为 `student`-`class` 关系和 `student`-`club` 关系创建的关系图。
Entity Framework 中的数据库关系处理可能不完全等同于数据库中的处理。例如,类型 I 多对多关系的处理在数据库和 Entity Framework 之间有所不同;您稍后会在本文中看到这一点。下面是 Entity Framework 从上述数据库生成的 Entity Framework 图;您可以将其与上述数据库图进行比较,以查看差异。Entity Framework 在实体类中添加了导航属性来处理数据库关系。通常有两种类型的导航属性:一端是一个实体对象,另一端是一个集合对象。从图 2 中,您可以看到每个实体列出的导航属性。例如,属性 `Class.Teacher` 是一个单端导航属性;属性 `Teacher.Classes` 是一个多端导航属性。
Entity Framework 中的一对多关系建模
Entity Framework 将通过导航属性来建模这种关系:一个外键导航属性表示一端,一个集合导航属性表示多端。例如,实体类 `Class` 有一个外键导航属性 `Teacher` 来表示一个老师,实体类 `Teacher` 有一个集合类型的导航属性 `Classes` 来表示多个班级。比较上面的图 1 和图 2,您可以发现数据库和 Entity Framework 处理这种关系的方式非常相似。
Entity Framework 中的多对多关系建模
在关系数据库中,使用匹配表来表示多对多关系。Entity Framework 会根据匹配表中除了两个外键列之外是否还有其他列,以两种可能的方式来处理它。
在这种情况下,Entity Framework 使用每个涉及实体类中的集合类型导航属性来建模。例如,导航属性 `Student.Classes` 和导航属性 `Class.Students` 表示 `Student` 和 `Class` 之间的多对多关系。
- 类型 I:真正多对多建模。如果匹配表只有两个外键列,Entity Framework 将会省略匹配表。例如,数据库中的匹配表 `StudentClassMatch` 没有出现在上面的 Entity Framework 图 2 中。为什么?Entity Framework 可以在不使用匹配表的情况下解决所有问题。
- 类型 II:两个一对多建模。如果匹配表除了两个外键列之外还有其他列,Entity Framework 将需要保留匹配表。因此,多对多关系被分解为两个一对多关系。匹配表 `StudentClubMatch` 属于这种情况。表 `StudentClubMatch` 有一个额外的列 `IsCommiteMember`,因此 Entity Framework 必须包含此表。在上面的图 2 中,生成了实体类 `StudentClubMatch`。现在,`Student` 和 `Club` 之间的多对多关系被分解为两个一对多关系:`Student` 和 `StudentClubMatch` 之间的一对多,`Club` 和 `StudentClubMatch` 之间的一对多。
Entity Framework 中的延迟加载
在 Entity Framework 4.0~4.3 中启用延迟加载时,相关对象仅在访问导航属性时自动加载。这很好,因为它避免了加载不必要的内容,从而可能节省大量资源。
启用延迟加载的方法
在 `ObjectContext` 中,将 `ObjectContext.ContextOptions.LazyLoadingEnabled` 设置为 `true`。
但是,在 `DbContext` 中,您需要将 `DbContext.Configuration.LazyLoadingEnabled` 和 `DbContext.Configuration.ProxyCreationEnabled` 都设置为 `true`。否则,延迟加载将不起作用。这就是为什么许多人在 Code first EF 4.3.1 中发现延迟加载不起作用,因为他们不知道需要 `ProxyCreationEnabled`。为什么我们需要将 `ProxyCreationEnabled` 设置为 true?`DbContext` 实际上是基于 `DbContext` 代码生成器在 `ObjectContext` 之上构建的。生成的实体类实际上是 POCO 实体,它们不像继承自 `EntityObject` 的对象那样具有相同的关系要求,因此它们不能直接支持延迟加载。为了支持这些生成的 POCO 实体的延迟加载,需要在运行时为这些 POCO 实体创建代理;代理类是这些 POCO 实体的子类。
`LazyLoadingEnabled` 和 `ProxyCreationEnabled` 的默认值因 `DbContext` 和 `ObjectContext` 而异,也因不同的代码生成器而异,因此,在创建 `ObjectContext` 或 `DbContext` 实例时,在代码中显式设置它们是个好主意。
显式加载导航属性
如果 Entity Framework 中的延迟加载被禁用,您可以通过几种不同的方式在代码中显式加载导航属性,例如 `LoadProperty`、`Load`、`Include` 函数等。下面是一些显式加载这些导航属性的示例代码:
在 `ObjectContext` 中
_objectContext.LoadProperty(class, c=>c.Teacher);
_objectContext.LoadProperty(teacher, t=>t.Classes); or:
teacher.Classes.Load(); or:
Teacher teacher = _objectContext.Teachers.Include(o=>o.Classes).FirstOrDefault();
在 `DbContext` 中
_dbContext.ObjectContext.LoadProperty(classObj, c=>c.Teacher);
_dbContext.ObjectContext.LoadProperty(teacher, t=>t.Classes); or
_dbContext.Entry(classObj).Reference(c => c.Teacher).Load();
_dbContext.Entry(teacher).Collection(t => t.Classes).Load(); or:
Teacher teacher = _dbContext.Teachers.Include(o=>o.Classes).FirstOrDefault();
`DbContext` 是基于 `ObjectContext` 构建的,它有一个 `ObjectConext` 属性,您可以直接访问它以获得 `ObjectContext` 的功能。对于 `ObjectContext` 中的 POCO 实体,`Load` 函数将不起作用,请使用 `LoadProperty` 或 `Include`。您可以为 `Include` 传递一个完整的路径,或者链接多个 `Include` 调用。有关 `Include` 的详细信息,请查找并阅读 MSDN 在线文档。
既然有延迟加载功能,为什么我们还需要显式加载?
在某些情况下,我们不希望在 Entity Framework 中启用延迟加载。下面列出了这两种情况:
- 如果一个集合导航属性可能非常庞大,我们应该避免延迟加载。否则,一个导航属性可能会消耗计算机的所有资源。例如,在 `LogType` 和 `Log` 表的情况下,一个 `LogType` 可能有数百万条日志。因此,我们应该避免对 `LogType` 的导航属性 `Log` 进行延迟加载。相反,我们可以通过过滤器显式加载导航属性,以限制检索的数据量;请参阅后面“检索多端导航属性的子集”部分中的详细信息。当然,我们也可以在业务层控制这一点,这样客户端就无法随意执行此危险操作。
- 序列化:如果需要对实体进行序列化,例如在 WCF 应用程序中,最好关闭延迟加载,以避免潜在的链式序列化。链式序列化将消耗大量资源并创建我们可能不想要的庞大序列化数据。
从数据库加载多端导航属性的子集
有时,我们需要从数据库加载多端导航属性的子集,以节省资源,因为多端导航属性的大小可能非常大。此功能在 Entity Framework 中禁用延迟加载时使用。
在 `ObjectContext` 中,`EntityCollection.CreateSourceQuery` 可以实现此目的。
List<Class> listT = teacher.Classes.CreateSourceQuery().Where(o => o.Id<10).ToList();
对于 `DbContext`,使用以下示例方法:
dbCxt.Entry(teacher).Collection(t=>t.Classes).Query().Where(o=>o.Id<10).Load();
上述方法在延迟加载被禁用时是好的。如果启用了延迟加载,则无需使用上述方法,我们可以直接使用集合类型导航属性的 `Where`、`Select` 等过滤器函数,因为整个导航属性已被加载到内存中。但是,上述示例方法是用于从数据库检索子集,而不是从内存中检索。
Entity Framework 中的创建、删除和编辑
下面我们只讨论一些典型的创建、删除和编辑操作,假设所有传入的参数都是分离的实体;还假设 Entity Framework 中的延迟加载已启用。由于灵活的 Linq 功能,从 Entity Framework 读取相对容易;我们将在本文中跳过读取功能。
概述
创建
通常,在 Entity Framework 中处理数据库关系实体的创建有两种方法:
- 基于实体的做法:显式将多端实体添加到 Entity Framework,让 Entity Framework 隐式解析关系,例如下面一节中的函数 AddClassVersion1。
- 基于关系的做法:通过更新导航属性显式创建一对多或多对多关系,并让 Entity Framework 隐式添加此实体,例如下面各节中的函数 AddClassVersion2 和 AddStudentToClass。
删除
对于删除,有三种情况需要区别处理:
- 对于一对多关系,使用基于实体的做法来删除实体,请参见下面的函数 DeleteClass。通过基于关系的做法移除关系可能不会删除实体,具体取决于业务需求,例如函数 RemoveAClassFromTeacher。
- 对于类型 I 多对多关系,使用基于关系的做法从匹配表中删除关系实体,例如下面的函数 RemoveStudentFromClass。
- 对于类型 II 多对多关系,使用基于实体的做法从匹配表中删除关系实体,例如下面的函数 RemoveStudentFromClub。
一对多关系
下面是一些在 `DbContext` 中添加/移除/删除/更新老师的班级的示例函数。`Teacher` 和 `Class` 之间存在一对多关系;`Teacher` 处于一端,`Class` 处于多端。这里,我们主要讨论多端实体:`Class`。
// entity based version
public void AddClassVersion1(Class c)
{
_dbContext.Entry(c).State = EntityState.Added;
_dbContext.SaveChanges();
if (c.Teacher == null)
c.Teacher = _dbContext.Teachers.Single(o => o.Id == c.TeacherId);
}
// relationship based version
public void AddClassVersion2(Class c)
{
if (c.Teacher == null)
c.Teacher = _dbContext.Teachers.Single(o => o.Id == c.TeacherId);
c.Teacher.Classes.Add(c);
_dbContext.SaveChanges();
}
public void UpdateClass(Class c)
{
C.Teacher = _dbContext.Teachers.Single(o => o.Id == c.TeacherId);
_dbContext.Entry(c).State = EntityState.Modified;
_dbContext.SaveChanges();
}
public void RemoveAClassFromTeacher(Class c) //After remove, this class is still in table
{
if (c.Teacher == null) c.Teacher = _dbContext.Teachers.Single(o => o.Id == c.TeacherId);
c.Teacher.Classes.Remove(c);
c.Teacher = null;
_dbContext.SaveChanges();
}
public void DeleteClass(Class c)
{
_dbContext.Entry(c).State = EntityState.Deleted;
_dbContext.SaveChanges();
}
注释:有两版添加 Class 的函数:`AddClassVersion1` 和 `AddClassVersion12`。`AddClassVersion1` 是通过基于实体的做法实现的;`AddClassVersion2` 是通过基于关系的做法实现的。函数 `RemoveAClassFromTeacher` 只是将班级从老师那里移除,而不会从 Entity Framework 中删除 `Class`。在此操作之后,`Class` 的 `TeacherId` 和 `Teacher` 变为 `null`。为了使此函数能够工作,数据库表必须允许 `Class.TeacherId` 为 `null`,然后自动生成的实体 `Class` 将具有 `TeacherId` 属性的 `Nullable<int>` 类型。函数 `DeleteClass` 会将此班级完全从 Entity Framework 和数据库表中删除。单端实体老师的添加、编辑和删除操作很简单;这里没有示例。对于 `teacher` 的删除,我们可以删除此 `teacher` 的所有班级,或者保留 `teacher` 的所有班级,但将每个班级的 `TeacherId` 设置为 `null`,这取决于业务需求。
即使我们在 Entity Framework 中启用了延迟加载,在函数 `AddClassVersion1` 中,我们仍然显式加载外键导航属性 `Class.Teacher`。但是,我们不需要显式加载 `Teacher.Classes`,它可以由延迟加载正确加载。我认为这是 `DbContext` 的一个缺陷。即,对于新添加的项目,外键导航属性的延迟加载不起作用,但多端导航属性的延迟加载工作正常,例如 `Teacher.Classes`。此缺陷仅发生在新添加的项目上。如果您上面没有为新添加的项目显式加载 `Class.Teacher`,那么它将是 `null`,如果它还没有在 Entity Framework 的某个地方加载过的话。但是,如果它已经加载过,那么 `C.Teacher` 可以由 Entity Framework 自动解析。而在 `ObjectContext` 中,延迟加载对于所有类型的导航属性都可以正常工作。下面是在 `ObjectContext` 中添加班级的代码;此函数不需要任何显式加载。这是一个基于实体的版本实现;当然,我们也可以通过基于关系的方法来实现。
public void AddClass(Class c) {
_objectContext.Classes.AddObject(c);
_objectContext.SaveChanges();
}
类型 I 多对多关系
Entity Framework 会为此情况省略匹配表。因此,即使数据库中有匹配表,Entity Framework 中也没有任何匹配的实体。`Student` 和 `Class` 是这种情况;`StudentClassMatch` 是数据库中的匹配表,但它不在 Entity Framework 中。请参见图 1 和图 2。下面是一些在 `DbContext` 中处理此关系的示例函数。
public void AddStudentToClass(Student s, Class c)
{
s.Classes.Add(c);
c.Students.Add(s);
_dbContext.SaveChanges();
}
public void RemoveStudentFromClass(Student s, Class c)
{
s.Classes.Remove(c);
c.Students.Remove(s);
_dbContext.SaveChanges();
}
注释:由于 Entity Framework 中没有 `StudentClassMatch` 表的实体类。因此,我们实现 `Add` 和 `Remove` 的唯一方法是使用基于关系的方法。通过更新导航属性,然后记录将被添加到匹配表 `StudentClassMatch` 或从中移除。这种情况没有编辑,因为 `StudentId` 和 `ClassId` 是复合主键,更改其中任何一个实际上都会将当前行转移到 `StudentClassMatch` 表中的另一个不同行。`ObjectContext` 以非常相似的方式处理这种类型的多对多关系。
类型 II 多对多关系
在这种情况下,由于匹配表中存在额外的列,Entity Framework 会保留匹配实体。`Student` 和 `Club` 属于这种情况。请参见图 1 和图 2。这种多对多关系被分解为两个一对多关系。请参见前面数据库部分中的讨论。下面是一些在 `DbContext` 中的示例函数。
// entity based version
public void AddStudentToClubVersion1(Student s, Club c, bool isCommitteMember)
{
//Create and add the matching table record.
StudentClubMatch scMatch = new StudentClubMatch();
scMatch.StudentId = s.Id;
scMatch.ClubId = c.Id;
scMatch.IsCommiteMember = isCommitteMember;
_dbContext.Entry(scMatch).State = EntityState.Added;
_dbContext.SaveChanges();
}
// relationship based version
public void AddStudentToClubVersion2(Student s, Club c, bool isCommitteMember)
{
//Create the matching table record first.
StudentClubMatch scMatch = new StudentClubMatch();
scMatch.Student = s;
s.StudentClubMatches.Add(scMatch);
scMatch.Club = c;
c.StudentClubMatches.Add(scMatch);
scMatch.IsCommiteMember = isCommitteMember;
_dbContext.SaveChanges();
}
public void RemoveStudentFromClub(Student s, Club c)
{
StudentClubMatch scMatch = _dbContext.StudentClubMatches.Single(o=>o.ClubId ==
c.Id && o.StudentId == s.Id);
_dbContext.Entry(scMatch).State = EntityState.Deleted;
_dbContext.SaveChanges();
}
public void UpdateStudentClubMatch(StudentClubMatch match)
{
_dbContext.Entry(match).State = EntityState.Modified;
_dbContext.SaveChanges();
}
注释:`AddStudentToClubVersion1` 是基于实体的;`AddStudentToClubVersion2` 是基于关系的。这种情况有两个一对多关系,因此,在 `AddStudentToClubVersion2` 中,我们需要更新两组一对多关系。
对于 `UpdateStudentClubMatch`,我们唯一可以更新的是额外的列 `IsCommitteMember`。如果您更改 `StudentClubMatch` 的 `StudentId` 或 `ClubId`,您实际上会转移到另一个不同的记录,因为 `StudentId` 和 `ClubId` 是复合主键。
我们如何知道 Entity Framework 中存在哪些关系?
下面列出了几种建议的方法:
- 如果您可以访问 Entity Framework 的 `.edmx` 文件,请点击它,您可以在 Visual Studio 中看到实体关系图。
- 如果您可以访问数据库,请检查 SQL Server 中的数据库图,它会告诉您数据库中的关系。类型 I 多对多关系在 Entity Framework 中的关系会略有不同。
- 或者,您可以检查 Entity Framework 本身生成的实体类。如果您在一个实体类中看到实体类型的属性,并在另一个匹配的实体类中看到相关的集合属性,那么这是一对多关系,例如属性 `Class.Teacher` 和 `Teacher.Classes`。如果您在两个匹配的实体类中都看到相关的集合属性,那么它们是多对多关系,例如属性 `Class.Students` 和 `Student.Classes`。
结论
- 在 Entity Framework 上实现业务操作时,请务必首先弄清楚涉及的操作是什么关系:无关系、一对多或多对多关系。对于每种类型的关系,我们可能需要在 Entity Framework 中以不同的方式进行处理。了解基本数据库关系将非常有帮助。
- 在 Entity Framework 中处理数据库关系实体的创建有两种方法:
- 基于实体的做法:显式将多端实体添加到 Entity Framework,让 Entity Framework 隐式解析关系。
- 基于关系的做法:通过更新导航属性显式创建一对多或多对多关系,并让 Entity Framework 隐式添加此实体。
- Entity Framework 以不同的方式处理两种类型的多对多关系。
类型 I:真正多对多关系建模。对于这种类型,数据库中的匹配表只有两个外键列。Entity Framework 不会为此匹配表创建实体类,因为没有必要。添加和移除此类型的匹配表项是通过基于关系的方法,通过更新两个多端导航属性来实现的。
类型 II:匹配表除了两个外键列之外还有额外的列。Entity Framework 必须创建一个实体类来建模此匹配表。关系被分解为两个一对多关系。此匹配项的添加操作类似于通过基于实体的方法或基于关系的方法处理两个一对多关系。 - 对于删除,有三种情况需要区别处理:
- 对于一对多关系,使用基于实体的做法来删除实体。
- 对于类型 I 多对多关系,使用基于关系的方法从匹配表中删除关系实体。
- 对于类型 II 多对多关系,使用基于实体的做法从匹配表中删除关系实体。
- 导航属性可以由延迟加载加载,也可以由用户手动加载,具体取决于 Entity Framework 中是否启用了延迟加载。
- 对于 `DbContext`,启用延迟加载需要同时将 `DbContext.Configuration.LazyLoadingEnabled` 和 `DbContext.Configuration.ProxyCreationEnabled` 设置为 true。但是,对于 `ObjectContext`,我们只需要将 `ObjectContext.ContextOptions.LazyLoadingEnabled` 设置为 true。
- 显式加载导航属性可以通过多种方式完成,例如 `LoadProperty`、`Load`、`Include` 等函数。`DbContext` 和 `ObjectContext` 有不同的处理方式;有关详细信息,请参阅之前的示例代码。对于 `ObjectContext` 中的 POCO,请勿使用 `Load`,而是使用 `LoadProperty` 或 `Include`。
- 在某些情况下,最好关闭 Entity Framework 中的延迟加载:
- 多端导航属性可能非常庞大,例如 `LogType.Logs`。
- 需要实体序列化,例如 WCF。为避免可能的链式序列化,在此情况下关闭延迟加载。
- 对于新添加的实体,`DbContext` 中的延迟加载似乎有一个缺陷,即如果未在某个地方加载过,那么单端导航属性将不会按预期加载。但多端导航属性则没问题。对于 `DbContext` 中已存在的实体,此缺陷不会发生。对于 `ObjectContext` 中的任何实体,此缺陷都不会发生。
- 对于延迟加载被禁用的情况,可以使用以下示例方法从数据库检索多端导航属性的子集:
在 `ObjectContext` 中
List<Class> listT = teacher.Classes.CreateSourceQuery().Where(o => o.Id<10).ToList();
在 `DbContext` 中
dbCxt.Entry(teacher).Collection(t=>t.Classes).Query().Where(o=>o.Id<10).Load();
- 如果数据库中的列允许为 `null`,则 Entity Framework 支持普通实体和 POCO 实体中的可空属性,例如实体类 `Class` 中的 `TeacherId` 属性。
public Nullable<int> TeacherId { get; set; }