第2章 查询数据库

发布时间 2023-12-29 15:01:35作者: 生活的倒影

主要内容

  • 对三种主要类型的数据库关系进行建模
  • 通过迁移创建和更改数据库
  • 定义和创建应用程序 DbContext
  • 加载相关数据
  • 将复杂查询拆分为子查询

本章介绍如何使用 EF Core 读取(称为查询)数据库。你将创建一个数据库,其中包含 EF Core 中的三种主要类型的数据库关系。在此过程中,你将了解如何通过 EF Core 创建和更改数据库的结构。

接下来,学习如何通过 EF Core 访问数据库,并从数据库表中读取数据。在了解使用主体数据及关联数据的各种方法之前,你将探索 EF Core 查询的基本格式,例如使用第 1 章中的 Books 数据表加载作者。

在学习了加载相关数据的方法以后,开始构建使图书销售网站正常工作所需的更复杂的查询,其中包括排序、筛选和分页,以及将这些单独的查询命令组合成一个复合数据库查询的方法。

提示:我已使用单元测试来确保我在本书中写的内容是正确的。您可能希望查看/运行这些单元测试,因为它们可以帮助您了解正在发生的事情。可以在 http://mng.bz/XdlG 的关联 GitHub 存储库中找到它们。 查看存储库中的自述文档,了解有关在何处查找单元测试以及如何运行单元测试的信息。

2.1 设置场景:图书销售网站

在本章中,您将开始构建示例图书销售网站,从现在开始称为图书应用程序。此示例应用程序为查看查询中的关系提供了一个很好的工具。本部分介绍图书应用访问数据库所需的数据库、各种类和 EF Core 部件。

2.1.1 Book App 的关系数据库

虽然我们可以创建一个数据库,将一本书、其作者和评论的所有数据放在一个表格中,但这在关系数据库中效果不佳,特别是因为评论的长度是可变的。关系数据库的规范是拆分任何重复的数据(例如作者)。

我们本可以通过多种方式在数据库中排列书籍数据的各个部分,但对于此示例,数据库具有 EF Core 中可以拥有的三种主要关系类型。这三种类型是

  • 一对一的关系 —— 一本书的价格
  • 一对多关系 —— 图书的评论
  • 多对多关系 —— 与作者链接的图书和与类别关联的图书

一对一的关系:一本书的价格

图书可以选择 PriceOffer 中的促销价格,这是一对一关系的一个示例。(从技术上讲,这种关系是一比零或一,但 EF Core 以相同的方式处理它)。参见图 2.1。

图 2.1 Book 和可选 PriceOffer 之间的一对一关系。如果 PriceOffer 链接到图书,则 PriceOffer 中的 NewPrice 将覆盖图书中的价格。

若要计算图书的最终价格,您需要检查 PriceOffer 表中是否存在通过外键链接到图书的行。如果找到这样的行,则 NewPrice 将取代原始图书的价格,并且 PromotionalText 将显示在屏幕上,如下例所示:

$40 $30 我们的夏季特价,仅限本周!

高级功能:在此示例中,有一个主键和一个外键,以使关系更易于理解。但对于一对一的关系,您也可以将外键设置为主键。在图 2.1 所示的 PriceOffer 表中,将有一个名为 BookId 的主键,它也是外键。因此,您可以放弃 PriceOfferId 列,这使得该表在数据库端的效率略高。我将在本书后面的第 8.6.1 节中介绍这个主题。

一对多关系:对一本书的评论

您想让客户评论一本书;他们可以给一本书点赞,也可以选择发表评论。由于一本书可能没有评论或许多(无限制的)评论,因此您需要创建一个表格来保存该数据。在此示例中,您将表命名为 Review。Books 表与 Review 表具有一对多关系,如图 2.2 所示。

图 2.2 一本书与其零对多评论之间的一对多关系。这些评论的工作方式与在任何电子商务网站(例如亚马逊)上的工作方式相同。

在摘要显示中,您需要计算评论数量并计算出平均星级以显示摘要。下面是您可能从这种一对多关系中生成的典型屏幕显示:

评分 4.5 来自 2 顾客

多对多关系:手动配置

书籍可以由一位或多位作者撰写,作者可以撰写一本或多本书。因此,您需要一个名为 Books 的表来保存书籍数据,另一个名为 Authors 的表来保存作者。Books 和 Authors 表之间的链接称为多对多关系,在这种情况下,需要一个链接表来实现这种关系。

在这种情况下,您可以创建自己的链接表,其中包含 Order 值,因为书中作者的姓名必须按特定顺序显示(图 2.3)。

图 2.3 在 Books 表和 Authors 表之间创建多对多关系所涉及的三个表。我使用多对多关系,因为书籍可以有很多作者,而作者可能写过很多书。这里需要的额外功能是 Order 值,因为作者在书中列出的顺序很重要,因此我使用 Order 值以正确的顺序显示作者。

多对多关系的典型屏幕显示如下所示:

作者:Dino Esposito, Andrea Saltarello

多对多关系:由 EF CORE 自动配置

书籍可以标记不同的类别(例如 Microsoft .NET、Linux、Web 等),以帮助客户找到有关他们感兴趣的主题的书籍。一个类别可能应用于多本书,而一本书可能有一个或多个类别,因此需要一个多对多链接表。但与之前的 BookAuthor 链接表不同,标签不必排序,这使得链接表更简单。

EF Core 5 及更高版本可以自动创建多对多链接表。图 2.4 显示了具有自动 BookTag 表的数据库,该表在 Books 表和 Tags 表之间提供多对多链接。BookTag 表显示为灰色,表示 EF Core 会自动创建它,并且它不会映射到你创建的任何类。

图 2.4 Books 和 Tags 表由您创建,EF Core 检测 Books 表和 Tags 表之间的多对多关系。EF Core 会自动创建设置多对多关系所需的链接表。

注意:第 8 章介绍了创建多对多关系的不同方法。

来自多对多关系的典型屏幕显示如下所示:

分类: Microsoft .NET, Web

2.1.2 本章未涉及的其他关系类型

在 2.1.1 节中介绍的三种关系类型是您将使用的主要关系:一对一、一对多和多对多。但 EF Core 确实还有一些其他变体。以下是第 8 章后面内容的简要概述:

  • Owned Type 类 —— 用于将分组数据(例如 Address 类)添加到实体类中。 Address 类链接到主实体,但您的代码可以围绕 Address 类进行复制,而不是复制各个街道、城市、州和相关属性。
  • 表拆分 —— 将多个类映射到一张表。例如,您可以拥有一个包含基本属性的摘要类和一个包含所有数据的详细类,这将使您能够更快地加载摘要数据。
  • 每个层次结构表 (TPH) —— 对于相似的数据组很有用。如果您有大量只有少量差异的数据(例如动物列表),则可以拥有 Dog、Cat 和 Snake 类可以继承的 Animal 基类,并具有每种类型的属性,例如 Dog 和 Cat 的 LengthOfTail和蛇的毒旗。 EF Core 将所有类映射到一张表,这样可以更高效。
  • 每个类型的表(TPT)—— 对于具有不同数据的数据组很有用。 TPT是EF Core 5中引入的,与TPH相反,每个类都有自己的表。按照 TPH 的 Animal 示例,TPT 版本会将 Dog、Cat 和 Snake 类映射到数据库中的三个不同表。

这四种关系模式内置于 EF Core 中,允许您优化在数据库中处理或存储数据的方式。但另一种关系类型不需要特定的 EF Core 命令来实现:分层数据。分层数据的一个典型示例是 Employee 类,该类具有指向员工经理的关系,而经理又是员工。 EF Core 使用与一对一和一对多相同的方法来提供层次关系;我在第 6 章和第 8 章中详细讨论了这种类型的关系。

2.1.3 显示所有表的数据库

图 2.5 显示了您将在本章和第 3 章中的示例中使用的图书应用程序数据库。它包含我到目前为止描述的所有表,包括 Books 表中的所有列和关系。

注意:数据库关系图使用与第 1 章中相同的布局和术语:PK表示主键,FK表示外键。

图 2.5 图书应用程序的完整关系数据库架构,显示用于保存图书信息的所有表及其列。您可以创建类以映射到此图中看到的所有表,但 BookTags 表(显示为灰色)除外。当 EF Core 发现 Books 和 Tags 表之间存在直接的多对多关系时,它会自动创建 BookTags 表。

