加载与Entity Framework相关的实体 - 初学者指南





5.00/5 (43投票s)
学习如何使用Entity Framework加载相关的实体,并提供简单的示例
引言
本文旨在为初学者提供一个关于使用 Entity Framework (EF) 访问相关实体主题的入门介绍。本文面向的是刚接触实体框架的开发者,无论他们是否经验丰富。
我在本文的代码中使用了 EF6,但它也适用于所有 Code First 版本。在下载的代码中,数据库将在您首次运行时创建并填充初始数据。它使用了 LocalDb
。
背景
在我第一份工作中,一个经验丰富的开发团队正在开发一个使用 EF 作为其对象关系映射器 (ORM) 的应用程序。不幸的是,他们并不理解 EF 的工作方式。这一切都与他们加载相关实体的方式有关。应用程序运行得异常缓慢。最终,其中一人运行了 SQL Server Profiler,发现每次点击页面上的某个按钮时,竟然有大约 10,000 次数据库访问。失败。因此,如果这件事能够难倒一个经验丰富的开发团队,那么它就值得我们深入研究。
我们将涵盖诸如延迟加载 (Lazy Loading)、急切加载 (Eager Loading)、显式加载 (Explicit Loading) 和代理 (proxies) 等概念。我不会过于深入,因为刚接触 EF 的人一开始会觉得各种配置和查询构造的组合相当令人困惑。但我希望在这篇文章中将这些内容清晰地呈现出来,以便刚开始使用 EF 的人们能够快速上手。
另外请注意,这不是一篇关于 LINQ 的文章。但为了让读者了解,我使用了表达式风格的语法(即“点语法”),而不是 SQL 风格。
相关实体
那么,我所说的“相关实体”是什么意思呢?首先,我将向您介绍我们将用于演示本文原理的数据库模式。
假设有人拥有 MSDN 订阅,并希望管理通过该订阅获得的各种软件许可证和文件。以下模式描绘了一个此类场景的示例。
图 1
(为本文的目的)相关实体仅仅是通过外键关系与其他实体相关联的实体。因此,如果我们有一个 Software
实体,那么一组 SoftwareFiles
实体就与该 Software
实体相关联。
在传统的 SQL 数据检索中,我们会使用 JOIN 或子查询来访问有关相关实体的信息。这会产生一个扁平化的 resultset
。
图 2
使用像 EF 这样的 ORM,我们最终会得到一个对象图,它本身并不是扁平化的。
图 3
所有 ORM 在加载相关实体方面都有自己的约定/API。这些 then 构成加载的特定实体对象图的一部分。我们首先要看的第一种访问方式是延迟加载。
懒加载
延迟加载几乎是默认设置。也就是说,如果您保持默认配置不变,并且不在查询中明确告诉 EF 您想要其他类型的加载,那么您就会得到延迟加载。这正是上面我提到的那个开发团队陷入的陷阱。但我将在本节的末尾对此进行评论。
简而言之,延迟加载是指相关实体将在第一次被访问时自动从数据库加载,但在此之前不会加载。请看下面的代码。
列表 1
private static void LazyLoadingRelatedProperty()
{
// Software is loaded as it is accessed in each iteration of the foreach loop
using (var context = new LicenceTrackerContext())
{
foreach (var licence in context.Licences)
{
Console.WriteLine("{0} - {1} - {2}", licence.LicenceKey, licence.Software.Name,
licence.Software.Description);
}
}
}
正如您在向控制台写入某些值的行中所见,每个 Licence
实体的相关 Software
实体在 foreach
循环中被访问。如果只向控制台写入 LicenceKey
,那么相关的 Software
实体将不会从数据库检索。但它被检索了(通过 licence.Software.Name
和 licence.Software.Description
),因此 Software
不是 null
。要加载该实体,我们只需访问 licence
对象中的 Software
属性。
那么,它是如何自动发生的呢?延迟加载的前提条件如下:
- 您的实体类必须是
public
并且不能是sealed
。 - 您希望延迟加载的 导航属性(相关实体)必须是
virtual
属性。 - 需要在上下文中将两个设置配置为
true
:- 延迟加载 (
context.Configuration.LazyLoadingEnabled = true;
) - 代理创建 (
context.Configuration.ProxyCreationEnabled = true;
)
- 延迟加载 (
关于第 1 点和第 2 点,您可以看到 Licence
类是 public
的,不是 sealed
的,并且 Software
属性是 virtual
的。类必须不是 sealed
且导航属性是 virtual
的原因是,EF 在运行时创建代理类,这些类提供了启用延迟加载的基础结构。动态代理是相关实体的派生类。
如果您运行下载代码中的示例应用程序并从菜单中选择 1,则会运行一个延迟加载的示例,该示例会将代理的运行时类型写入输出窗口。
图 4
如果您再次运行应用程序并选择菜单项 2,您将看到当导航属性未标记 virtual
关键字时会发生什么。在这种情况下,我尝试访问 SoftwareFile
类的 Software 导航属性。此时会抛出 NullReferenceException
。选择菜单项 3 将显示当代理未启用时会发生什么。
请注意,是否启用代理的决定比是否延迟加载的决定更广泛。延迟加载不是使用代理的唯一原因。也有不使用代理的原因,例如序列化(EF 创建的代理无法被序列化)。在下载代码中,您会看到我在一些地方添加了注释,表示我禁用了代理。这只是为了演示该操作不需要代理,而不是评论我是否认为应该启用它们。
虽然能够获得这种自动化的、即插即用的功能非常棒,但我们需要了解应用程序访问数据库的情况。以下截图来自 SQL Server Profiler,在我运行了示例应用程序并选择了菜单项 1 之后。
图 5
它清楚地显示,有一个 SELECT
语句发送到数据库,后面跟着 3 个 SELECT
语句。因此,清单 1 中的代码导致了 4 个单独的语句发送到数据库。在这里,我们看到了延迟加载的成本。当相关属性在 foreach
循环中被延迟访问时,就会发送一个 SELECT
语句到数据库。因此,您可能会陷入像我上面描述的那个不幸的开发团队那样的境地,即在 foreach
循环(有数千次迭代)中解析导航属性,导致数千次数据库服务器访问,而不是只有一次。
实际上,相关属性并不是在 foreach
循环的每次迭代中都被访问。在上面的示例中,Id
为 1
的 Software
是两个许可证的相关属性。但是,它只检索一次。因此,数据库访问次数将取决于实体是否在前一个迭代中被返回。
总之,这并不意味着永远不应使用延迟加载。您可能绝对希望利用它的情况。您可能只想访问某个许可证的相关属性(参见清单 2)。但请注意潜在的多个 SQL 语句,并确保分析这是否比急切加载(即将介绍)更好。
列表 2
foreach (var licence in context.Licences)
{
if (licence.Id == 2)
{
// This only gets lazy loaded for the licence with an id of 2
Console.WriteLine("{0} - {1} - {2}",
licence.LicenceKey,
licence.Software.Name,
licence.Software.Description
);
}
}
延迟加载甚至在数据库检索已被强制执行时也有效(参见清单 3)。您可能还记得 IQueryable
在被告知执行之前实际上并没有访问数据库(延迟执行)。所以这意味着您可以实际强制执行一次,然后依靠延迟加载来访问相关实体。示例代码中的菜单项 4 正是这样做的。
列表 3
private static void LazyLoadingAfterQueryHasBeenForced()
{
using (var context = new LicenceTrackerContext())
{
var licences = context.Licences.ToList(); // First Database round trip is here
foreach (var licence in licences) // Further round trips happen in here
{
Console.WriteLine("{0} - {1} - {2}",
licence.LicenceKey,
licence.Software.Name,
licence.Software.Description
);
}
}
}
急切加载
急切加载基本上是相反的想法。与其等待某个条件出现才加载相关属性,不如无论如何都加载所有相关实体;我们指示 Entity Framework 提前加载这些相关实体。这会导致一个 SELECT
命令发送到数据库,通常包含各种 JOIN,具体取决于您在对象图中急切加载的深度。它会导致整个对象图一次性显现(在告诉 EF 访问数据库的正常操作之一期间,即一次迭代、一次调用 ToList()
或 Single()
等)。
代码看起来与之前非常相似,只是使用了 Include
扩展方法。
列表 4
using (var context = new LicenceTrackerContext())
{
// Include brings in the related Software property of the Licences objects
// It does not matter whether the property is marked virtual or not.
context.SoftwareFiles.Include(s => s.Software).ToList()
.ForEach(s => Console.WriteLine("{0} - {1}", s.Software.Name, s.Software.Description));
}
在该示例中,我们通过 SoftwareFile
对象的 Software 导航属性进行导航。通过使用 Include
,我们可以指示 EF 使用一个查询语句来检索数据,而不管 foreach
循环的迭代次数。Include
的 lambda 重载为我们提供了强类型,提供了极其有用的 IntelliSense,这让我们确信我们走在正确的道路上。
如果您正在将您的思路联系起来,那么很明显,我之前提到的开发团队应该使用急切加载而不是延迟加载。它会返回一个大的 dataset
,但只有 1 次数据库命中。
我将在本文后面探讨一些更复杂的涉及急切加载的场景。在那之前,我们将介绍显式加载。
显式加载
显式加载就是字面意思;明确。与延迟加载不同,关于何时运行查询,没有歧义或混淆的可能性。在下一个示例中,我将检索单个 LicenceAllocation
实例并显式加载 Person
导航属性。显式加载是通过上下文的 Entry
方法完成的。
列表 5
private static void ExplicitRelatedProperty()
{
using (var context = new LicenceTrackerContext())
{
// Don't need proxies when explicitly loading.
context.Configuration.ProxyCreationEnabled = false;
var licenceAllocation = context.LicenceAllocations.Single(la => la.Id == 1);
context.Entry(licenceAllocation)
.Reference(la => la.Person)
.Load();
Console.WriteLine("This Licence allocation is allotted to {0} {1}",
licenceAllocation.Person.FirstName,
licenceAllocation.Person.LastName);
}
}
Entry
返回一个 DbEntityEntry
类型的对象。它有许多 public
方法,但我们感兴趣的用于显式加载相关实体的两个是 Reference
和 Collection
。在导航属性不是集合的情况下,您可以通过调用 Reference
方法并传递一个 lambda 来显式加载相关实体,如清单 5 所示。在此之后,并继续使用 Fluent API,您将调用 Load
方法来实际调用查询并加载实体。
对于导航属性是集合的情况,请使用 DbEntityEntry
对象的 Collection
方法。所有其他内容与非集合导航属性相同。
列表 6
private static void ExplicitRelatedCollectionProperty()
{
using (var context = new LicenceTrackerContext())
{
// Don't need proxies when explicitly loading.
context.Configuration.ProxyCreationEnabled = false;
var softwareType = context.SoftwareTypes.Single(st => st.Id == 2);
context.Entry(softwareType)
.Collection(st => st.SoftwareProducts)
.Load();
Console.WriteLine("This SoftwareType has the following {0} products:",
softwareType.SoftwareProducts.Count);
foreach (var softwareProduct in softwareType.SoftwareProducts)
{
Console.WriteLine("{0}", softwareProduct.Name);
}
}
}
您不太可能看到显式加载与其他类型的加载一样多。人们倾向于将数据上下文抽象到一个 Repository 接口后面,我从未在 Repository 中见过显式加载的暴露。但这只是轶事,基于我的经验。
深入理解 Include
我们在上面看到了如何使用 Include
方法预先加载实体的一个相关属性,该方法使用 lambda 来指定要包含的属性。
context.SoftwareFiles.Include(s => s.Software)
如果我们想深入对象图的下一层怎么办?例如,我们从 Licences 实体集开始,对于每个 Licence,我们想急切加载 Software,然后对于每个 Software
实体,我们想急切加载其 Type
导航属性(SoftwareType
实体)。这看起来将像清单 7。
列表 7
using (var differentContext = new LicenceTrackerContext())
{
// When using Include, no proxies are required.
differentContext.Configuration.ProxyCreationEnabled = false;
foreach (var licence in differentContext.Licences.Include(l => l.Software.Type))
{
Console.WriteLine("{0} - {1} - {2} - {3}",
licence.LicenceKey,
licence.Software.Name,
licence.Software.Description,
licence.Software.Type.Description);
}
}
您可以看到,我通过在 lambda 表达式中访问 Software 的 Type
属性,深入了对象图一层。这很简单,但当涉及到集合时,情况会略有不同。
让我们用一个例子来看看它是如何完成的,我们从 People
实体开始。我们的目标是从 People
导航到 Software
。从这个角度来看,我们可以看到每个 Person
实体的 LicenceAllocations
集合需要遍历。其语法是:
列表 8
private static void EagerLoadingThroughCollections()
{
using (var context = new LicenceTrackerContext())
{
context.People.Include(p => p.LicenceAllocations.Select(la => la.Licence.Software)).ToList()
.ForEach(p =>
{
if (p.LicenceAllocations.Any())
Console.WriteLine("{0} - {1}",
p.LicenceAllocations.First().Licence.Software.Name,
p.LicenceAllocations.First().Licence.LicenceKey
);
});
}
}
因此,对于每个 LicenceAllocation
集合,都会调用 LINQ 的 Select
扩展方法,并传入一个 lambda 来指定我们想要急切加载的每个 LicenceAllocation
的属性。我通过 Licence
导航属性进行访问,以获取每个 Licence
的 Software
实体。正如 Include
一贯的那样,这只会导致一条语句在数据库上执行。您可以使用 Profiler 来检查。
但是,当您沿着对象图的某个路径导航到某个级别,并且您想在您之前急切加载的路径上的另一个分支急切加载某些内容时,该怎么办?这很拗口,所以我将用一个例子来澄清。假设您的查询中到目前为止有一个 Include
调用,它从 Licences
实体开始,并通过 LicenceAllocations
急切加载到 People
。
Licences -> LicenceAllocations -> People
现在,在同一个查询中,您还想通过 LicenceAllocations
急切加载到 SoftwareFiles
。
Licences -> LicenceAllocations -> Licence -> Software -> SoftwareFiles
因此,您在对象图中的导航有效地从 LicenceAllocation
对象处分支。
该查询所需的 Include
调用将如清单 8 所示。
列表 8
private static void EagerLoadingThroughCollectionsAgain()
{
using (var context = new LicenceTrackerContext())
{
context.Configuration.ProxyCreationEnabled = false;
var licencesQuery = context.Licences
.Include(s => s.LicenceAllocations.Select(la => la.Person))
.Include(p => p.LicenceAllocations.Select(la => la.Licence.Software.SoftwareFiles));
foreach (var licence in licencesQuery)
{
licence.LicenceAllocations
.Select(l =>
string.Concat(
l.Person.LastName, ", ",
l.Person.FirstName, " - ",
l.Licence.Software.SoftwareFiles.Select(sf => sf.FileName).First()))
.ToList()
.ForEach(Console.WriteLine);
}
}
}
因此,您可以看到这里的策略不是尝试在单个 Include
方法中同时处理两条路径,而是实际使用 2 个 Include
方法,因为它们“终止”于不同的路径。
结论
ORM 框架都有自己独特的方式将扁平化的 SQL 结果集转化为更接近所关心的域的对象图。本文是关于 EF 执行此功能的介绍。
讨论的每种替代方案都有其优点和缺点;可以说,它们都有权衡。因此,有些方案比其他方案更适合某些场景。
它还讨论了代理如何在混合中使用,但指出在决定启用/禁用它们时有几件事需要考虑。不使用延迟加载并不是这方面的唯一考虑因素。
最后,要知道发生了什么。如果您看到一些不寻常的情况,或者想知道为什么一个查询花费如此长的时间,请启动 SQL Server Profiler 来确切了解 LINQ-to-Entities 生成了什么 SQL。(如果您正在使用 ASP.NET 开发 Web 应用程序,还有一个 Glimpse 插件也可以显示生成的 SQL)。查看语句执行了多少次。让黑盒子变成透明的。
历史
文章
版本 | 日期 | 摘要 |
1.0 | 2014 年 7 月 25 日 | 原始发布文章 |
代码
版本 | 日期 |
1.0 | 2014 年 7 月 25 日 |