第6章 使用EF Core进行读写操作的技巧

发布时间 2024-01-05 16:08:18作者: 生活的倒影

本章涵盖

  • 选择正确的方法从数据库中读取数据
  • 编写在数据库端表现良好的查询
  • 避免使用查询筛选器和特殊 LINQ 命令时出现问题
  • 使用 AutoMapper 更快地编写 Select 查询
  • 编写代码以快速复制和删除数据库中的实体

前四章介绍了读取/写入数据库的不同方法,在第 5 章中,您使用这些信息构建了 Book App(一个 ASP.NET Core Web 应用进程)。本章汇集了使用 EF Core 读取和写入数据的许多不同技巧和技术。

本章分为两部分:从数据库读取和写入数据库。每个部分都涵盖了你可能遇到的某些读/写问题,但同时解释了 EF Core 如何实现解决方案。目的是通过解决不同的问题为您提供许多实用技巧,同时加深您对 EF Core 工作原理的了解。这些提示很有用,但从长远来看,成为 EF Core 专家将使你成为更好的开发人员。

提示:别忘了配套的 Git 存储库 (http://mng.bz/XdlG) 包含本书每一章的单元测试。在本章中,请在 master 分支的 Test 项目中查找以 Ch06_ 开头的类。有时,查看代码比阅读单词更快。

6.1 从数据库读取

本节介绍从数据库读取数据的不同方面和相关示例。目的是通过查看不同的问题,让您了解 EF Core 的一些内部工作。在此过程中,你将获得在使用 EF Core 生成应用进程时可能有用的各种提示。下面是有关通过 EF Core 从数据库读取的主题列表:

  • 探索查询中的关系确定阶段
  • 了解 AsNoTracking 及其变体的作用
  • 高效读取分层数据
  • 了解 Include 方法的工作原理
  • 使加载导航集合的故障安全
  • 在实际情况下使用查询过滤器
  • 考虑需要特别注意的 LINQ 命令
  • 使用 AutoMapper 自动构建 Select 查询
  • 评估 EF Core 在读取数据时如何创建实体类

6.1.1 探索查询中的关系确定阶段

使用 EF Core 查询数据库时,将运行一个名为“关系确定”的阶段,以填充查询中包含的其他实体类的导航属性。我在第 1.9.2 节中描述了这个过程,其中 Book 实体链接到其作者。到目前为止,您看到的所有查询都仅链接当前查询读入的实体类。但实际上,对普通读写查询的关系确定可以在单个查询外部链接到任何跟踪的实体,如本节所述。

每当将实体类作为跟踪实体读取时(查询不包含命令 AsNoTracking),关系确定阶段将运行以链接导航属性。重要的一点是,关系确定阶段不仅查看查询中的数据;它还会在填充导航属性时查看所有现有的跟踪实体。图 6.1 显示了加载带有 Reviews的 Book 的两种方法,这两种方法都填充了 Book 的 Reviews 导航属性。

图 6.1 此图显示了一个查询,该查询使用 Include 方法加载 Reviews(参见左侧的代码),该查询加载带有 Reviews 的 Book。右侧的查询加载了没有评论的书籍;然后,它会执行第二个查询,分别加载评论。两个版本的代码都会产生相同的结果:加载一个 Book 实体,并且还加载其 Reviews 导航属性,并将 Reviews 链接到该 Book。

使用 EF Core 查询数据库时,将运行一个名为“关系确定”的阶段,以填充查询中包含的其他实体类的导航属性。我在第 1.9.2 节中描述了这个过程,其中 Book 实体链接到其作者。到目前为止,您看到的所有查询都仅链接当前查询读入的实体类。但实际上,对普通读写查询的关系确定可以在单个查询外部链接到任何跟踪的实体,如本节所述。

每当将实体类作为跟踪实体读取时(查询不包含命令 AsNoTracking),关系确定阶段将运行以链接导航属性。重要的一点是,关系确定阶段不仅查看查询中的数据;它还会在填充导航属性时查看所有现有的跟踪实体。图 6.1 显示了加载带有 Reviews的 Book 的两种方法,这两种方法都填充了 Book 的 Reviews 导航属性。

如这个简单示例所示,查询完成时运行的关系修复将根据数据库键约束填充任何导航链接,并且它非常强大,例如,如果在四个单独的查询中加载了所有 Books、Reviews、BookAuthor 和 Authors,EF Core 将正确链接所有导航属性。以下代码片段就是这样做的:在第一行中阅读的书籍开始时没有填充任何关系,但在四行代码结束时,将填充书籍的 Reviews 和 AuthorsLink 导航属性,并且还填充了 BookAuthor's Book 和 Author 导航属性:

var books = context.Books.ToList();
var reviews = context.Set<Review>().ToList();
var authorsLinks = context.Set<BookAuthor>().ToList(); 
var authors = context.Authors.ToList();

EF Core 的此功能允许你执行一些有用的操作。在第 6.1.3 节中,您将学习如何使用此技术有效地读取分层数据。

6.1.2 了解 AsNoTracking 及其变体的作用

通过 EF Core 查询数据库时,这样做是有原因的:更改读入的数据(例如更改 Book 实体中的 Title 属性)或执行只读查询(例如显示 Books 及其价格、作者等)。本部分介绍 AsNoTracking 和 AsNoTrackingWithIdentityResolution 方法如何提高只读查询的性能并影响读入的数据。第 1 章中的以下代码片段使用 AsNoTracking 在控制台上显示书籍及其作者的列表:

var books = context.Books
    .AsNoTracking()
    .Include(a => a.Author)
    .ToList();

没有两种 AsNoTracking 方法之一的普通查询将跟踪查询加载的实体类,从而允许您更新或删除已加载的实体类。但是,如果只需要只读版本,则可以在查询中包含两种方法。这两种方法都提高了性能,并确保对数据的更改不会写回到数据库,但返回的关系略有不同:

  • AsNoTracking 产生更快的查询时间,但并不总是表示确切的数据库关系。
  • AsNoTrackingWithIdentityResolution 通常比普通查询快,但比使用 AsNoTracking 的相同查询慢。改进之处在于数据库关系得到正确表示,数据库中的每一行都有一个实体类实例。

首先,让我们看看使用两个 AsNoTracking 变体的查询返回的数据的差异。为了获得最佳性能,AsNoTracking 方法不执行称为标识解析的功能,该功能可确保数据库中每行只有一个实体实例。如果不将标识解析功能应用于查询,则意味着可能会获得实体类的额外实例。

图 6.2 显示了在第 1 章中对超级简单数据库使用 AsNoTracking 和 AsNoTrackingWithIdentityResolution 方法时会发生什幺情况。这个例子有四本书,但前两本书的作者是同一个。如图所示,AsNoTracking 查询创建了四个 Author 类实例,但数据库在 Author 表中只有三行。

图 6.2 前两本书的作者是马丁·福勒。在左侧的 AsNoTracking 查询中,EF Core 创建了 Author 类的四个实例,其中两个包含相同的数据。右侧包含 AsNoTrackingWithIdentityResolution(或普通查询)的查询仅创建 Author 类的三个实例,并且前两本书指向同一实例。

在大多数只读情况下,例如用作者的名字显示每本书,拥有四个 Author 类实例并不重要,因为重复的类包含相同的数据。在这些类型的只读查询中,应使用 AsNoTracking 方法,因为它生成的查询速度最快。

但是,如果以某种方式使用这些关系,例如创建链接到同一作者的其他书籍的书籍的报告,则 AsNoTracking 方法可能会导致问题。在这种情况下,应使用 AsNoTrackingWithIdentityResolution 方法。

历史 一些历史:在 EF Core 3.0 之前,AsNoTracking 方法包括标识解析阶段,但在非常注重性能的 EF Core 3.0 中,标识解析已从 AsNoTracking 方法中删除。删除标识解析调用会对现有应用进程产生一些问题,因此 EF Core 5 添加了 AsNoTrackingWithIdentityResolution 方法来修复这些问题。

为了让您了解性能差异,我对三个查询进行了简单的测试,加载了一百本带有评论、BookAuthor 和 Author 实体的书籍。表 6.1 显示了计时(第二个查询)。

表 6.1 使用普通读写查询和包含 AsNoTracking 和 AsNoTrackingWithIdentityResolution 方法的查询运行同一查询的结果

AsNoTracking variants

Time (ms)

Percentage difference

no AsNoTracking (normal query)

95

100%

AsNoTracking

40

42%

AsNoTrackingWithIdentityResolution

85

90%

 

正如你所看到的,AsNoTracking在这个(不科学的)测试中是最快的,大约是普通查询的两倍,所以它值得使用。AsNoTrackingWithIdentityResolution 方法仅比普通读写查询(在本例中)稍快,但与 AsNoTracking 版本一样,不会跟踪实体,这提高了 SaveChanges 在查找更新数据时的性能。

AsNoTracking 和 AsNoTrackingWithIdentityResolution 方法的另一个功能是关系修正阶段(请参见第 6.1.1 节)仅在查询中起作用。因此,使用 AsNoTracking 或 AsNoTrackingWithIdentityResolution 的两个查询将创建每个实体的新实例,即使第一个查询加载了相同的数据也是如此。使用普通查询时,两个单独的查询将返回相同的实体类实例,因为关系修正阶段适用于所有跟踪的实体。

6.1.3 高效读取分层数据

我曾经为一个客户工作过,这个客户有很多分层数据——数据,它有一系列深度不确定的链接实体类。问题在于,我必须先解析整个层次结构,然后才能显示它。我最初是通过急切加载前两个级别来做到这一点的;然后我使用显式加载来构建更深层次的。此技术有效,但性能较慢,并且数据库因大量单个数据库访问而过载。

这种情况让我思考:如果普通的查询关系修复如此聪明,它能帮助我提高查询的性能吗?它可以!让我举个例子,使用一家公司的员工。图 6.3 显示了我们想要加载的公司的层次结构。

图 6.3 分层数据的一个示例。这种数据的问题在于你不知道它有多深。但事实证明,一个.Include(x => x.WorksForMe) 就是您所需要的。然后,查询的关系修复阶段将以正确的方式链接分层数据。

您可以使用 .Include(x => x.WorksForMe)。ThenInclude(x => x.WorksForMe) 等,但单个 .Include(x => x.WorksForMe) 就足够了,因为关系修复可以解决其余的问题。下一个列表提供了一个示例,在该示例中,您需要列出所有从事开发工作的员工及其关系。此查询中的 LINQ 将转换为一个 SQL 查询。

清单 6.1 加载所有从事开发工作的员工及其关系

var devDept = context.Employees  //数据库包含所有员工。
    .Include(x => x.WorksForMe)  //一个包含就是您所需要的;关系修复将找出与什幺相关的内容。
    .Where(x => x.WhatTheyDo.HasFlag(Roles.Development))  //将员工筛选为从事开发工作的员工
    .ToList();

清单 6.1 提供了分层数据的跟踪版本,但如果您想要一个只读版本,可以将 AsNoTrackingWithIdentityResolution 方法添加到查询中。请注意,AsNoTracking 将不起作用,因为关系的链接依赖于 EF Core 的关系修正功能,该功能在 AsNoTracking 方法中处于关闭状态。

在找到这种方法之前,我使用的是显式加载,这会产生性能不佳的查询。切换到此方法可缩短单个查询所花费的时间,并减少数据库服务器上的负载。

注意:您确实需要确定要包含的关系。在本例中,我有一个 Manager 导航属性(单个)和一个 WorksForMe 导航属性(集合)。事实证明,包括 WorksForMe 属性会同时填充 WorksForMe 集合和 Manager 属性。但是,包括 Manager 导航属性意味着仅当存在要链接到的实体时,才会创建 WorksForMe 集合;否则,WorksForMe 集合为 null。我不知道为什幺两者的区别 Include 用法不同;这就是为什幺我测试所有内容以确保我知道 EF Core 的工作原理。

6.1.4 了解 Include 方法的工作原理

加载实体类及其关系的最简单方法是使用 Include 方法,该方法易于使用,通常可生成高效的数据库访问。但值得了解 Include 方法的工作原理以及需要注意的事项。

当 EF Core 3.0 出现时,Include 方法转换为 SQL 的方式发生了变化。EF Core 3.0 更改在许多情况下提供了性能改进,但对于某些复杂的查询,它会对性能产生负面影响。以 Book App 数据库中的示例为例,并查看如何加载带有评论和作者的书籍。以下代码片段显示了查询:

var query = context.Books
    .Include(x => x.Reviews)
    .Include(x => x.AuthorsLink)
    .ThenInclude(x => x.Author);

图 6.4 显示了 EF Core 2.2 和 EF Core 3.0 为具有 4 个评论和 2 个作者的书籍生成的不同 SQL 查询。

图 6.4 比较 EF Core 3 发布前后 EF Core 加载数据的方式。最高版本是 EF Core 在 EF Core 3 之前的工作方式 - 它使用单独的数据库查询来读取任何集合。较低版本是 EF Core 3 及更高版本的功能,它将所有数据合并到一个大查询中。

EF Core 3.0 处理加载集合关系的方式的好处是性能,在许多情况下速度更快。我做了一个简单的实验,在 EF Core 2.1 和 EF Core 3.0 中加载了包含十条评论和两名作者的书籍,EF Core 3.0 版本的速度提高了大约 20%。但在某些特定情况下,它确实可能非常缓慢,正如我接下来介绍的那样。

如果要包含在查询中,并且其中一些关系在集合中具有大量条目,则会出现性能问题。您可以通过查看图 6.4 最右侧的两个计算来了解问题。此图显示通过 3.0 之前的 EF Core 版本读入的行数是通过将行相加来计算的。但在 EF Core 3.0 及更高版本中,读取的行数是通过将行数相乘来计算的。假设您正在加载 3 个关系,每个关系有 100 行。EF Core 3.0 之前的版本将读取 100 100 100 = 300 行,但 EF Core 3.0 及更高版本将使用 100 * 100 * 100 = 100 万行。

为了查看性能问题,我创建了一个测试,其中实体具有三个一对多关系,每个关系在数据库中有 100 行。以下代码片段显示了在查询中加载关系的正常 Include 方法,该方法花费了 3500 毫秒(一个可怕的结果)!

var result = context.ManyTops
    .Include(x => x.Collection1)
    .Include(x => x.Collection2)
    .Include(x => x.Collection3)
    .Single(x => x.Id == id);

幸运的是,EF Core 5 提供了一个名为 AsSplitQuery 的方法,该方法指示 EF Core 单独读取每个 Include,如以下清单所示。此操作仅花费了 100 毫秒,速度快了大约 50 倍。

清单 6.2 单独读取关系并让关系修复将它们连接起来

var result = context.ManyTops
    .AsSplitQuery()  //使每个 Include 单独加载,从而停止乘法问题
    .Include(x => x.Collection1)
    .Include(x => x.Collection2)
    .Include(x => x.Collection3)
    .Single(x => x.Id == id)

如果发现使用多个 Include 的查询速度较慢,可能是因为两个或多个包含的集合包含大量条目。在这种情况下,请在 Include 之前添加 AsSplitQuery 方法,以交换到每个包含的集合的单独加载。

6.1.5 使加载导航集合具有故障安全

我总是试图让任何代码都成为故障安全,我的意思是,如果我在代码中犯了一个错误,我宁愿它失败并出现异常,也不愿默默地做错事。我担心的一个方面是,当我加载具有关系的实体时,忘记添加正确的 Include 集。似乎我永远不会忘记这样做,但在具有大量关系的应用进程中,它很容易发生。事实上,我已经做过很多次了,包括在我客户的应用进程中,这就是我使用故障安全方法的原因。让我解释一下问题,然后解释一下我的解决方案。

对于任何使用集合的导航属性,我经常看到开发人员在构造函数中或通过对属性的赋值将空集合分配给集合导航属性(请参阅以下清单)。

清单 6.3 导航集合设置为空集合的实体类

public class BookNotSafe
{
    public int Id { get; set; }
    public ICollection<ReviewNotSafe> Reviews { get; set; }  //此名为“评论”的导航属性具有许多条目,即一对多关系。

    public BookNotSafe()
    {
        Reviews = new List<ReviewNotSafe>();  //名为 Reviews 的导航属性预加载了一个空集合,因此在创建主实体 BookNotSafe 时,可以更轻松地将 ReviewNotSave 添加到导航属性。
    }
}

开发人员这样做是为了更轻松地将条目添加到新创建的实体类实例上的导航集合中。缺点是,如果忘记了加载导航属性集合的 Include,则当数据库可能具有应填充该集合的数据时,将获得一个空集合。

如果要替换整个集合,则还有另一个问题。如果没有 Include,则不会删除数据库中的旧条目,因此会得到新实体和旧实体的组合,这是错误的答案。在下面的代码片段(改编自清单 3.17)中,数据库最终得到了三个评论,而不是替换了现有的两个评论:

var book = context.Books
    //missing .Include(x => x.Reviews)
    .Single(p => p.BookId == twoReviewBookId);

book.Reviews = new List<Review>{ new Review{ NumStars = 1}}; context.SaveChanges();

不将空集合分配给集合的另一个很好的理由是性能。例如,如果需要使用集合的显式加载,并且知道它已经加载,因为它不是 null,则可以跳过执行(冗余)显式加载。此外,在第 13 章中,我将选择性能最佳的方式将新的 Review 实体类添加到 Book 实体类,具体取决于是否已加载 Book's Reviews 集合属性。

因此,在我的代码中(以及整本书),我没有使用集合预加载任何导航属性。当我省略 Include 方法时,我不会以无提示方式失败,而是在代码访问导航集合属性时收到 NullReferenceException。在我看来,这个结果比得到错误的数据要好得多。

6.1.6 在实际情况中使用全局查询过滤器

3.5节中引入了全局查询过滤器(简称查询过滤器)来实现软删除功能。在本节中,您将了解在实际应用进程中使用软删除所涉及的一些问题。您还将了解如何使用查询过滤器来生成多租户系统。

实际应用中的软删除

软删除功能非常有用,因为应用进程的用户在删除某些内容时可以获得第二次机会。我的两个客户的应用进程几乎在每个实体类上都使用了软删除功能。通常,普通用户会删除某些内容,这实际上意味着软删除它,而管理员可以取消删除该项目。这两个应用进程都很复杂且截然不同,因此我学到了很多有关实现软删除的知识。

首先,软删除不像普通的数据库删除命令那样工作。通过数据库删除,如果您删除一本书,您还将删除链接到您删除的图书的所有 PriceOffer、Reviews 和 AuthorLinks(请参阅第 3.5.3 节)。软删除不会发生这种情况,软删除有一些有趣的问题。

例如,如果您软删除一本书,PriceOffer、Reviews 和 AuthorLinks 仍然存在,如果您不考虑清楚,这可能会导致问题。在第 5.11.1 节中,您构建了一个后台进程,用于记录数据库中每小时的评论数量。如果你软删除了一本有 10 条评论的书,你可能会期望评论的数量会减少,但使用清单 5.14 中的代码,情况却不会。你需要一种方法来处理这个问题。

领域驱动设计 (DDD) 中称为根和聚合的模式可以帮助您解决这种情况。在此模式中,Book 实体类是根,PriceOffer、Reviews 和 AuthorLinks 是聚合。 (请参阅第 3.1.1 节中的主要和从属描述。)此模式继续说明您应该仅通过根访问聚合。此过程适用于软删除,因为如果图书(根)被软删除,您将无法访问其聚合。因此,考虑到软删除,计算所有评论的正确代码是

var numReviews = context.Books.SelectMany(x => x.Reviews).Count();

注意:使用软删除解决根/聚合问题的另一种方法是在设置软删除时模拟级联删除行为,这非常复杂。但是我构建了一个名为 EfCore.SoftDeleteServices 的库,该库模仿级联删除行为,但使用软删除;请参见 https://github.com/JonPSmith/EfCore.SoftDeleteServices

要考虑的第二件事是,您不应该将软删除应用于一对一关系。如果在现有但软删除的实体已存在的情况下尝试添加新的一对一实体,则会遇到问题。如果您有一个软删除的 PriceOffer(它与图书具有一对一的关系),并且尝试向图书添加另一个 PriceOffer,您将收到数据库异常。一对一关系在外键 BookId 上有一个唯一索引,并且(软删除的)PriceOffer 占用了该位置。

正如我的客户发现的那样,软删除功能非常有用,因为用户可能会错误地删除错误的数据。但了解这些问题可以让您计划如何在应用进程中处理它们。我通常使用根/聚合方法,不允许软删除一对一依赖的实体。

使用查询过滤器创建多租户系统

多租户系统是一种系统,其中不同的用户或用户组拥有只能由某些用户访问的数据。你可以找到很多例子,比如Office365和GitHub。查询过滤器功能不足以单独构建 Office365,但您可以使用查询过滤器构建复杂的多租户应用进程。

在查询过滤器的软删除使用中,您使用了布尔值作为过滤器,但对于多租户系统,您需要一个更复杂的密钥,我将其称为 DataKey。每个租户都有一个唯一的DataKey。租户可能是单个用户,或更可能是一组用户。图 6.5 显示了一个软件即服务 (SaaS) 应用进程示例,该应用进程为许多零售公司提供库存控制。在本例中,Joe 在 Dress4U 工作并拥有登录时的 DataKey。

图 6.5 当 Joe 登录时,将在 DataKeyLookup 表中查找他的名字和 UserId,并将相应的 DataKey (123) 添加到他的用户声明中。当 Joe 要求提供库存列表时,将从用户声明中提取 DataKey,并在创建应用进程时将其提供给应用进程的 DbContext。然后,DataKey 将用于应用于 Stock 表的全局查询筛选器。因此,乔只看到蓝色连衣裙和银色连衣裙。

在 Book App 中,没有人需要登录,因此您无法实现图 6.5 中所示的确切方法,但它确实有一个篮子 cookie,您可以使用伪 UserId。当用户在图书应用中选择要购买的图书时,会创建一个购物篮 Cookie,用于保存用户购物篮中的每本书,以及一个 UserId。如果用户单击“我的订单”菜单项以仅显示来自该用户的订单,则使用此购物篮 cookie。以下代码从购物篮 Cookie 中获取 UserId,并使用查询筛选器仅返回用户创建的订单。此代码有两个主要部分:

  • UserIdService 从篮子 cookie 中获取 UserId。
  • IUserIdService 通过应用进程的 DbContext 构造函数注入,用于访问当前用户。

下面的列表显示了 UserIdService 代码,该代码依赖于 IHttpContextAccessor 来访问当前的 HTTP 请求。

清单 6.4 UserIdService,它从篮子 cookie 中提取 UserId

public class UserIdService : IUserIdService
{
    //IHttpContextAccessor 是一种访问当前 HTTP 上下文的方法。要使用它,您需要使用命令 services 在 Startup 类中注册它。AddHttpContextAccessor()中。
    private readonly IHttpContextAccessor _httpAccessor;

    public UserIdService(IHttpContextAccessor httpAccessor)
    {
        _httpAccessor = httpAccessor;
    }

    //在某些情况下,HTTPContext 可能为 null,例如后台任务。在这种情况下,请提供一个空的 GUID。 
    public Guid GetUserId()
    {
        var httpContext = _httpAccessor.HttpContext; 
        if (httpContext == null)
            return Guid.Empty;

        //使用现有服务查找购物篮 Cookie。如果没有 cookie,则代码将返回空 GUID。 
        var cookie = new BasketCookie(httpContext.Request.Cookies); 
        if (!cookie.Exists())
            return Guid.Empty;
 
        //如果存在篮子 cookie,则创建 CheckoutCookieService,该服务提取 UserId 并返回它
        var service = new CheckoutCookieService(cookie.GetValue()); 
        return service.UserId;
    }	
}

当有一个值要充当 DataKey 时,需要将其提供给应用进程的 DbContext。典型的方法是通过 DI 构造函数注入;注入的服务提供了一种获取 DataKey 的方法。在我们的示例中,我们使用从篮子 cookie 中获取的 UserId 作为 DataKey。然后,在应用于 Order 实体类中的 CustomerId 属性的查询筛选器中使用该 UserId,该实体类包含创建 Order 的人员的 UserId。对 Order 实体的任何查询将仅返回当前用户创建的 Orders。下面的清单显示了如何将 UserIdService 服务注入到应用进程的 DbContext 中,然后在查询筛选器中使用该 UserId。

清单 6.5 Book App 的 DbContext 注入了 UserId 和 Query Filter

public class EfCoreContext : DbContext
{
    private readonly Guid _userId; //此属性保存 Order 实体类的查询筛选器中使用的 UserId。
 
    public EfCoreContext(DbContextOptions<EfCoreContext> options, //设置应用进程 DbContext 的常规选项
        IUserIdService userIdService = null) //设置 UserIdService。请注意,此参数是可选的,这使得在不使用查询筛选器的单元测试中使用起来要容易得多。
        : base(options)
    {
        //设置 UserId。如果 UserId 为 null,则简单替换版本提供默认的 Guid.Empty 值。
        _userId = userIdService?.GetUserId() ?? new ReplacementUserIdService().GetUserId();
    }
 
    public DbSet<Book> Books { get; set; }
    //… rest of DbSet<T> left out

    protected override void OnModelCreating(ModelBuilder modelBuilder) //配置 EF Core 并设置查询过滤器的方法
    {
        //… other configuration left out for clarity

        //软删除查询过滤器 (Soft-delete Query Filter)
        modelBuilder.Entity<Book>()
        .HasQueryFilter(p => !p.SoftDeleted); modelBuilder.Entity<Order>()
        .HasQueryFilter(x => x.CustomerName == _userId); //Order 查询筛选器,将从 cookie 篮子中获取的当前 UserId 与 Order 实体类中的 CustomerId 进行匹配
    }
}

需要明确的是,应用进程 DbContext 的每个实例都会获取当前用户的 UserId,或者如果他们从未“购买”一本书,则获取空 GUID。 DbContext 的配置是在第一次使用时设置并缓存的,而 lambda 查询过滤器链接到一个名为 _userId 的实时字段。查询过滤器是固定的,但 _userId 是动态的,并且可以在 DbContext 的每个实例上更改。

但重要的是,查询过滤器不要放在单独的配置类中(请参阅第 7.5.1 节),因为 _userId 将固定为首次使用时提供的 UserId。您必须将 lambda 查询放在可以获取动态信息的地方

_userId 变量。在本例中,我将其放置在应用进程 DbContext 内的 OnModelCreating 方法中,这很好。在第 7 章中,我向您展示了一种自动配置查询过滤器以保持 _userId 动态的方法;参见第 7.15.4 节。

如果您有用户登录的 ASP.NET Core 应用进程,则可以使用 IHttpContextAccessor 访问当前的 ClaimPrincipal。 ClaimPrincipal 包含登录用户的声明列表,包括其 UserId,该 UserId 存储在声明中,其名称由系统常量 ClaimTypes.NameIdentifier 定义。或者,如图 6.5 所示,您可以在登录时向用户添加新的 Claim,以提供在查询过滤器中使用的 DataKey。

注意:有关完整多租户系统的示例,其中用户的 Id 用于在登录时查找租户的 DataKey 并将 DataKey 声明添加到用户声明中,请参阅 http://mng.bz/yY7q 上的文章。

6.1.7 考虑需要特别注意的LINQ命令

EF Core 在将 LINQ 方法映射到 SQL(大多数关系数据库的语言)方面做得非常出色。但三种类型的 LINQ 方法需要特殊处理:

  • 一些 LINQ 命令需要额外的代码才能适应数据​​库的工作方式,例如 LINQ Average、Sum、Max 以及处理 null 返回所需的其他聚合命令。唯一不会返回 null 的聚合是 Count。
  • 一些 LINQ 命令可以与数据库一起使用,但只能在严格的范围内使用,因为数据库不支持该命令的所有可能性。一个示例是 GroupBy LINQ 命令;数据库只能有一个简单的键,并且 IGrouping 部分有很大的限制。
  • 一些 LINQ 命令与数据库功能非常匹配,但对数据库可以返回的内容有一些限制。例如 Join 和 GroupJoin。

EF Core 文档有一个很棒的页面,名为“复杂查询运算符”(请参阅​​ http://mng.bz/MXan),其中对其中许多命令进行了很好的描述,因此我不打算逐一介绍它们。但我确实想警告您有关令人恐惧的 InvalidOperationException 异常,其中包含一条消息,其中包含无法翻译的单词,并告诉您收到该异常后该怎幺做。

问题是,如果你的 LINQ 稍有错误,你就会得到无法翻译的异常。除了表明您应该通过插入对 AsEnumerable 的调用来显式切换到客户端评估之外,该消息对于诊断问题可能没有太大帮助(但请参阅以下注释) ”。尽管您可以切换到客户端评估,但您的性能可能会受到(很大的)影响。

注意:EF Core 团队正在优化从无法翻译的异常返回的消息,并为常见情况添加特定消息,例如尝试将 String.Equal 方法与 StringComparison 参数(无法转换为 SQL)一起使用。

以下部分提供了一些提示,使更主流的复杂命令与关系数据库一起使用。我还建议你测试任何复杂的查询,因为它们很容易出错。

聚合需要 NULL(除了 COUNT)

您可能会使用 LINQ 聚合 Max、Min、Sum、Average、Count 和 CountLong,因此下面是一些有关如何使它们正常工作的提示:

  • 如果对数据库中有意义的内容(例如行或关系链接(例如一本书的评论数)进行计数,则 Count 和 CountLong 方法可以正常工作。
  • LINQ 聚合 Max、Min、Sum 和 Average 需要可为 null 的结果,例如上下文。Books.Max(x => (十进制?x.价格)。如果源(在此示例中为 Price)不可为 null,则必须已强制转换为源的可为 null 版本。此外,如果您使用 Sqlite 进行单元测试,请记住它不支持十进制,因此即使您使用可为 null 的版本,您也会收到错误。
  • 不能直接在数据库上使用 LINQ Aggregate 方法,因为它执行每行计算。

GROUPBY LINQ 命令

另一个有用的 LINQ 方法是 GroupBy。在 SQL 数据库上使用 GroupBy 时,Key 部分必须是标量值(或多个值),因为这是 SQL GROUP BY 支持的内容。IGrouping 部分可以是所选数据,包括一些 LINQ 命令。我的经验是,您需要将 GroupBy 命令与执行命令(参见第 2.3.3 节)一起执行,例如 ToList。其他任何似乎都会导致无法翻译的异常。

下面是一个取自客户端应用进程的真实示例,更改了一些名称以保留客户端的机密。请注意,Key 可以是标量列和 IGrouping 部分的组合:

var something = await _context.SomeComplexEntity
    .GroupBy(x => new { x.ItemID, x.Item.Name })
    .Select(x => new
    {
        Id = x.Key.ItemID, Name = x.Key.Name,
        MaxPrice = x.Max(o => (decimal?)o.Price)
    })
    .ToListAsync();

6.1.8 使用 AutoMapper 自动构建 Select 查询

在第 2 章中,您了解到 Select 查询允许您构建一个查询来准确返回您需要的数据,并且从性能方面来看,这些查询通常也非常高效。问题是它们需要更多的时间来编写 - 只需多几行,但实际应用进程可能有数千个查询,因此每个 Select 查询都会增加开发时间。我一直在寻找自动化的方法,而 AutoMapper (https://automapper.org) 可以帮助您自动构建 Select 查询。

我不会描述 AutoMapper 的所有功能,这可能需要一整本书!但我将向您概述如何设置和使用 AutoMapper,因为我认为其他主题没有很好地涵盖这些主题。首先,我们将手动编码的简单 Select 查询与 AutoMapper 构建的 Select 查询进行比较,如图 6.6 所示。

图 6.6 两个版本的 Select 查询生成相同的结果和相同的 SQL 代码。此查询非常简单,只复制了三个属性,但它可以让您了解 AutoMapper 的工作原理。在本例中,DTO 的属性与要复制的属性具有相同的类型和名称,这意味着 AutoMapper 将自动生成 LINQ 代码来复制这三个属性。

虽然图 6.6 中的示例很简单,但它表明您可以使用 AutoMapper 的 ProjectTo 方法将 Select 查询折叠为一行。图 6.6 使用 AutoMapper 的 By Convention 配置,它通过按每个属性的类型和名称进行匹配,将源(在本例中为 Book 类)中的属性映射到 DTO 属性。 AutoMapper可以自动映射一些关系。例如,十进制类型且名为 PromotionNewPrice 的属性将映射图书的 Promotion.NewPrice 关系。 (此 AutoMapper 功能称为扁平化;请参阅 http://mng.bz/aorB。)

 图 6.7 显示了使用 AutoMapper 的四种常规配置:

图 6.7 AutoMapper 将 Book 实体类映射到 BookDto 类的四种方式。默认约定是通过相似的名称和类型进行映射,包括通过具有与属性 access 等效但不带点的名称来处理关系。例如,DTO 属性 PromotionNewPrice 会自动映射到源中的 Promotion.NewPrice 属性。映射也可以嵌套;实体类中的集合可以映射到具有 DTO 的集合。

  • 相同类型和相同名称映射——属性通过具有相同类型和相同名称从实体类映射到 DTO 属性。
  • 修剪属性 - 通过从 DTO 中省略实体类中的属性,Select 查询不会加载这些列。
  • 扁平化关系 - DTO 中的名称是导航属性名称和导航属性类型中的属性的组合。例如,Promotion.NewPrice 的 Book 实体引用映射到 DTO 的 PromotionNewPrice 属性。
  • 嵌套 DTO - 此配置允许您将集合从实体类映射到 DTO 类,以便您可以从导航集合属性中的实体类复制特定属性。

现在您已经了解了 AutoMapper 的功能,我想为您提供一些有关如何使用和配置它的提示。

对于简单映射,请使用 [AUTOMAP] 属性

使用 AutoMapper 的 ProjectTo 方法非常简单,但它依赖于 AutoMapper 的配置,后者更复杂。在 AutoMapper 的 8.1 版中,Jimmy Bogart 添加了 AutoMap 属性,该属性允许按约定配置简单映射。以下代码片段在第一行(粗体)中显示了 [AutoMap] 属性,您可以在其中定义此 DTO 应从哪个实体类映射:

[AutoMap(typeof(Book))]
public class ChangePubDateDtoAm
{
    public int BookId { get; set; } 
    public string Title { get; set; }
    public DateTime PublishedOn { get; set; }
}

通过 AutoMap 属性映射的类使用 AutoMapper 的 By Convention 配置,并带有一些参数和属性以允许进行一些调整。正如您在图 6.7 中所看到的,按照惯例,它可以做很多事情,但肯定不是您可能需要的所有工作。为此,您需要 AutoMapper 的 Profile 类。

复杂映射需要配置文档类

当 AutoMapper 的 By Convention 方法还不够时,您需要生成一个 AutoMapper Profile 类,该类允许您为 By Convention 方法未涵盖的属性定义映射。例如,要将一本书映射到清单 2.10 和 2.11 中描述的 BookListDto,九个 DTO 属性中的三个需要特殊处理。您必须创建一个 MappingConfiguration。有几种方法可以做到这一点,但通常情况下,你使用 AutoMapper 的 Profile 类,该类很容易找到和注册。下面的列表显示了一个类,该类继承了 Profile 类,并设置了过于复杂而 AutoMapper 无法推断的映射。

清单 6.6 AutoMapper Profile 类为某些属性配置特殊映射

 

public class BookListDtoProfile : Profile //您的类必须继承 AutoMapper Profile 类。可以有多个继承 Profile 的类。
{
    public BookListDtoProfile()
    {
        CreateMap<Book, BookListDto>() //设置 Book 实体类到 BookListDto 的映射
            //实际价格取决于促销活动是否具有价格优惠。
            .ForMember(p => p.ActualPrice,
                m => m.MapFrom(s => s.Promotion == null ? s.Price : s.Promotion.NewPrice))
            .ForMember(p => p.AuthorsOrdered,
                m => m.MapFrom(s => string.Join(", ", s.AuthorsLink.Select(x => x.Author.Name))))
            //包含使 Average 方法在数据库中运行所需的特殊代码
            .ForMember(p => p.ReviewsAverageVotes, m => m.MapFrom(s =>
                s.Reviews.Select(y => (double?)y.NumStars).Average()));
    } //以逗号分隔的字符串获取作者名单
}

此代码设置了 9 个属性中的三个,其他 6 个属性使用 AutoMapper 的 By 约定方法,这就是 ListBookDto 类中某些属性名称很长的原因。例如,名为 PromotionPromotionalText 的 DTO 属性具有该名称,因为它按约定映射到导航属性 Promotion,然后映射到 PriceOffer 实体类中的 PromotionalText 属性。

您可以在一个配置文档中添加大量 CreateMap 调用,也可以拥有多个配置文档。配置文档可能会变得复杂,管理它们是使用 AutoMapper 所涉及的主要痛点。我的一个客户有一个 1,000 行长的个人资料。

注册 AUTOMAPPER 配置

最后一个阶段是使用依赖注入注册所有映射。幸运的是,AutoMapper 有一个名为 AutoMapper.Extensions.Microsoft.DependencyInjection 的 NuGet 包,其中包含 AddAutoMapper 方法,该方法扫描您提供的进程集并将 IMapper 接口注册为服务。使用 IMapper 接口为具有 [AutoMap] 属性的所有类和继承 AutoMapper 的 Profile 类的所有类注入配置。在 ASP.NET Core 应用进程中,以下代码片段将添加到 Startup 类的 Configure 方法中:

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllersWithViews();
    // … other code removed for clarity

    services.AddAutoMapper( MyAssemblyToScan1, MyAssemblyToScan2…);
}

6.1.9 评估 EF Core 在读取数据时如何创建实体类

到目前为止,本书中的实体类还没有用户定义的构造函数,因此,如果你在该实体类中阅读,EF Core 将使用默认的无参数构造函数,然后直接更新属性和支持字段。(第 7 章介绍支持字段。但有时,使用带有参数的构造函数很有用,因为它可以更轻松地创建实例,或者因为您希望确保以正确的方式创建类。

注意:使用构造函数创建类是一种很好的方法,因为您可以定义创建有效实例所必须设置的参数。将 DDD 方法与 EF Core 一起使用时(请参阅第 13 章),创建实体类的唯一方法是通过某种形式的构造函数或静态工厂。

自 EF Core 2.1 以来,EF Core 在需要创建实体类实例时(通常是在读取数据时)使用实体类的构造函数。如果对构造函数使用 EF Core 的“按约定”模式,即构造函数的参数按类型和名称(使用 camel/Pascal 大小写)与属性匹配,并且不包含导航属性,如以下清单所示),则 EF Core 也将使用它。

public class ReviewGood
{
//您可以将属性设置为具有私有 setter。EF Core 仍可以设置它们。
    public int Id { get; private set; }
    public string VoterName { get; private set; }
    public int NumStars { get; set; }

    public ReviewGood //构造函数不需要类中所有属性的参数。此外,构造函数可以是任何类型的可访问性:公共、私有等。
        (string voterName) //EF Core 将查找具有相同类型和与属性匹配的名称的参数(与名称的 Pascal/camel 大小写版本匹配)。
    {
        VoterName = voterName;  //转让不应包括对数据的任何更改;否则,您将无法获得数据库中的确切数据。
        NumStars = 2; //对没有参数的属性的任何赋值都可以。EF Core 将在构造函数之后将该属性设置为从数据库读回的数据。
    }
}	

我本可以向 ReviewGood 类添加一个构造函数来设置所有非导航属性,但我想指出,EF Core 可以使用构造函数创建实体实例,然后填充构造函数参数中不存在的任何属性。现在,在查看了有效的构造函数之后,让我们看看 EF Core 不能或不会使用的构造函数,以及如何处理每个问题。

可能导致 EF CORE 问题的构造函数

EF Core 无法使用的第一种构造函数类型是具有类型或名称不匹配的参数的构造函数。下面的清单显示了一个示例,其中包含一个名为 starRating 的参数,该参数分配给名为 NumStars 的属性。如果此构造函数是唯一的构造函数,则 EF Core 将在首次使用应用进程的 DbContext 时引发异常。

列表 6.8 包含 EF Core 无法使用的构造函数的类,导致异常

public class ReviewBadCtor
{
    public int Id { get; set; }
    public string VoterName { get; set; } 
    public int NumStars { get; set; }

    public ReviewBadCtor //此类中的唯一构造函数
        ( string voterName, int starRating) //此参数的名称与此类中任何属性的名称都不匹配,因此 EF Core 在读取数据时无法使用它来创建类的实例。
    {
        VoterName = voterName; NumStars = starRating;
    }
}

EF Core 无法使用的构造函数的另一个示例是具有设置导航属性的参数的构造函数。例如,如果 Book 实体类的构造函数包含用于设置 PriceOffer Promotion 导航属性的参数,则 EF Core 也无法使用它。EF Core 可以使用的构造函数只能具有非关系属性。

如果构造函数与 EF Core 的 By Convention 模式不匹配,则需要提供 EF Core 可以使用的构造函数。标准解决方案是添加一个私有无参数构造函数,EF Core 可以使用该构造函数创建类实例并使用其常规参数/字段设置。

注意:EF Core 可以使用带有访问修饰符的构造函数。例如,它使用从私有构造函数到公共构造函数的任何级别的访问。正如您已经看到的,它还可以写入具有私有 setter 的属性,例如 public int Id{get; private set;}。EF Core 可以处理只读属性(例如实例公共 int Id {get;}),但存在一些限制;请参见 http://mng.bz/go2E

如果在将参数数据分配给匹配属性时更改参数数据,则会出现另一个更微妙的问题。以下代码片段会导致问题,因为读入的数据将在赋值中更改:

public ReviewBad(string voterName)
{
    VoterName = "Name: "+voterName; //在分配给属性之前更改参数
    //… other code left out
}

ReviewBad构造函数中赋值的结果意味着,如果数据库中的数据是XXX,那幺读取后会是Name: XXX,这不是你想要的。解决方案是更改参数名称,使其与属性名称不匹配。在这种情况下,您可以将其称为 voterNameNeedingPrefix。

最后,请注意,当 EF Core 使用构造函数时,将应用对构造函数中的参数应用的检查和验证。如果您有一个测试来确保字符串不为空,那幺您应该将数据库列配置为非空(请参阅第 7 章),以确保数据库中的某些流氓数据不会返回空值。

EF Core 可以通过实体构造器注入某些服务

当我们讨论实体类构造函数时,我们应该看看 EF Core 通过实体类构造函数注入一些服务的能力。 EF Core 可以注入三种类型的服务,其中最有用的是注入一种允许延迟加载关系的方法,我对此进行了完整描述。另外两个用途是高级功能;我总结了它们的作用,并提供了 Microsoft EF Core 文档的链接以获取更多信息。

在第 2.4.4 节中,您学习了如何通过 Microsoft.EntityFrameworkCore.Proxies NuGet 包配置关系的延迟加载。该包是配置延迟加载的最简单方法,但它有一个缺点,即必须将所有导航属性设置为使用延迟加载,即每个导航属性都必须将关键字 virtual 添加到其属性定义中。

如果要限制哪些关系使用延迟加载,可以通过实体类的构造函数获取延迟加载服务。然后,更改导航属性以在属性的 getter 方法中使用此服务。下面的清单显示了一个 BookLazy 实体类,该类具有两个关系:一个不使用延迟加载的 PriceOffer 关系和一个使用延迟加载的 Reviews 关系。

清单 6.9 显示延迟加载如何通过注入的延迟加载器方法工作

 

public class BookLazy
{
    public BookLazy() { } //你需要一个公共构造函数,以便你可以在代码中创建这本书。
 
    private BookLazy(ILazyLoader lazyLoader)
    {
        _lazyLoader = lazyLoader;
    }

    private readonly ILazyLoader _lazyLoader; //EF Core 使用此私有构造函数来注入 LazyLoader。
    public int Id { get; set; }
    public PriceOffer Promotion { get; set; } //未通过延迟加载加载的普通关系链接

    private ICollection<LazyReview> _reviews;  //实际的审查是在后备领域进行的(见第8.7节)。
    public ICollection<LazyReview> Reviews //您将访问的列表
    {
        get => _lazyLoader.Load(this, ref _reviews); //读取属性将触发数据的延迟加载(如果尚未加载)。
        set => _reviews = value; //该集只是更新了支持字段。
    }
}

通过 ILazyLoader 接口注入服务需要将 NuGet 包 Microsoft.EntityFrameworkCore.Abstractions 添加到项目中。该包具有最小的类型集并且没有依赖项,因此它不会通过引用 DbContext 和其他数据访问类型来“污染”项目。

但是,如果您实施的架构不允许其中包含任何外部包,则可以通过在实体的构造函数中使用 Action 类型来添加参数。 EF Core 将使用一个操作填充 Action 类型的参数,该操作将实体实例作为其第一个参数,将字段名称作为第二个参数。调用此操作时,它将关系数据加载到给定实体类实例中的命名字段中。

注意:通过提供小型扩展方法,可以使 Action 选项的工作方式类似于 ILazyLoader。可以在 EF Core 文档页的“无代理延迟加载”部分末尾的扩展方法中看到此效果,该部分位于与本书关联的 GitHub 存储库的 Test 项目的 LazyBook2 类 http://mng.bz/e5Zv

通过构造函数将服务注入实体类的另外两种方法如下:

  • 如果您想在实体类中运行数据库访问,则注入实体类链接到的 DbContext 实例非常有用。在第 13 章中,我介绍了在实体类中执行数据库访问的优点和缺点。简而言之,除非您有严重的性能或业务逻辑问题且无法通过任何其他方式解决,否则您不应该使用此技术。
  • 此实体类实例的 IEntityType 使您可以访问有关此实体的配置、状态、EF Core 信息,等等与此实体类型关联的信息。

这两种技术都是高级功能,我不会详细介绍它们。有关实体类构造函数的 EF Core 文档提供了有关此主题的更多信息;参见 http://mng.bz/pV78

6.2 使用 EF Core 写入数据库

本章的第一部分是关于查询数据库的。现在,您将把注意力转向写入数据库:创建、更新和删除实体类。与第 6.1 节一样,目的是让您了解 EF Core 在写入数据库时在内部的工作方式。第 6.1 节的一些小节是关于了解写入数据库时发生的情况,还有一些是用于快速复制或删除数据的简洁技术。以下是我将介绍的主题列表:

  • 评估 EF Core 如何将具有关系的实体写入数据库
  • 评估 DbContext 如何处理写出具有关系的实体
  • 快速复制具有关系的数据
  • 快速删除实体

6.2.1 评估 EF Core 如何将实体/关系写入数据库

当你创建具有新关系的新实体时,导航属性是你的朋友,因为 EF Core 会为你处理填充外键的问题。下一个列表显示了一个简单的示例:添加一本具有新评论的新书。

清单 6.10 添加一个新的 Book 实体和一个新的 Review

var book = new Book //创建一本新书
{
    Title = "Test",
    Reviews = new List<Review>()
};
book.Reviews.Add(new Review { NumStars = 1 });  //将新的“评论”添加到“书评”导航属性中
context.Add(book);  //Add 方法表示应将实体实例添加到相应的行中,并添加或更新任何关系。
context.SaveChanges(); //SaveChanges 执行数据库更新。

若要将这两个链接的实体添加到数据库,EF Core 必须执行以下操作:

  • 确定创建这些新行的顺序 - 在本例中,它必须在 Books 表中创建一行,以便它具有 Book 的主键。
  • 将任何主键复制到任何关系的外键中 - 在本例中,它将 Books 行的主键 BookId 复制到新 Review 行中的外键中。
  • 复制回在数据库中创建的任何新数据,以便实体类正确表示数据库 - 在这种情况下,它必须复制回 BookId 并更新 Book 和 Review 实体类中的 BookId 属性以及 Review 实体类的 ReviewId。

以下列表显示了此创建的 SQL。

清单 6.11 创建两行的 SQL 命令,返回主键

 

--首次数据库访问
SET NOCOUNT ON; --由于 EF Core 想要返回主键,因此它会关闭数据库更改的返回。
INSERT INTO [Books] ([Description], [Title], ...) VALUES (@p0, @p1, @p2, @p3, @p4, @p5, @p6); --在 Books 表格中插入一个新行。数据库生成 Book 的主键。
SELECT [BookId] FROM [Books]
WHERE @@ROWCOUNT = 1 AND [BookId] = scope_identity(); --返回主键,并进行检查以确保已添加新行
 
--第二次数据库访问 
SET NOCOUNT ON;
INSERT INTO [Review] ([BookId], [Comment], ...) VALUES (@p7, @p8, @p9, @p10); --在“审阅”(Review) 表格中插入一个新行。数据库生成 Review 的主键。
SELECT [ReviewId] FROM [Review]
WHERE @@ROWCOUNT = 1 AND [ReviewId] = scope_identity(); --返回主键,并进行检查以确保已添加新行

这个例子很简单,但它涵盖了所有主要部分。您需要了解的是,您可以创建具有关系和这些关系的关系的复杂数据,EF Core 将研究如何将它们添加到数据库中。

我看过 EF Core 代码,其中开发人员使用对 SaveChanges 方法的多次调用来获取第一次创建的主键,以设置相关实体的外键。如果具有链接不同实体的导航属性,则无需执行此操作。因此,如果您认为需要调用 SaveChanges 两次,通常您没有设置正确的导航属性来处理这种情况。

警告:不建议多次调用 SaveChanges 以创建具有关系的实体,因为如果第二个 SaveChanges 由于某种原因失败,则数据库中的数据集不完整,这可能会导致问题。有关更多信息,请参阅第 3.2.2 节中名为“为什幺应该在更改结束时仅调用一次 SaveChanges”的侧边栏。

6.2.2 评估 DbContext 如何处理写出实体/关系

在第 6.2.1 节中,你了解了 EF Core 在数据库端执行的操作,但现在我们将了解 EF Core 内部发生的情况。大多数时候,你不需要这些信息,但有时,知道它很重要。例如,如果在调用 SaveChanges 期间捕获更改,则仅在调用 SaveChanges 之前获取其状态,但只有在调用 SaveChanges 之后才具有新创建的实体的主键。

注意:我在写本书的第一版时遇到了 SaveChanges 之前/之后的问题。我需要检测对 Book 实体类的更改及其任何相关实体类(如 Review、BookAuthor 和 PriceOffer)的更改。此时,我需要在开始时捕获每个实体的状态,但在 SaveChanges 完成之前,我可能没有正确的外键。

即使你没有尝试像 SaveChanges 之前/之后问题这样复杂的事情,了解 EF Core 的工作原理也很好。此示例比上一个示例稍微复杂一些,因为我想向您展示 EF Core 处理实体类的新实例而不是从数据库中读取的实体实例的不同方式。下一个列表中的代码将创建一个新书,但作者已在数据库中。代码中有注释 STAGE 1、STAGE 2 和 STAGE 3,我描述了每个阶段之后会发生什幺。

清单 6.12 创建一个新书,其中包含指向现有作者的新多对多链接

//这三个阶段中的每一个都以评论开始。
//STAGE1
var author = context.Authors.First(); //为新书阅读现有作者
var bookAuthor = new BookAuthor { Author = author };  //创建新的 BookAuthor 链接行,准备链接到 Book to the Author
//创建一本书,并使用单个条目填充 AuthorsLink 导航属性,将其链接到现有作者
var book = new Book
{
    Title = "Test Book",
    AuthorsLink = new List<BookAuthor> { bookAuthor }
};

//STAGE2
context.Add(book); //调用 Add 方法,该方法告知 EF Core 需要将 Book 添加到数据库中

//STAGE3
context.SaveChanges(); //SaveChanges 查看所有跟踪的实体,并确定如何更新数据库以实现您要求它执行的操作。

图 6.8、6.9 和 6.10 显示了实体类内部发生的情况及其在每个阶段的跟踪数据。这三个图中的每一个都显示了其阶段结束时的以下数据:

  • 每个实体实例在流程的每个阶段的状态(显示在每个实体类的上方)
  • 括号中当前值的主键和外键。如果键为 (0),则尚未设置。
  • 导航链接显示为从导航属性到它所链接到的相应实体类的连接。
  • 每个阶段之间的变化,以粗体文本或较粗的线条表示导航链接。

图 6.8 显示了第 1 阶段完成后的情况。此初始代码设置了一个新的 Book 实体类(左),其中包含一个新的 BookAuthor 实体类(中间),该实体类将 Book 链接到现有的 Author 实体类(右)。

图 6.8 第 1 阶段结束。此图显示,具有链接到该书的新 BookAuthor 的新 Book 的状态为 Detached,而从数据库中读入的现有 Author 的状态为 Unchanged。该图还显示了代码为将 Book 实体链接到 Author 实体而设置的两个导航链接。最后,Book 和 BookAuthor 的主键和外键未设置(即零),而 Author 实体具有现有的主键 (123),因为它已在数据库中。

图 6.8 是清单 6.12 中第 1 阶段完成后的三个实体类的图形版本。此图是调用任何 EF Core 方法之前的起点。图 6.9 显示了行上下文之后的情况。Add(book) 执行。这些更改以粗体显示,并带有添加的导航链接的粗线。

图 6.9 第 2 阶段结束。这里发生了很多事情。两个新实体 Book 和 BookAuthor 的状态已更改为 Added。同时,Add 方法尝试设置外键:它知道 Author 的主键,因此可以在 BookAuthor 实体中设置 AuthorId。它不知道 Book 的主键 (BookId),因此它会在隐藏的跟踪值中放置一个唯一的负数,充当伪键。Add 还具有一个关系修正阶段,用于填充任何其他导航属性。

您可能会对执行 Add 方法时发生的情况感到惊讶。 (我是!)似乎让实体尽可能接近调用 SaveChanges 后它们将所处的位置。以下是在第 2 阶段调用 Add 方法时发生的事情。

Add 方法将作为参数提供的实体的状态设置为已添加 — 在本例中为 Book 实体。然后,它通过导航属性或外键值查看链接到作为参数提供的实体的所有实体。对于每个链接实体,它执行以下操作:

  • 如果实体未被跟踪(即其当前状态为分离),则它将其状态设置为已添加。在此示例中,该实体是 BookAuthor。作者的状态不会更新,因为该实体已被跟踪。
  • 它填写任何外键作为正确的主键。如果链接的主键尚不可用,它会在主键和外键的跟踪数据的 CurrentValue 属性中放置一个唯一的负数,如图 6.9 所示。
  • 它填充当前未通过运行第 6.1.1 节中描述的关系修复版本设置的任何导航属性。这些关系如图 6.9 中的粗线所示。

在此示例中,要链接到的唯一实体由您的代码设置,但 Add 的关系修复阶段可以链接到任何跟踪的实体。如果当前 DbContext 中有大量关系和/或大量跟踪实体类,则对 Add 方法的调用可能需要一些时间才能执行。我在第 14 章详细介绍了这个性能问题。

最后一个阶段,即阶段 3,是调用 SaveChanges 方法时发生的情况,如图 6.10 所示。

图 6.10 第 3 阶段结束。完成 SaveChanges 后,Book 和 BookAuthor 实体已添加到数据库中:在 Books 和 BookAuthors 表中插入了两个新行。创建 Book 行意味着其主键由数据库生成,该数据库将复制回 Book 的 BookId 和 BookAuthor 的 BookId 外键。返回时,Book 的状态和 BookAuthor 设置为“未更改”。

您在第 6.2.1 节中看到,数据库设置或更改的任何列都会被复制回实体类中,以便实体与数据库匹配。在此示例中,更新了 Book 的 BookId 和 BookAuthor 的 BookId,以在数据库中创建键值。此外,现在,此数据库写入中涉及的所有实体都与数据库匹配,其状态将设置为“未更改”。

这个例子似乎是对“有效”事物的冗长解释,很多时候,你不需要知道为什幺。但是,当某些事情无法正常工作时,或者当您想要执行一些复杂的操作(例如记录实体类更改)时,此信息非常有用。

如果它们不同,哪个会胜出:导航链接或外键值?

我在第 6.2.2 节的第 2 阶段中指出,add 方法“通过导航属性或外键值查看链接到作为参数提供的实体的所有实体。如果导航链接链接到一个实体,而外键链接到另一个实体,哪个获胜?我的测试显示导航链接获胜。但该结果未在 EF Core 文档中定义。我已要求澄清(请参阅 https://github.com/dotnet/efcore/issues/21105),但在此问题得到答案之前,您必须测试代码以确保“导航属性优先于外键值”功能没有更改

6.2.3 使用关系复制数据的快速方法

有时,您希望复制实体类及其所有关系。我的一个客户需要将不同版本的定制设计结构发送给客户,以便他们可以选择他们喜欢的版本。这些设计有许多共同的部分,设计人员不想为每个设计键入这些数据;他们想构建第一个设计,并将其复制为下一个设计的起点。

一种解决方案是克隆每个实体类及其关系,但这是一项艰巨的工作。(我的客户的设计可能有数百个项目,每个项目有 ~25 个关系。但是,了解 EF Core 的工作原理后,我可以使用 EF Core 本身编写代码来复制设计。

例如,你将利用你对 EF Core 的了解来复制用户的图书应用订单,该订单具有 LineItems 的集合,而 LineItems 又链接到图书。您只想使用 LineItems 复制 Order,但不想复制 LineItems 链接到的 Books;一本书的两份副本会引起各种各样的问题。让我们首先查看要复制的订单,如下面的清单所示。

清单 6.13 创建一个订单,其中包含两个准备复制的 LineItem

var books = context.SeedDatabaseFourBooks();  //在这个测试中,添加四本书作为测试数据。
var order = new Order //创建一个包含两个要复制的 LineItem 的订单
{
    CustomerId = Guid.Empty,  //将 CustomerId 设置为默认值,以便查询筛选器读回订单
    LineItems = new List<LineItem>
    {
        new LineItem //添加链接到第一本书的第一个 LineNum
        {
            LineNum = 1, 
            ChosenBook = books[0], 
            NumBooks = 1
        },
        new LineItem //添加与第二本书相关的第二个 LineNum
        {
            LineNum = 2, 
            ChosenBook = books[1], 
            NumBooks = 2
        },
    }
};
context.Add(order); //将该订单写入数据库
context.SaveChanges();

要正确复制该订单,您需要了解三件事(并且您知道第 6.2.2 节中的前两件事):

  • 如果添加的实体具有未跟踪的链接实体(即状态为“已分离”),则这些实体将设置为“已添加状态”。
  • EF Core 可以通过导航链接查找链接的实体。
  • 如果尝试将实体类添加到数据库,并且主键已在数据库中,则会出现数据库异常,因为主键必须是唯一的。

了解这三件事后,可以让 EF Core 复制 Order 及其 LineItems,但不能复制 LineItems 链接到的 Books。下面是复制 Order 及其 LineItems,但不复制链接到 LineItems 的 Book 的代码。

清单 6.14 复制一个带有 LineItems 的订单

var order = context.Orders //此代码将查询 Orders 表。
    .AsNoTracking() //AsNoTracking 表示实体是只读的;他们的状态将是超然的。
    .Include(x => x.LineItems) //包括 LineItems,因为您也想复制它们。
    .Single(x => x.OrderId == id);  //接受您要复制的订单
order.OrderId = default; //您不添加 .ThenInclude(x => x.ChosenBook) 添加到查询中。如果这样做,查询将复制 Book 实体,这不是你想要的。

//将主键(Order 和 LineItem)重置为其默认值,告知数据库生成新的主键
order.LineItems.First().LineItemId = default; 
order.LineItems.Last().LineItemId = default;
//写出订单并创建副本
context.Add(order);
context.SaveChanges();

请注意,您尚未重置外键,因为您依赖于导航属性覆盖任何外键值这一事实。(请参阅前面的侧边栏“如果它们不同,哪个胜出:导航链接还是外键值?但是,由于您很小心,因此您可以生成一个单元测试来检查关系是否正确复制。

6.2.4 删除实体的快速方法

现在,您可以复制实体及其关系。如何快速删除实体?事实证明,有一种快速方法可以删除实体,该方法可以很好地用于使用 Web 应用进程时断开连接的状态删除。

第 3 章介绍了如何通过读取要删除的实体,然后使用该实体实例调用 EF Core 的 Remove 方法来删除实体。这种方法有效,但它需要两个数据库访问:一个用于读取要删除的实体,另一个用于调用 SaveChanges 以删除实体。但事实证明,Remove 方法所需要的只是设置了主键的相应实体类。下面的列表显示了通过提供 Book 的主键值 BookId 来删除 Book 实体。

清单 6.15 通过设置主键从数据库中删除实体

var book = new Book //创建要删除的实体类(在本例中为 Book)
{
    BookId = bookId //设置实体实例的主键
};
context.Remove(book);  //对 Remove 的调用会告知 EF Core 你希望删除此实体/行。
context.SaveChanges(); //SaveChanges 将命令发送到数据库以删除该行。

在断开连接的情况下,例如某种形式的 Web 应用进程,删除命令仅返回类型和主键值,从而使删除代码更简单、更快捷。一些小事情与关系的读取/删除方法不同:

  • 如果提供的主键没有行,EF Core 将引发 DbUpdateConcurrencyException,表示未删除任何内容。
  • 数据库处于删除其他链接实体的命令中;EF Core 对此没有发言权。(有关详细信息,请参阅第 8 章中对 OnDelete 的讨论。

总结

  • 当将实体类作为跟踪实体读取时,EF Core 使用称为关系修复的过程,该过程将所有导航属性设置为任何其他跟踪实体。
  • 正常的跟踪查询使用身份解析,为每个唯一主键使用一个实体类实例生成数据库结构的最佳表示。
  • AsNoTracking 查询比普通跟踪查询更快,因为它不使用身份解析,但它可以使用相同的数据创建重复的实体类。
  • 如果您的查询使用 Include 方法加载多个关系集合,则会创建一个大型数据库查询,这在某些情况下可能会很慢。
  • 如果您的查询缺少 Include 方法,您将得到错误的结果,但有一种方法可以设置您的导航集合,以便您的代码将失败而不是返回错误的数据。
  • 使用全局查询过滤器来实现软删除功能效果很好,但请注意如何处理依赖于软删除实体的关系。
  • 选择查询在数据库方面非常高效,但需要编写更多行代码。 AutoMapper 库可以自动构建 Select 查询。
  • EF Core 在读入数据时创建一个实体类。如果您遵循正常模式,它会通过默认的无参数构造函数或您编写的任何其他构造函数来执行此操作。
  • 当 EF Core 在数据库中创建实体时,它会读回数据库生成的任何数据,例如数据库提供的主键,以便它可以更新实体类实例以匹配数据库。