为了帮助您理解此数据库,图 2.6 显示了书籍列表的屏幕输出,但仅关注一本书。如您所见,图书应用程序需要访问数据库中的每个表才能构建图书列表(第 2.6 节中的图 2.10)。稍后,我将向您展示相同的书籍显示,但带有提供每个元素的查询)。

图 2.6 显示哪个数据库表提供信息的每个部分的单本书列表。如您所见,该列表需要来自所有五个数据库表的信息才能创建此视图。在本章中,您将构建代码来生成此显示,并具有各种排序、筛选和分页功能,以创建适当的电子商务应用程序。

从 Git 存储库下载并运行示例应用程序

如果要下载图书应用程序代码并在本地运行,请按照第 1.6.2 节中同名的侧边栏中定义的步骤进行操作。master 分支包含本书第 1 部分的所有代码,其中包括 BookApp ASP.NET Core 项目。

2.1.4 EF Core 映射到数据库的类

我创建了五个 .NET 类来映射到数据库中的六个表。对于多对多链接表,这些类称为 Book、PriceOffer、Review、Tag、Author 和 BookAuthor,它们被称为实体类,以表明它们由 EF Core 映射到数据库。从软件的角度来看,实体类并没有什么特别之处。它们是普通的 .NET 类,有时称为普通的旧 CLR 对象 (POCO)。术语“实体类”将该类标识为 EF Core 已映射到数据库的类。

主实体类是 Book 类,如下面的清单所示。您可以看到它引用了单个 PriceOffer 类、Review 类实例的集合、Tag 类实例的集合,最后是 BookAuthor 类的集合,这些类将书籍数据链接到一个或多个包含作者姓名的 Author 类。

清单 2.1 Book 类,映射到数据库中的 Books 表

public class Book  // The Book class contains the main book information.
{
    // 我们使用 EF Core 的 By Convention 配置来定义此实体类的主键,因此我们使用 Id,并且由于该属性的类型为 int,因此 EF Core 假定数据库将在添加新行时使用 SQL IDENTITY 命令创建唯一键。
    public int BookId { get; set; } 
    public string Title { get; set; }
    public string Description { get; set; }
    public DateTime PublishedOn { get; set; } 
    public string Publisher { get; set; } 
    public decimal Price { get; set; }
    public string ImageUrl { get; set; }

    // relationships
    public PriceOffer Promotion { get; set; }  // 链接到可选的一对一PriceOffer关系。
    public ICollection<Review> Reviews { get; set; }   // 对这本书的评论可能为零到很多。
    public ICollection<Tag> Tags { get; set; }   // EF Core 5 与 Tag 实体类的自动多对多关系
    public ICollection<BookAuthor> AuthorsLink { get; set; }  // 提供指向多对多链接表的链接,该链接表链接到本书的作者
}

注意:在第 1 部分中,实体类使用默认(空)构造函数。如果要为任何实体类创建特定的构造函数,则应注意 EF Core 在读取和创建实体类的实例时可能会使用构造函数。我在第 6.1.11 节中介绍了这个主题。

为简单起见,我们使用 EF Core 的 By Convention 配置方法对数据库进行建模。我们对每个实体类中保存主键和外键的属性使用按约定命名。此外,导航属性的 .NET 类型(如 ICollection Reviews)定义了我们想要的关系类型。例如,由于 Reviews 属性属于 .NET 类型 Icollection,因此该关系是一对多关系。第 7 章和第 8 章介绍了用于配置 EF Core 数据库模型的其他方法。

高级说明:在图书应用程序中,当我的导航属性是集合时,我使用 ICollection 类型。我这样做是因为新的快速加载排序功能(参见第 2.4.1 节)可以返回一个排序的集合,而默认的 HashSet 定义说它只包含一个“其元素没有特定顺序”的集合。但是,当导航属性包含大型集合时,不使用 HashSet 会产生性能成本。我在第 14 章中介绍了这个问题。

如果要访问现有数据库,会发生什么情况?

本书中的示例演示了如何通过 EF Core 定义和创建数据库,因为最复杂的情况是需要了解所有配置选项。但是,访问现有数据库要容易得多,因为 EF Core 可以使用称为“反向工程”的功能为你生成应用程序的 DbContext 类和所有实体类,该功能在第 9.7 节中介绍。

另一种可能性是,你不希望 EF Core 更改数据库结构,而是希望自己处理该任务,例如通过 SQL 更改脚本或数据库部署工具。我在 9.6.2 节中介绍了这种方法。

2.2 创建应用程序的 DbContext

要访问数据库,您需要执行以下操作:

  1. 定义应用程序的 DbContext,方法是创建一个类并从 EF Core 的 DbContext 类继承。
  2. 每次要访问数据库时,都创建该类的实例。

您将在本章后面看到的所有数据库查询都使用这些步骤,我在以下各节中将详细介绍这些步骤。

2.2.1 定义应用程序的 DbContext:EfCoreContext

使用 EF Core 所需的关键类是应用程序的 DbContext。通过继承 EF Core 的 DbContext 类并添加各种属性以允许软件访问数据库表来定义此类。它还包含可以重写以访问 EF Core 中的其他功能(例如配置数据库建模)的方法。图 2.7 概述了图书应用程序的 DbContext,并指出了所有重要部分。

图 2.7 应用程序的 DbContext 是访问数据库的关键类。此图显示了应用程序的 DbContext 的主要部分,从其继承 EF Core 的 DbContext 开始,它引入了大量代码和功能。您必须使用 DbSet 类添加一些属性,这些属性将类映射到与您使用的属性名称同名的数据库表。其他部分是构造函数(用于处理数据库选项的设置)和 OnModelCreating 方法(可以重写该方法以添加自己的配置命令并按所需方式设置数据库)。

关于图 2.7 需要注意的一点是,图书应用的 DbContext 不包括 Review 实体类和 BookAuthor 链接实体类的 DbSet 属性。在图书应用中,这两个实体类不是直接访问的,而是通过图书类导航属性访问的,如第 2.4 节所示。

注意:我跳过了数据库建模的配置,这是在应用程序的 DbContext 的 OnModelCreating 方法中完成的。第 7 章和第 8 章详细介绍了如何对数据库进行建模

2.2.2 创建应用程序的 DbContext 实例

第 1 章介绍了如何通过重写应用程序的 OnConfiguring 方法来设置应用程序的 DbContext。这种方法的缺点是连接字符串是固定的。在本章中,你将使用另一种方法,因为你希望使用不同的数据库进行开发和单元测试。你将使用通过应用程序的 DbContext 构造函数提供该数据库的方法。

清单 2.2 提供了创建应用程序 DbContext(称为 EfCoreContext)时数据库的选项。老实说,此列表基于我在单元测试一章(第 17 章)中使用的内容,因为它的好处是向您展示了创建应用程序 DbContext 实例的每个步骤。第 5 章介绍如何在 ASP.NET Core 应用程序中使用 EF Core,介绍一种更强大的方法,即使用称为依赖项注入的功能创建应用程序的 DbContext。

清单 2.2 创建应用程序的 DbContext 实例以访问数据库

// 连接字符串,其格式由所使用的数据库提供进程和托管类型决定
const string connection = "Data Source=(localdb)\\mssqllocaldb;"+ "Database=EfCoreInActionDb.Chapter02;"+ "Integrated Security=True;";
 
var optionsBuilder = new DbContextOptionsBuilder<EfCoreContext>();  // 您需要一个 EF Core DbContextOptionsBuilder<> 实例来设置所需的选项。
 
// 你正在访问 SQL Server 数据库并使用 Microsoft.EntityFrameworkCore.SqlServer 库中的 UseSqlServer 方法,并且此方法需要数据库连接字符串。
optionsBuilder.UseSqlServer(connection); var options = optionsBuilder.Options;

using (var context = new EfCoreContext(options))  // 使用已设置的选项创建最重要的 EfCoreContext。使用 using 语句是因为 DbContext 是一次性的
{
    var bookCount = context.Books.Count();   // 使用 DbContext 查找数据库中的图书数量
    //... etc.

在此列表的末尾,将在 using 语句中创建 EfCoreContext 的实例,因为 DbContext 具有 IDisposable 接口,因此应在使用它后将其释放。因此,从现在开始,如果您看到一个名为 context 的变量,它是使用清单 2.2 中的代码或类似的方法创建的。

2.2.3 为您自己的应用程序创建数据库

有几种方法可以使用 EF Core 创建数据库,但通常方法是使用 EF Core 的迁移功能。此功能使用应用程序的 DbContext 和实体类(如我所描述的实体类)作为数据库结构的模型。Add-Migration 命令首先对数据库进行建模,然后使用该模型生成命令以创建适合该模型的数据库。

提示:如果您克隆了本书(http://mng.bz/XdlG)附带的 Git 存储库,则可以通过查看 DataLayer 项目中的 Migration 文档夹来了解迁移的样子。此外,所有正确的 NuGet 包都将添加到 DataLayer 和 BookApp 项目中,以允许创建迁移并将其应用于 SQL Server 数据库。

除了处理数据库创建之外,迁移的伟大之处在于,它们可以使用您在软件中所做的任何更改来更新数据库。如果更改实体类或任何应用程序的 DbContext 配置,则 Add-Migration 命令将生成一组命令来更新现有数据库。以下是添加迁移以及创建或迁移数据库所需完成的步骤。此过程基于

ASP.NET Core 应用程序(有关 ASP.NET Core 的详细信息,请参阅第 5 章),在单独的项目中使用 DbContext,并使用 Visual Studio 进行开发。(我在第 9 章中介绍了其他选项)。

  1. 包含 DbContext 的项目需要 NuGet 包 Microsoft.EntityFrameworkCore.SqlServer 或其他数据库提供程序(如果使用其他数据库)。
  2. ASP.NET Core 项目需要以下 NuGet 包:Microsoft.EntityFrameworkCore.SqlServer(或与步骤 1 中相同的数据库提供程序,Microsoft.EntityFrameworkCore.Tools 。
  3. ASP.NET Core 的 Startup 类包含用于添加 EF Core 数据库提供程序的命令,appsettings.json 文档包含要创建/迁移的数据库的连接字符串。(EF Core 使用 ASP.NET Core 的 CreateHostBuilder(args)。Build() 方法获取 DbContext 的有效实例。
  4. 在 Visual Studio 中,通过选择“工具”打开包管理器控制台 (PMC)> NuGet 包管理器> 包管理器控制台。
  5. 在 PMC 窗口中,确保默认项目是 ASP.NET Core 项目。
  6. 在 PMC 中,运行命令 Add-Migration MyMigrationName -Project DataLayer。此命令创建一组类,这些类将数据库从其当前状态迁移到与运行命令时应用程序的 DbContext 和实体类匹配的状态。(命令中显示的 MyMigrationName 是将用于迁移的名称。
  7. 运行命令 Update-Database,将 AddMigration 命令创建的命令应用于数据库。如果不存在数据库,Update-Database 将创建一个数据库。如果数据库存在,则该命令将检查该数据库是否应用了此数据库迁移,如果缺少任何数据库迁移,则此命令会将其应用于数据库。(有关迁移命令的更多信息,请参阅第 9 章。

注意:还可以使用 EF Core 的 .NET Core 命令行接口 (CLI) 来运行这些命令(请参阅 http://mng.bz/454w)。第 9 章列出了迁移命令的 Visual Studio 和 CLI 版本。

使用 Update-Database 命令的替代方法是调用上下文。Database.Migrate 方法。此方法对于托管的 ASP.NET Core Web 应用程序特别有用;第 5 章介绍了此选项,包括它的一些限制。

注意:第 9 章详细介绍了 EF Core 的迁移功能以及更改数据库结构的其他方法(称为数据库的架构)。

2.3 了解数据库查询

现在,你可以开始了解如何使用 EF Core 查询数据库。图 2.8 显示了一个示例 EF Core 数据库查询,其中突出显示了查询的三个主要部分。

图 2.8 EF Core 数据库查询的三个部分,以及示例代码。您将熟悉这种类型的 LINQ 语句,它是所有查询的基本构造块。

节省时间:如果您熟悉 EF 和/或 LINQ,则可以跳过此部分。

图 2.8 中所示的命令由几个方法组成。此结构称为 Fluent 接口。像这样的 Fluent 界面逻辑流畅且直观,这使得它们易于阅读。以下各节将介绍此命令的三个部分。

注意:图 2.8 中的 LINQ 命令称为 LINQ 方法或 lambda 语法。可以使用另一种格式来编写具有 EF Core 的 LINQ 命令:查询语法。我在附录 A 中介绍了这两种 LINQ 语法。

2.3.1 应用程序的 DbContext 属性访问

该命令的第一部分通过 EF Corea 连接到数据库。引用数据库表的最常见方法是通过<T>应用程序的 DbContext 中的 DbSet 属性,如图 2.7 所示。

您将在本章中使用此 DbContext 属性访问,但后面的章节将介绍访问类或属性的其他方法。基本思路是相同的:需要从通过 EF Core 连接到数据库的内容开始。

2.3.2 一系列 LINQ/EF Core 命令

命令的主要部分是一组 LINQ 和/或 EF Core 方法,用于创建所需的查询类型。LINQ 查询的范围可以从超级简单到相当复杂。本章从简单的查询示例开始,但在本章结束时,您将学习如何生成复杂的查询。

注意:学习 LINQ 对您来说至关重要,因为 EF Core 使用 LINQ 命令进行数据库访问。附录简要概述了 LINQ。也有很多在线资源可用;请参阅 http://mng.bz/j4Qx。

2.3.3 执行命令

该命令的最后一部分揭示了有关 LINQ 的一些信息。在 LINQ 命令串行的末尾应用最终执行命令之前,LINQ 将作为一系列命令保存在所谓的表达式树中(请参见第 A.2.2 节),这意味着它尚未对数据执行。EF Core 可以将表达式树转换为你正在使用的数据库的正确命令。在 EF Core 中,在以下情况下对数据库执行查询:

  • 它由 foreach 语句枚举。
  • 它由集合操作枚举,例如 ToArray、ToDictionary、ToList、ToListAsync 等。
  • LINQ 运算符(如 First 或 Any)在查询的最外层指定。

在本章后面的显式加载关系时,你将使用某些 EF Core 命令,例如 Load。

此时,LINQ 查询将转换为数据库命令并发送到数据库。如果要生成高性能数据库查询,则希望在调用 execute 命令之前提供用于筛选、排序、分页等的所有 LINQ 命令。因此,筛选器、排序和其他 LINQ 命令将在数据库内运行,从而提高查询的性能。你会看到

此方法在第 2.8 节中实际使用,当您构建查询以筛选、排序和分页数据库中的书籍以显示给用户时。

2.3.4 数据库查询的两种类型

图 2.8 中的数据库查询就是我所说的普通查询,也称为读写查询。此查询从数据库中读取数据,以便您可以更新该数据(请参阅第 3 章)或将其用作新条目的现有关系,例如使用现有作者创建新书(请参阅第 6.2.2 节)。

另一种类型的查询是 AsNoTracking 查询,也称为只读查询。此查询已将 EF Core 的 AsNoTracking 方法添加到 LINQ 查询中(请参阅以下代码片段)。除了将查询设置为只读外,AsNoTracking 方法还通过关闭某些 EF Core 功能来提高查询的性能;有关更多信息,请参见第 6.12 节:

context.Books.AsNoTracking().Where(p => p.Title.StartsWith("Quantum")).ToList();

注意:第 6.1.2 节提供了普通读写查询和 AsNoTracking 只读查询之间差异的详细列表。

2.4 加载相关数据

我向您展示了 Book 实体类,该类具有指向其他三个实体类的链接:PriceOffer、Review 和 BookAuthor。现在,我想解释一下,作为开发人员,您如何访问这些关系背后的数据。您可以通过四种方式加载数据:预先加载、显式加载、选择加载和延迟加载。但是,在我介绍这些方法之前,你需要知道,除非你要求,否则 EF Core 不会在实体类中加载任何关系。如果加载 Book 类,则默认情况下,Book 实体类(Promotion、Reviews 和 AuthorsLink)中的每个关系属性都将为 null。

这种不加载关系的默认行为是正确的,因为这意味着 EF Core 会最大程度地减少数据库访问。如果要加载关系,则需要添加代码以指示 EF Core 执行此操作。以下部分介绍使 EF Core 加载关系的四种方法。

2.4.1 预先加载:加载与主实体类的关系

加载相关数据的第一种方法是预先加载,这需要告诉 EF Core 在加载主实体类的同一查询中加载关系。预先加载是通过两个流畅的方法指定的,Include 和 ThenInclude。下一个列表显示了将 Books 表的第一行作为 Book 实体类的实例加载,以及单个关系 Reviews。

清单 2.3 预先加载第一本书,并带有相应的评论关系

var firstBook = context.Books
    .Include(book => book.Reviews)  // 获取 Review 类实例的集合,该集合可能是一个空集合
    .FirstOrDefault();  // 如果数据库中没有书籍,则采用第一本书或 null

如果查看此 EF Core 查询创建的 SQL 命令(如以下代码片段所示),你将看到两个 SQL 命令。第一个命令加载 Books 表中的第一行。第二个加载评论,其中外键 BookId 与第一个 Books 行主键具有相同的值:

SELECT "t"."BookId", "t"."Description", "t"."ImageUrl",

  "t"."Price", "t"."PublishedOn", "t"."Publisher",

  "t"."Title", "r"."ReviewId", "r"."BookId",

  "r"."Comment", "r"."NumStars", "r"."VoterName"

FROM (

  SELECT "b"."BookId", "b"."Description", "b"."ImageUrl",

    "b"."Price", "b"."PublishedOn", "b"."Publisher", "b"."Title"

  FROM "Books" AS "b"

  LIMIT 1

) AS "t"

LEFT JOIN "Review" AS "r" ON "t"."BookId" = "r"."BookId"

ORDER BY "t"."BookId", "r"."ReviewId"

现在让我们看一个更复杂的例子。下面的清单显示了获取第一本书的查询,并预先加载了它的所有关系 - 在本例中为 AuthorsLink 和第二级 Author 表、Reviews 和可选的 Promotion 类。

清单 2.4 快速加载 Book 类和所有相关数据

var firstBook = context.Books
    .Include(book => book.AuthorsLink)  // 第一个 Include 获取 BookAuthor 的集合。
        .ThenInclude(bookAuthor => bookAuthor.Author)  // 获取下一个链接 - - 在本例中,获取作者链接
    .Include(book => book.Reviews)  // 获取 Review 类实例的集合,该集合可能是一个空集合
    .Include(book => book.Tags)  // 获取第一本书,如果数据库中没有书籍,则为空
    .Include(book => book.Promotion)  // 加载并直接访问标签
    .FirstOrDefault();  // 加载任何可选的PriceOffer类,如果已分配

该列表显示了如何使用 eager-loading 方法 Include 来获取 AuthorsLink 关系。此关系是第一级关系,直接从要加载的实体类引用。该 Include 后跟 ThenInclude 以加载二级关系 — 在本例中,链接表另一端的 Author 表为 BookAuthor。此模式(Include 后跟 ThenInclude)是访问比第一级关系更深入的关系的标准方法。您可以使用多个 ThenInclude 一个接一个地转到任何深度。

如果使用 EF Core 5 中引入的多对多关系的直接链接,则不需要 ThenInclude 加载二级关系,因为该属性通过 Tags 属性(类型为 ICollection)直接访问多对多关系的另一端。只要不需要链接表中的某些数据,例如用于正确排序 Book 的作者的 BookAuthor 链接实体类中的 Order 属性,此方法就可以简化多对多关系的使用。

EF6:EF Core 中的急切加载类似于 EF6.x 中的加载,但 EF6.x 没有 ThenInclude 方法。因此,清单 2.4 中使用的 Include/ThenInclude 代码将作为上下文编写在 EF6.x 中。Books.Include(book=> book.AuthorLink.Select(bookAuthor => bookAuthor.Author)。

如果关系不存在(例如 Book 类中的 Promotion 属性指向的可选 PriceOffer 类),则 Include 不会失败;它根本不加载任何内容,或者在集合的情况下,它返回一个空集合(一个条目为零的有效集合)。同样的规则也适用于 ThenInclude:如果前面的 Include 或 ThenInclude 为空,则忽略后续的 ThenInclude。如果不包含集合,则默认情况下该集合为 null。

预先加载的优点是,EF Core 将以有效的方式加载 Include 和 ThenInclude 引用的所有数据,使用最少的数据库访问或数据库往返。我发现这种类型的加载在需要更新现有关系的关系更新中很有用;第 3 章介绍了这个主题。我还发现 eager loading 在业务逻辑中很有用;第 4 章更详细地介绍了这个主题。

缺点是,预先加载会加载所有数据,即使您不需要其中的一部分。例如,图书列表显示不需要图书描述,图书描述可能相当大。

使用 INCLUDE 和/或 THENINCLUDE 时的排序和筛选

EF Core 5 添加了在使用 Include 或 ThenInclude 方法时对相关实体进行排序或筛选的功能。如果只想加载相关数据的子集(例如仅加载五星的审阅)和/或对包含的实体进行排序(例如,根据 Order 属性对 AuthorsLink 集合进行排序),则此功能非常有用。可以在 Include 或 ThenInclude 方法中使用的 LINQ 命令只有 Where、OrderBy、OrderByDescending、ThenBy、ThenByDescending、Skip 和 Take,但这些命令是排序和筛选所需的全部命令。

下一个列表显示了与清单 2.4 相同的代码,但 AuthorsLink 集合在 Order 属性上排序,并且 Reviews 集合被筛选为仅加载 NumStars 为 5 的 Reviews。

清单 2.5 使用 Include 或 ThenInclude 时的排序和过滤

var firstBook = context.Books
    .Include(book => book.AuthorsLink
        .OrderBy(bookAuthor => bookAuthor.Order)) // 排序示例:在预先加载 AuthorsLink 集合时,对 BookAuthors 进行排序,以便作者按正确的顺序显示。
        .ThenInclude(bookAuthor => bookAuthor.Author)
    .Include(book => book.Reviews
        .Where(review => review.NumStars == 5))  // 筛选器示例。在这里,您仅加载星级为 5 的评论。
    .Include(book => book.Promotion)
    .First();

2.4.2 显式加载:加载主实体类之后的关系

加载数据的第二种方法是显式加载。加载主实体类后,可以显式加载所需的任何其他关系。清单 2.6 与清单 2.4 在显式加载方面执行相同的工作。首先,它加载了书;然后,它使用显式加载命令来读取所有关系。

清单 2.6 显式加载 Book 类和相关数据

var firstBook = context.Books.First();  // 读取第一本书
context.Entry(firstBook).Collection(book => book.AuthorsLink).Load();   // Explicity加载链接表,BookAuthor
foreach (var authorLink in firstBook.AuthorsLink)  // 若要加载所有可能的作者,代码必须遍历所有 BookAuthor 条目并加载每个链接的 Author 类。
{
    context.Entry(authorLink).Reference(bookAuthor => bookAuthor.Author).Load();
}
// Loads all the reviews 
context.Entry(firstBook).Collection(book => book.Tags).Load();   // Loads the Tags
context.Entry(firstBook).Reference(book => book.Promotion).Load();  // Loads the optional PriceOffer class

或者,可以使用显式加载将查询应用于关系,而不是加载关系。清单 2.7 显示了使用显式加载方法 Query 来获取评论计数并加载每个评论的星级。可以在 Query 方法之后使用任何标准 LINQ 命令,如 Where 或 OrderBy。

清单 2.7 使用一组优化的相关数据显式加载 Book 类

var firstBook = context.Books.First(); 
var numReviews = context.Entry(firstBook).Collection(book => book.Reviews).Query().Count(); // 执行查询以计算此书的评论
var starRatings = context.Entry(firstBook)  // 执行查询以获取该书的所有星级评分
    .Collection(book => book.Reviews)
    .Query().Select(review => review.NumStars)
    .ToList();

显式加载的优点是可以稍后加载实体类的关系。我发现,当我使用仅加载主实体类的库并需要其关系之一时,这种技术很有用。仅在某些情况下,当您需要相关数据时,显式加载也很有用。您可能还会发现显式加载在复杂的业务逻辑中很有用,因为您可以将加载特定关系的工作留给需要它的业务逻辑部分。

显式加载的缺点是更多的数据库往返,这可能效率低下。如果您预先知道所需的数据,则预先加载数据通常更有效,因为加载关系所需的数据库往返次数更少。

2.4.3 选择加载:加载主实体类的特定部分和任何关系

加载数据的第三种方法是使用 LINQ Select 方法选取所需的数据,我称之为选择加载。下一个列表显示了如何使用 Select 方法从 Book 类中选择一些标准属性,并在查询中执行特定代码以获取此书的客户评论计数。

清单 2.8 选择 Book 类,选取特定属性和一次计算

var books = context.Books
    .Select(book => new  // 使用 LINQ Select 关键字并创建一个匿名类型来保存结果
        {
            // 几个属性的简单副本
            book.Title, 
            book.Price, 
            NumReviews = book.Reviews.Count,
        }
    ).ToList(); // 运行一个计算评论数量的查询

这种方法的优点是只加载您需要的数据,如果您不需要所有数据,这会更有效率。在清单 2.8 中,只需要一个 SQL SELECT 命令即可获取所有这些数据,这在数据库往返方面也很有效。EF Core 将查询的 p.Reviews.Count 部分转换为 SQL 命令,以便在数据库内完成计数,如以下 EF Core 创建的 SQL 代码片段所示:

SELECT "b"."Title", "b"."Price",

  ( SELECT COUNT(*)

  FROM "Review" AS "r"

  WHERE "b"."BookId" = "r"."BookId") AS "NumReviews"

FROM "Books" AS "b"

 

选择加载方法的缺点是需要为所需的每个属性/计算编写代码。在第 7.15.4 节中,我展示了一种自动执行此过程的方法。

注意:第 2.6 节包含一个更复杂的选择加载示例,您将使用它来构建图书应用程序的高性能图书列表查询。

2.4.4 延迟加载:按需加载关系

延迟加载使编写查询变得容易,但它对数据库性能有不良影响。延迟加载确实需要对 DbContext 或实体类进行一些更改,但在进行这些更改后,阅读就很容易了;如果访问未加载的导航属性,EF Core 将执行数据库查询以加载该导航属性。

您可以通过以下两种方式之一设置延迟加载:

  • 在配置 DbContext 时添加 Microsoft.EntityFrameworkCore.Proxies 库
  • 通过其构造函数将延迟加载方法注入实体类

第一个选项很简单,但会锁定您为所有关系设置延迟加载。第二个选项要求您编写更多代码,但允许您选择哪些关系使用延迟加载。我将只解释本章中的第一个选项,因为它很简单,而将第二个选项留给第 6 章(第 6.1.10 节),因为它使用了我尚未介绍的概念,例如依赖注入。

注意:如果现在想要查看所有延迟加载选项,请访问Microsoft的 EF Core 文档,网址为 https://docs.microsoft.com/en-us/ef/core/querying/related-data/lazy

若要配置简单的延迟加载方法,必须执行以下两项操作:

  • 在作为关系的每个属性之前添加关键字 virtual。
  • 在设置 DbContext 时添加 UseLazyLoadingProxies 方法。

因此,转换为简单延迟加载方法的 Book 实体类型将类似于以下代码片段,其中 virtual 关键字已添加到导航属性中:

public class BookLazy

{

  public int BookLazyId { get; set; }

  //… Other properties left out for clarity

  public virtual PriceOffer Promotion { get; set; }

  public virtual ICollection<Review> Reviews { get; set; }

  public virtual ICollection<BookAuthor> AuthorsLink { get; set; }

}

 

使用 EF Core 的代理库有一个限制:必须使每个关系属性都虚拟;否则,EF Core 将在使用 DbContext 时引发异常。

第二部分是将 EF Core 的代理库添加到设置 DbContext 的应用程序,然后将 UseLazyLoadingProxies 添加到 DbContext 的配置中。下面的代码片段显示了清单 2.2 (UseLazyLoadingProxies) 中所示的 DbContext 中添加的方法:

var optionsBuilder =

  new DbContextOptionsBuilder<EfCoreContext>();

optionsBuilder

  .UseLazyLoadingProxies()

  .UseSqlServer(connection);

var options = optionsBuilder.Options;

using (var context = new EfCoreContext(options))

在实体类中配置延迟加载以及创建 DbContext 的方式后,读取关系非常简单;查询中不需要额外的 Include 方法,因为当代码访问该关系属性时,会从数据库加载数据。清单 2.9 显示了 Book's Reviews 属性的延迟加载。

清单 2.9 延迟加载 BookLazy 的 Reviews 导航属性

var book = context.BookLazy.Single();  // 获取 BookLazy 实体类的实例,该实例已将其 Reviews 属性配置为使用延迟加载
var reviews = book.Reviews.ToList();  // 访问 Reviews 属性时,EF Core 将从数据库中读入评论。

清单 2.9 创建了两个数据库访问。第一次访问加载没有任何属性的 BookLazy 数据,第二次访问发生在访问 BookLazy 的 Reviews 属性时。

许多开发人员发现延迟加载很有用,但我避免使用它,因为它的性能问题。每次访问数据库服务器都会产生时间开销,因此最好的方法是尽量减少对数据库服务器的调用次数。但是延迟加载(和显式加载)可能会产生大量数据库访问,使查询速度变慢,并导致数据库服务器更加努力地工作。请参阅第 14.5.1 节,了解相关数据加载的四种类型的并排比较。

提示:即使您为延迟加载设置了关系属性,也可以通过在虚拟关系属性上添加 Include 来获得更好的性能。延迟加载将看到属性已加载,并且不会再次加载。将清单 2.9 的第一行更改为 context.BookLazy.Include (book => book.Review).Single() 会将两个数据库访问减少到一个访问。

2.5 使用客户端与服务器评估:在查询的最后阶段调整数据

到目前为止,你看到的所有查询都是 EF Core 可以转换为可在数据库服务器上运行的命令的查询。但 EF Core 具有一项称为“客户端与服务器评估”的功能,它允许你在查询的最后阶段(即查询中的最后一个 Select 部分)运行无法转换为数据库命令的代码。EF Core 在数据从数据库返回后运行这些不可服务器运行的命令。

EF6 客户端与服务器评估是 EF Core 中的一项新功能,也是一项有用的功能。

客户端与服务器评估功能使你有机会在查询的最后一部分调整/更改数据,这样你就不必在查询后应用额外的步骤。在第 2.6 节中,使用客户端与服务器评估来创建以逗号分隔的书籍作者列表。如果未对该任务使用客户端与服务器评估,则需要 (a) 发回所有作者姓名的列表,以及 (b) 在查询后添加一个额外的步骤,使用 foreach 部分应用字符串。加入每本书的作者。

警告:如果 EF Core 无法转换 LINQ,它将引发异常

在 EF Core 3 之前,任何无法转换为数据库命令的 LINQ 都将使用客户端与服务器评估在软件中运行。在某些情况下,此方法将生成性能极差的查询。(我在本书的第一版中写过这个子项目。EF Core 3 改变了这种情况,以便仅在 LINQ 查询的最后阶段使用客户端与服务器评估,从而阻止客户端与服务器评估生成性能较差的查询。

但这种更改会产生一个不同的问题:如果 LINQ 查询无法转换为数据库命令,EF Core 将引发 InvalidOperationException,并显示一条包含无法翻译单词的消息。问题在于,只有在尝试该查询时才会出现该错误,并且您不希望该错误在生产中发生!

在本书中,我将指导您编写有效的查询,但对于复杂的查询,很容易在 LINQ 中得到不太正确的内容,从而导致引发 Invalid- OperationException。即使我非常了解 EF Core,这种情况仍然发生在我身上,这就是为什么我在第 17 章中建议使用实际数据库对数据库访问进行单元测试和/或进行一组集成测试。

对于图书应用程序中图书的列表显示,您需要 (a) 按顺序从作者表中提取所有作者的姓名,以及 (b) 将它们转换为一个字符串,名称之间用逗号括起来。下面是一个示例,该示例以正常方式加载两个属性 BookId 和 Title,以及第三个属性 AuthorsString,该属性使用客户端与服务器计算。

清单 2.10 包含非 SQL 命令 的查询,string.join

var firstBook = context.Books
    .Select(book => new  // 选择的这些部分可以转换为 SQL 并在服务器上运行。
    {
        book.BookId, 
        book.Title,
        AuthorsString = string.Join(", ", book.AuthorsLink  // // string.Join 在客户端软件中执行。
            .OrderBy(ba => ba.Order)
            .Select(ba => ba.Author.Name))
    }
    ).First();

在具有两个作者 Jack 和 Jill 的书籍上运行此代码将导致 AuthorsString 包含 Jack、Jill 和 BookId,并且 Title 将设置为 Books 表中相应列的值。图 2.9 显示了清单 2.10 如何通过四个阶段进行处理。我想重点介绍第 3 阶段,其中 EF Core 运行无法转换为 SQL 的客户端代码。

图 2.9 查询的某些部分将转换为 SQL 并在 SQL Server 中运行;另一部分,字符串。联接,必须由 EF Core 在客户端完成,然后才能将组合结果传递回应用程序代码。

清单 2.10 中的示例相当简单,但您需要注意如何使用客户端和服务器评估创建的属性。对属性使用客户端与服务器计算意味着不能在任何将生成数据库命令的 LINQ 命令(如对该属性进行排序或筛选的任何命令)中使用该属性。如果这样做,您将收到一个 InvalidOperationException,其中包含一条消息,其中包含无法翻译的单词。例如,在图 2.9 中,如果尝试对 AuthorsString 进行排序或筛选,则会出现无法翻译的异常。

2.6 构建复杂查询

介绍了查询数据库的基础知识后,让我们看一下实际应用程序中更常见的示例。您将构建一个查询来列出图书应用程序中的所有图书,并具有一系列功能,包括排序、过滤和分页。

您可以使用预先加载来构建书籍显示。首先,您将加载所有数据;然后,在代码中,您将合并作者、计算价格、计算平均投票等等。这种方法的问题在于(a)您正在加载不需要的数据以及(b)必须在软件中完成排序和过滤。对于本章的图书应用程序(大约有 50 本书),您可以将所有书籍和关系预先加载到内存中,然后在软件中对它们进行排序或过滤,但这种方法不适用于亚马逊!

更好的解决方案是在 SQL Server 内部计算值,以便在数据返回到应用程序之前完成排序和过滤。在本章的其余部分中,您将使用一种选择加载方法,将选择、排序、过滤和分页部分组合到一个大查询中。您将从本部分开始选择部分。不过,在向您展示加载图书数据的选择查询之前,让我们先回到本章开头的 Quantum Networking 的图书列表显示。这次,图 2.10 显示了获取每条数据所需的每个单独的 LINQ 查询。

这个数字很复杂,因为获取所有数据所需的查询很复杂。记住这个图,让我们看看如何构建图书选择查询。您从要放入数据的类开始。这种类型的类的存在只是为了将您想要的确切数据组合在一起,有多种引用方式。在 ASP.NET 中,它被称为 ViewModel,但该术语还具有其他含义和用途;因此,我将这种类型的类称为数据传输对象 (DTO)。清单 2.11 显示了 DTO 类 BookListDto。

定义 数据传输对象 (DTO) 有很多定义,但适合我使用 DTO 的定义是“用于封装数据并将其从应用程序的一个子系统发送到另一个子系统的对象”(Stack Overflow, https://stackoverflow.com/a/1058186/1434764)。

图 2.10 构建图书列表显示所需的每个查询,以及用于提供图书显示该部分所需值的查询的每个部分。有些查询很简单,例如获得书名,但其他查询则不那么明显,例如从评论中计算出平均票数。

清单 2.11 DTO 书单

public class BookListDto
{
    public int BookId { get; set; }  // 如果客户单击该条目购买图书,则需要主密钥。
    public string Title { get; set; }
    public DateTime PublishedOn { get; set; } // 虽然没有显示发布日期,但您需要按它排序,因此您必须将其包括在内。
    public decimal Price { get; set; } // 图书的正常售价
    public decimal ActualPrice { get; set; }  // 销售价格——正常价格或促销价格。NewPrice(如果存在)
    public string PromotionPromotionalText { get; set; }  // 要显示的促销文本
    public string AuthorsOrdered { get; set; } // 字符串,用于保存以逗号分隔的作者姓名列表
    public int ReviewsCount { get; set; }  // 审阅过本书的人数
    public double? ReviewsAverageVotes { get; set; }  // 所有投票的平均数,如果没有投票则无效
    public string[] TagStrings { get; set; } // 本书的标签名称(即类别)
}

若要使用 EF Core 的选择加载,将接收数据的类必须具有默认构造函数(可以在不向构造函数提供任何属性的情况下创建该构造函数),该类不能是静态的,并且属性必须具有公共 setter。

接下来,您将构建一个 select 查询,用于填充 BookListDto 中的每个属性。由于您希望将此查询与其他查询部件(如排序、筛选和分页)一起使用,因此您将使用 IQueryable 类型创建一个名为 MapBookToDto 的方法,该方法接受 IQueryable 并返回 IQueryable。下面的清单显示了此方法。如您所见,LINQ Select 将您在图 2.10 中看到的所有单个查询组合在一起。

清单 2.12 填充 BookListDto 的 Select 查询

public static IQueryable<BookListDto> MapBookToDto(this IQueryable<Book> books)  // 接收 IQueryable 并返回 IQueryable
{
    return books.Select(book => new BookListDto
    {
        // Books 表中现有列的简单副本
        BookId = book.BookId, 
        Title = book.Title, 
        Price = book.Price,
        PublishedOn = book.PublishedOn, 
        ActualPrice = book.Promotion == null ? book.Price : book.Promotion.NewPrice,   // 计算销售价格(正常价格)或促销价格(如果存在这种关系)
        PromotionPromotionalText = book.Promotion == null ? null : book.Promotion.PromotionalText,  // PromotionalText 取决于此书是否存在 PriceOffer
        // 按正确的顺序获取作者姓名数组。您正在使用客户端与服务器评估,因为您希望将作者姓名合并到一个字符串中。
        AuthorsOrdered = string.Join(", ", book.AuthorsLink.OrderBy(ba => ba.Order).Select(ba => ba.Author.Name)), 
        ReviewsCount = book.Reviews.Count,   // 您需要计算评论的数量。
        ReviewsAverageVotes =
            book.Reviews.Select(review =>  // 若要使 EF Core 将 LINQ 平均值转换为 SQL AVG 命令,需要将 NumStars 强制转换为(double?)。
                (double?) review.NumStars).Average(), 
        TagStrings = book.Tags.Select(x => x.TagId).ToArray(),  // 本书的标签名称(类别)数组
    });
}

注意:清单 2.12 中 Select 查询的各个部分是我在第 1 章灵光一现时提到的重复代码。第 6 章介绍了自动执行大部分编码的映射器,但在本书的第 1 部分中,我列出了所有代码完整的,以便您看到全貌。请放心,有一种方法可以自动执行查询的选择加载方法,从而提高您的工作效率。

MapBookToDto 方法使用查询对象模式;该方法接受 IQueryable 并输出 IQueryable,这允许您将查询或查询的一部分封装在方法中。这样,查询就被隔离在一个地方,从而更容易查找、调试和性能调整。您还将使用查询对象模式进行查询的排序、筛选和分页部分。

注意:查询对象对于构建查询(例如本例中的图书列表)非常有用,但也存在替代方法,例如存储库模式。

MapBookToDto 方法也是 .NET 所称的扩展方法。扩展方法允许您将查询对象链接在一起。当您组合图书列表查询的每个部分以创建最终的复合查询时,您将看到第 2.9 节中使用的这种链接。

注意:如果 (a) 在静态类中声明该方法,(b) 该方法是静态的,并且 (c) 第一个参数前面有关键字 this,则该方法可以成为扩展方法。

查询对象接受 IQueryable 输入并返回 IQueryable,因此您将 LINQ 命令添加到原始 IQueryable 输入。你可以在最后添加另一个查询对象,或者如果你想执行查询,添加一个执行命令(见图 2.8)例如 ToList 来执行查询。当您组合本书的选择、排序、筛选和分页查询对象时,您将在第 2.9 节中看到这种方法的实际应用,EF Core 将其转变为相当高效的数据库查询。在第 15 章中,您将进行一系列性能调整,以使图书列表查询更快。

注意:您可以通过从 Git 存储库克隆代码,然后在本地运行 Book App Web 应用程序来查看此查询的结果。日志菜单功能将向您显示用于加载图书列表的 SQL,以及您选择的特定排序、过滤和分页设置。

2.7 介绍图书应用程序的架构

我等到现在才讨论 Book App 的设计,因为既然您已经创建了 BookListDto 类,它应该更有意义。在此阶段,你拥有通过 EF Core 映射到数据库的实体类(Book、Author 等)。您还有一个 BookListDto 类,它以演示端所需的形式保存数据,在本例中为 ASP.NET Core Web 服务器。

在一个简单的示例应用程序中,可以将实体类放在一个文档夹中,将 DTO 放在另一个文档夹中,依此类推。但是,即使在小型应用程序(如图书应用程序)中,这种做法也可能令人困惑,因为对数据库使用的方法与向客户显示数据时使用的方法不同。关注点分离 (SoC) 原则(参见 http://mng.bz/7Vom)表示,您的软件应分解为单独的部分。例如,书籍显示数据库查询不应包含创建要向用户显示书籍的 HTML 的代码。

您可以通过多种方式拆分图书应用程序的各个部分,但我们将使用一种称为分层架构的通用设计。此方法适用于中小型 .NET 应用程序。图 2.11 显示了本章的图书应用程序的体系结构。

图 2.11 Book App 的分层体系结构方法 将代码的各个部分放在离散项目中会分离每个项目中的代码。例如,DataLayer 只需要担心数据库,而不需要知道数据将如何使用;这就是 SoC 的实际应用原理。箭头始终指向左侧,因为较低(左侧)的项目无法访问较高(右侧)的项目。

这三个大矩形是 .NET 项目,其名称位于图的底部。这三个项目的类和代码按以下方式拆分:

  • DataLayer —— 这一层的重点是数据库访问。实体类和应用程序的 DbContext 位于此项目中。该图层对其上面的图层一无所知。
  • ServiceLayer —— 该层通过使用 DTO、查询对象和各种类来运行命令,充当 DataLayer 和 ASP.NET Core Web 应用程序之间的适配器。这个想法是,前端 ASP.NET 核心层有很多事情要做,以至于 ServiceLayer 将其预制数据交给显示。
  • BookApp —— 这一层的重点称为表示层,旨在以方便且适用于用户的方式呈现数据。表示层应该只关注与用户的交互,这就是为什么我们尽可能多地将数据库和数据移出表示层的原因。在图书应用程序中,您将使用一个主要提供 HTML 页面的 ASP.NET Core Web 应用程序,并在浏览器中运行少量 JavaScript。

使用分层架构会使 Book App 更难理解,但这是构建实际应用程序的一种方式。使用层还可以让你更容易地知道代码的每一位应该在关联的 Git 存储库中做什么,因为代码并不都是纠缠在一起的。

2.8 添加排序、过滤和分页

随着项目结构的完成,您可以更快地推进并构建剩余的查询对象,以创建最终的书籍列表显示。首先,我将向你展示图书应用程序的排序、过滤器和页面控件的屏幕截图(图 2.12),以便你了解你正在实现的内容。

图 2.12 排序、过滤和分页这三个命令,如图书应用程序主页所示。如果在随附的 Git 存储库中运行图书应用,则可以看到此页面的运行情况。

2.8.1 按价格、出版日期和客户评级对图书进行排序

LINQ 中的排序是通过 OrderBy 和 OrderByDescending 方法完成的。创建一个名为 OrderBooksBy 的 Query 对象作为扩展方法,如下一清单所示。你将看到,除了 IQueryable 参数之外,此方法还接受一个枚举参数,该参数定义用户所需的排序类型。

清单 2.13 OrderBooksBy Query Object 方法

public static IQueryable<BookListDto> OrderBooksBy (this IQueryable<BookListDto> books, OrderByOptions orderByOptions)
{
    switch (orderByOptions)
    {
        case OrderByOptions.SimpleOrder: 
            return books.OrderByDescending(x => x.BookId);   // 由于分页,您始终需要排序。你对主键进行默认排序,速度很快。
        case OrderByOptions.ByVotes:
            return books.OrderByDescending(x => x.ReviewsAverageVotes);  // 通过投票订购书籍。没有任何投票的书籍(空返回)排在底部。
        case OrderByOptions.ByPublicationDate: 
            return books.OrderByDescending(x => x.PublishedOn);  // 按出版日期排序,最新书籍位于顶部
        case OrderByOptions.ByPriceLowestFirst:
            return books.OrderBy(x => x.ActualPrice);   // 按实际价格下单,考虑任何促销价格——最低优先和最高优先
        case OrderByOptions.ByPriceHighestFirst:
            return books.OrderByDescending( x => x.ActualPrice);
        default:
            throw new ArgumentOutOfRangeException( nameof(orderByOptions), orderByOptions, null);
    }
}

调用 OrderBooksBy 方法将返回原始查询,并在末尾添加相应的 LINQ 排序命令。将此查询传递给下一个查询对象,或者,如果已完成,则调用命令来执行代码,例如 ToList。

注意:即使用户没有选择排序,您仍会进行排序(请参阅 SimpleOrder 开关语句),因为您将使用分页,一次只提供一页而不是所有数据,并且 SQL 需要对数据进行排序以处理分页。最有效的排序是针对主键,因此您可以针对该键进行排序。

2.8.2 按出版年份、类别和客户评级筛选图书

为图书应用程序创建的筛选比第 2.8.1 节中介绍的排序要复杂一些,因为您可以让客户首先选择他们想要的筛选类型,然后选择实际的筛选值。投票的筛选值很简单:它是一组固定值(4 或更高、3 或更高,依此类推),类别是标签的 TagId。但是要按日期过滤,您需要在下拉列表中找到要放置的出版物的日期。

查看用于计算有书籍的年份的代码很有启发性,因为该代码是组合 LINQ 命令以创建最终下拉列表的一个很好的示例。下面是从 GetFilterDropDownValues 方法中获取的代码片段。

清单 2.14 生成书籍出版年份列表的代码

// 加载书籍,同时过滤掉未来的书籍;然后选择书籍出版的年份
var result = _db.Books
    .Where(x => x.PublishedOn <= DateTime.UtcNow.Date)
    .Select(x => x.PublishedOn.Year)
    .Distinct()  // Distinct 方法返回书籍出版的每一年的列表。
    .OrderByDescending(x => x.PublishedOn)   // 按年份排序,最新年份在顶部
    .Select(x => new DropdownTuple
    {
        Value = x.ToString(), 
        Text = x.ToString()  // 最后,我使用两个客户端/服务器评估将值转换为字符串。
    }).ToList();
var comingSoon = _db.Books.Any(x => x.PublishedOn > DateTime.Today);   // 如果列表中的图书尚未出版,则返回 true
if (comingSoon)  // 为所有未来的书籍添加即将推出的过滤器
    result.Insert(0, new DropdownTuple
    {
        Value = BookListDtoFilter.AllBooksNotPublishedString,
        Text = BookListDtoFilter.AllBooksNotPublishedString
    });
return result;

此代码的结果是包含每年出版书籍的值/文本对列表,以及尚未出版的书籍的“即将推出”部分。这些数据由 ASP.NET Core 转换为 HTML 下拉列表并发送到浏览器。

下面的清单显示了名为 FilterBooksBy 的过滤器查询对象,它将清单 2.14 中创建的下拉列表中的 Value 部分作为输入,以及客户要求的任何类型的过滤。

清单 2.15 FilterBooksBy Query Object 方法

// 该方法同时提供筛选器类型和用户选择的筛选器值。
public static IQueryable<BookListDto> FilterBooksBy( this IQueryable<BookListDto> books, BooksFilterBy filterBy, string filterValue)
{
    if (string.IsNullOrEmpty(filterValue)) return books;  // 如果未设置筛选器值,则返回 IQueryable,不做任何更改
    switch (filterBy)
    {
        case BooksFilterBy.NoFilter: 
            return books;  // 对于未选择筛选器,则返回 IQueryable,不做任何更改
        case BooksFilterBy.ByVotes:
            // 按投票筛选仅返回平均投票数高于 filterVote 值的书籍。如果某本书没有评论,则 ReviewsAverageVotes 属性将为 null,并且测试始终返回 false。
            var filterVote = int.Parse(filterValue); 
            return books.Where(x => x.ReviewsAverageVotes > filterVote); 
        case BooksFilterBy.ByTags:
            // 选择标签类别与 filterValue 匹配的任何书籍
            return books.Where(x => x.TagStrings.Any(y => y == filterValue)); 
        case BooksFilterBy.ByPublicationYear:
            if (filterValue == AllBooksNotPublishedString)   // 如果选择了“即将推出”,则仅返回尚未出版的图书
                return books.Where( x => x.PublishedOn > DateTime.UtcNow);
            // 如果我们有一个特定的年份,我们会根据它进行过滤。请注意,我们还会删除未来的图书(以防用户选择今年的日期)。
            var filterYear = int.Parse(filterValue); 
            return books.Where( x => x.PublishedOn.Year == filterYear && x.PublishedOn <= DateTime.UtcNow);
        default:
            throw new ArgumentOutOfRangeException (nameof(filterBy), filterBy, null);
    }
}

2.8.3 其他过滤选项:在文本中搜索特定字符串

我们本可以创建大量其他类型的书籍过滤器/搜索,按书名搜索是显而易见的。但是,您希望确保用于搜索字符串的 LINQ 命令在数据库中执行,因为它们的性能将比

加载所有数据并在软件中进行过滤。EF Core 将 LINQ 查询中的以下 C# 代码转换为数据库命令:==、Equal、StartsWith、EndsWith、Contains 和 IndexOf。表 2.1 显示了其中一些命令的实际应用。

表 2.1 SQL Server 数据库中的 .NET 字符串命令示例

字符串命令 示例(查找包含字符串 The Cat sat on the mat 的标题。
StartsWith var books = context.Books.Where(p => p.Title.StartsWith("The")).ToList();
EndsWith var books = context.Books.Where(p => p.Title.EndsWith("MAT.")).ToList();
Contains var books = context.Books.Where(p => p.Title.Contains("cat"))

另一个需要知道的重要事项是,SQL 命令执行的字符串搜索是否区分大小写取决于数据库的类型,在某些数据库中,该规则称为排序规则。默认的 SQL Server 数据库默认排序规则使用不区分大小写的搜索,因此搜索 Cat 将找到 cat 和 Cat。默认情况下,许多 SQL 数据库不区分大小写,但 Sqlite 混合了区分大小写/不区分大小写(有关详细信息,请参阅存储库中的单元测试Ch02_StringSearch类),而 Cosmos DB 默认区分大小写。

EF Core 5 提供了在数据库中设置排序规则的各种方法。通常,您可以为数据库或特定列配置排序规则(在第 7.7 节中介绍),但也可以使用 EF 在查询中定义排序规则。Functions.Collate 方法。以下代码片段设置 SQL Server 排序规则,这意味着此查询将使用此查询的 Latin1_General_CS_AS(区分大小写)排序规则来比较字符串:

context.Books.Where( x =>EF.Functions.Collate(x.Title, "Latin1_General_CS_AS")== “HELP” //This does not match “help”

注意:在具有许多脚本的多种语言中定义什么是大写字母和什么是小写字母是一个复杂的问题!幸运的是,关系数据库多年来一直在执行此任务,而 SQL Server 有 200 多个排序规则。

另一个字符串命令是 SQL 命令 LIKE,您可以通过 EF 访问它。Function.Like 方法。此命令提供了一种简单的模式匹配方法,使用 _(下划线)匹配任何字母,使用 % 匹配零到多字符。以下代码片段将与 The Cat sat at the mat. 和 The dog sat on the step 匹配。但不是兔子坐在牛舍上。因为兔子不是三个字母长:

var books = context.Books.Where(p => EF.Functions.Like(p.Title, "The    sat on the %.")).ToList();

其他查询选项:复杂查询(GROUPBY、SUM、MAX 等)

本章介绍了广泛的查询命令,但 EF Core 可以将更多命令转换为大多数数据库。第 6.1.8 节介绍了需要更多解释或特殊编码的命令。

2.8.4 对列表中的书籍进行分页

如果您使用过 Google 搜索,那么您就使用过分页。谷歌会显示前十几个结果,你可以翻阅其余的。我们的图书应用程序使用分页,通过使用 LINQ 命令的 Skip 和 Take 方法,这很容易实现。

尽管由于 LINQ 分页命令非常简单,其他查询对象与 BookListDto 类相关联,但您可以创建一个通用分页查询对象,该对象将用于任何 IQueryable 查询。此查询对象如以下清单所示。该对象确实依赖于获取正确范围内的页码,但应用程序的另一部分无论如何都必须这样做才能在屏幕上显示正确的分页信息。

清单 2.16 通用的 Page Query Object 方法

public static IQueryable<T> Page<T>( this IQueryable<T> query, int pageNumZeroStart, int pageSize)
{
    if (pageSize == 0)
        throw new ArgumentOutOfRangeException (nameof(pageSize), "pageSize cannot be zero.");
    if (pageNumZeroStart != 0) query = query.Skip(pageNumZeroStart * pageSize);  // 跳过正确的页数
    return query.Take(pageSize);  // 获取此页面大小的数字
}

正如我之前所说,分页仅在对数据进行排序时才有效。否则,SQL Server 将引发异常,因为关系数据库不保证数据传回的顺序;关系数据库中没有默认的行顺序。

2.9 组合在一起:组合查询对象

我们已经介绍了为图书应用程序构建图书列表所需的每个查询对象。现在是时候看看如何组合这些查询对象以创建复合查询以使用网站了。在单独的部分中构建复杂查询的好处是,这种方法使编写和测试整个查询变得更加简单,因为您可以单独测试每个部分。

清单 2.17 显示了一个名为 ListBooksService 的类,它有一个方法 SortFilterPage,它使用所有查询对象(select、sort、filter 和 page)来构建复合查询。它还需要应用程序的 DbContext 来访问通过构造函数提供的 Books 属性。

提示:清单 2.17 以粗体突出显示了 AsNoTracking 方法。此方法阻止 EF Core 在只读查询上拍摄跟踪快照(参见图 1.6),从而使查询速度稍快一些。应在任何只读查询(读取数据但从不更新数据的查询)中使用 AsNoTracking 方法。在本例中,我们没有加载任何实体类,因此它是多余的,但我把它放在那里是为了提醒我们查询是只读的。

public class ListBooksService
{
    private readonly EfCoreContext _context;

    public ListBooksService(EfCoreContext context)
    {
        _context = context;
    }

    public IQueryable<BookListDto> SortFilterPage (SortFilterPageOptions options)
    {
        var booksQuery = _context.Books  // 首先在应用进程的 DbContext 中选择 Books 属性
            .AsNoTracking()  // 由于此查询是只读的,因此请添加 .AsNoTracking。
            .MapBookToDto()  // 使用 Select Query 对象,该对象选取/计算所需的数据
            .OrderBooksBy(options.OrderByOptions)  // 通过使用给定的选项添加命令来排序数据
            .FilterBooksBy(options.FilterBy,options.FilterValue);   // 添加用于筛选数据的命令
        
        options.SetupRestOfDto(booksQuery);  // 此阶段设置页数,并确保 PageNum 在正确的范围内。
        
        return  booksQuery.Page(options.PageNum-1, options.PageSize);  //// 应用分页命令
    }
}

如您所见,四个查询对象(选择、排序、筛选和页面)依次添加(称为链接)以形成最终的复合查询。请注意,选项。Page Query 对象之前的 SetupRestOfDto(booksQuery) 代码会整理出诸如有多少页之类的内容,确保 PageNum 在正确的范围内,并执行一些其他内务处理项。第 5 章展示了如何在 ASP.NET Core Web 应用程序中调用 ListBooksService。

本章提要

  • 要通过 EF Core 以任何方式访问数据库,您需要定义一个应用程序 DbContext。
  • EF Core 查询由三部分组成:应用程序的 DbContext 属性、一系列 LINQ/EF Core 命令以及执行查询的命令。
  • 使用 EF Core,您可以对三种主要数据库关系进行建模:一对一、一对多和多对多。其他关系将在第 8 章中介绍。
  • EF Core 映射到数据库的类称为实体类。我使用这个术语是为了强调这样一个事实:我所指的类由 EF Core 映射到数据库。
  • 如果加载实体类,默认情况下它不会加载其任何关系。例如,查询 Book 实体类不会加载其关系属性(Reviews、AuthorsLink 和 Promotion);它让它们为空。
  • 您可以通过四种方式加载附加到实体类的相关数据:急切加载、显式加载、选择加载和延迟加载。
  • EF Core 的客户端与服务器评估功能允许查询的最后阶段包含无法转换为 SQL 命令的命令,例如 string.Join。
  • 我使用术语“查询对象”来指代封装的查询或查询的一部分。这些查询对象通常构建为 .NET 扩展方法,这意味着它们可以轻松链接在一起,类似于 LINQ 的编写方式。
  • 选择、排序、过滤和分页是常见的查询用途,可以封装在查询对象中。
  • 如果仔细编写 LINQ 查询,则可以将聚合计算(例如 Count、Sum 和 Average)移至关系数据库中,从而提高性能。

对于熟悉 EF6.x 的读者:

  • 本章中的许多概念与 EF6.x 中的相同。在某些情况下(例如急切加载),EF Core 命令和/或配置略有变化,但通常会变得更好。