第5章 在 ASP.NET Core Web 应用进程中使用 EF Core

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

本章涵盖

  • 在 ASP.NET Core 中使用 EF Core
  • 在 ASP.NET Core 中使用依赖注入
  • 在 ASP.NET 核心MVC操作中访问数据库
  • 使用 EF Core 迁移更新数据库
  • 使用 async/await 提高可扩展性

在本章中,您将使用 ASP.NET Core 构建一个真正的 Web 应用进程,将所有内容集成在一起。当然,使用 ASP.NET Core 会涉及 EF Core 之外的问题,例如依赖项注入(在第 5.4 节中介绍)和 async/await(在第 5.10 节中介绍)。但是,如果要在此类应用进程中使用 EF Core,了解它们是必需的。

本章假定您已阅读第 2-4 章,并了解如何查询和更新数据库以及什幺是业务逻辑。本章介绍数据库访问代码的存放位置以及如何在实际应用进程中调用它。它还介绍了在 ASP.NET Core(包括 Blazor Server)应用进程中使用 EF Core 的特定问题。因此,本章包含大量有关 ASP.NET Core 的内容,但重点介绍如何在此类应用进程中很好地使用 EF Core。

最后,将介绍在后台任务获取应用进程 DbContext 实例的各种方法的更多信息。

5.1 ASP.NET Core 简介

ASP.NET Core网站指出,“ASP.NET Core是一个跨平台、高性能的开源框架,用于构建现代的、基于云的、互联网连接的应用进程”(http://mng.bz/QmOw)。这个总结是一个很好的总结,但 ASP.NET Core有很多很棒的功能,很难选择只针对哪些具体内容进行深入讨论。

注意:我推荐 Andrew Lock 的书 ASP.NET Core in Action(Manning,2020 年),以详细了解 ASP.NET Core 的许多功能。

多年来,我一直在使用 ASP.NET MVC5,这是 ASP.NET Core 的前身。我认为这是一个很好的框架,尽管性能有点慢。但对我来说,ASP.NET Core 使得 ASP.NET MVC5 大放异彩,性能有了显着提高,并采用了新的数据显示方式,例如 Razor Pages 和 Blazor。

提示:当我第一次尝试 ASP.NET Core 时,我对它的性能感到失望;事实证明,默认日志记录在开发模式下会减慢速度。当我用更快的内存日志记录替换普通记录器时,列出该书的图书应用进程页面速度快了三倍!因此,请注意过多的日志记录会减慢应用进程的速度。

在本书中,你将使用 ASP.NET Core 生成图书应用,这是一个 Web 应用进程,以演示 EF Core 如何与实际应用进程配合使用。ASP.NET Core 可以以多种方式使用,但用于图书应用示例。我们将使用 ASP.NET Core 的模型-视图-控制器 (MVC) 模式。

5.2 了解图书应用进程的架构

第 2 章展示了 Book App 的图表,第 4 章扩展了另外两个项目来处理业务逻辑。图 5.1 显示了第 4 章之后的组合架构,以及应用进程中的所有项目。在学习本章时,您将了解我们如何以及为何在各个项目中拆分数据库访问代码。原因之一是使 Web 应用进程更易于编写、重构和测试。这种分层架构创建了一个包含所有代码的可执行文档,它与许多云提供商配合得很好,如果 Web 应用进程负载过重,这些提供商可以启动更多的 Web 应用进程实例;主机将运行 Web 应用进程的多个副本,并放置负载平衡器以将负载分散到所有副本上。此过程在 Microsoft Azure 中称为横向扩展,在 Amazon Web Services (AWS) 中称为自动缩放。

注意:在第 3 部分中,我更新了图书应用进程的架构,以使用模块化单体、领域驱动设计和简洁的架构。请参阅 http://mng.bz/5jD1 上有关分层和干净体系结构的有用Microsoft文档。

图 5.1 图书应用进程中的所有项目。箭头显示 EF Core 数据在层中上下移动的主要路径。

5.3 理解依赖注入

ASP.NET Core 广泛使用依赖注入 (DI),就像 .NET 一样。您需要了解 DI,因为它是 ASP.NET Core 中用于获取应用进程 DbContext 实例的方法。

定义:依赖注入是一种将应用进程动态链接在一起的方法。通常,您可以编写 var myClass = new MyClass() 来创建 MyClass 的新实例。该代码可以工作,但是您已经对该类的创建进行了硬编码,并且只能通过更改代码来更改它。通过 DI,您可以使用 IMyClass 等接口向 DI 提供者注册您的 MyClass。然后,当您需要该类时,您使用 IMyClass myClass,DI 提供进程将动态创建一个实例并将其注入到 IMyClass myClass 参数/属性中。

使用 DI 有很多好处,主要如下:

  • DI 允许您的应用进程动态链接自身。 DI 提供商将计算出您需要哪些类并按正确的顺序创建它们。例如,如果您的类之一需要应用进程的 DbContext,DI 可以提供它。
  • 一起使用接口和 DI 意味着您的应用进程更加松散耦合;您可以将一个类替换为与相同接口匹配的另一个类。这种技术在单元测试中特别有用:您可以使用另一个实现接口的更简单的类(在单元测试中称为存根或模拟)来提供服务的替换版本。
  • 还存在其他更高级的功能,例如使用 DI 根据某些设置选择要返回的类。如果您正在开发模式下构建电子商务应用进程,您可能希望使用虚拟信用卡处理进程而不是正常的信用卡系统。

我经常使用 DI,如果没有它,我不会构建任何真正的应用进程,但我承认,当你第一次看到它时,它可能会令人困惑。

注意:本部分简要介绍了 DI,以便了解如何将 DI 与 EF Core 配合使用。如果您想了解有关 ASP.NET Core 中 DI 的更多信息,请参阅 http://mng.bz/Kv16 Microsoft 的文档。要全面了解 DI,请考虑 Steven Van Deursen 和 Mark Seemann 合著的 Dependency Injection Principles, Practices,and Patterns(Manning,2019 年)一书,其中有一整章介绍了 .NET Core DI (http://mng.bz/XdjG)。

5.3.1 为什幺需要在 ASP.NET Core 中了解 DI

第 2 章介绍了如何使用以下代码片段创建应用进程的 DbContext 的实例:

const string connection = "Data Source=(localdb)\\mssqllocaldb;" + "Database=EfCoreInActionDb.Chapter02;" + "Integrated Security=True;";

var optionsBuilder = new DbContextOptionsBuilder<EfCoreContext>();

optionsBuilder.UseSqlServer(connection);

var options = optionsBuilder.Options;

using (var context = new EfCoreContext(options))
{…

该代码有效,但存在一些问题。首先,您必须对每次进行数据库访问重复此代码。其次,此代码使用固定的数据库访问字符串(称为连接字符串),当您要将站点部署到主机时,该字符串将不起作用,因为主机数据库的位置将与用于开发的数据库不同。

可以通过多种方式解决这两个问题,例如,通过重写应用进程的 DbContext 中的 OnConfiguration 方法(在第 5.11.1 节中介绍)。但 DI 是处理这种情况的更好方法,也是 ASP.NET Core 使用的。使用一组略有不同的命令,您可以告诉 DI 提供进程如何创建应用进程的 DbContext(称为注册服务的过程),然后向 DI 请求 ASP.NET Core 系统中任何支持 DI 的应用进程 DbContext 实例。

5.3.2 ASP.NET Core 中依赖注入的基本示例

设置代码以配置应用进程的 DbContext 有点复杂,并且可能会隐藏 DI 部分。我在 ASP.NET Core 中的第一个 DI 示例(如图 5.2 所示)使用了一个名为 Demo 的简单类,您将在 ASP.NET 控制器中使用该类。这个例子在第 5.7 节中很有用,我将向您展示如何使用 DI 使您的代码更易于调用。

图 5.2 通过 DI 将名为 Demo 的类插入到控制器的构造函数中。右侧的代码注册 IDemo/Demo 对,AddControllersWithViews 命令注册所有 ASP.NET Core 控制器。当 ASP.NET Core 需要 HomeController(用于显示 HTML 页面)时,DI 将创建 HomeController。由于 HomeController 需要一个 IDemo 实例,因此 DI 将创建一个实例并将其注入到 HomeController 的构造函数中。

图 5.2 显示,通过向 ASP.NET Core 的 DI 注册 IDemo/Demo 对,您可以在 HomeController 类中访问它。已注册的类称为服务。

规则是,任何 DI 服务都可以在任何其他 DI 服务中引用或注入。在图 5.2 中,您注册了 IDemo/Demo 类,并调用了 AddControllersWithViews 配置方法来注册 ASP.NET Core 的控制器类,具体来说,在本例中为 HomeController 类。这允许您在 HomeController 的构造函数中使用 IDemo 接口,并且 DI 在 Demo 类上提供了一个实例。在 DI 术语中,使用构造函数注入来创建已注册的类的实例。在本章中,您将以各种方式使用 DI,但此处定义的规则和术语将帮助您理解这些后面的示例。

5.3.3 DI 创建的服务的生命周期

在谈论 EF Core 时,DI 的一个重要功能是 DI 创建的实例的生存期,即实例在丢失或释放之前存在多长时间。在我们的 IDemo/Demo 示例中,您将实例注册为瞬态实例;每次您请求 Demo 实例时,它都会创建一个新实例。如果要将自己的类与 DI 一起使用,则很可能会声明暂时的生存期;这就是我用于所有服务的方法,因为这意味着每个实例都从其默认设置开始。对于简单的、类似值的类,例如启动时的数据设置,您可以将它们声明为单例(每次都获得相同的实例)。

应用进程的 DbContext 是不同的。它的生存期设置为作用域,这意味着无论在一个 HTTP 请求期间请求多少个应用进程的 DbContext 实例,你都会获得相同的实例。但是,当该 HTTP 请求结束时,该实例将消失(从技术上讲,由于 DbContext 实现了 IDisposable,因此它被释放),并且您将在下一个 HTTP 请求中获得一个新的作用域实例。图 5.3 显示了三种生存期,每个新实例都有一个新字母。

图 5.3 DI 生成的实例有三种类型的生存期:单例、瞬态和作用域。此图显示了这三种类型,每种类型有四次注入,每个 HTTP 请求两次。字母代表每个实例。如果一个字母被多次使用,则所有这些注入都是该类的同一实例。

如果将应用进程的 DbContext 注入到多个类中,则需要对应用进程的 DbContext 使用作用域内生存期。例如,有时,最好将复杂的更新分解为多个类。如果这样做,则需要应用进程的 DbContext 在所有类中都相同;否则,在一个类中所做的更改将不会出现在另一个类中。

让我们将复杂的更新分解为 Main 类和 SubPart 类,其中 Main 类通过其构造函数中的 ISubPart 接口获取 SubPart 的实例。现在,Main 部件调用 ISubPart 接口中的方法,SubPart 代码加载实体类并更改属性。在整个更新结束时,Main 代码调用 SaveChanges。如果注入到 Main 和 SubPart 类中的两个应用进程的 DbContext 不同,则 SubPart 类所做的更改将丢失。

这种情况可能听起来很晦涩或不寻常,但即使在中等规模的应用进程中,它也可能经常发生。我经常将复杂的代码分解为单独的类,要幺是因为整个代码太大了,要幺是因为我想分别对代码的不同部分进行单元测试。

相反,每个 HTTP 请求都必须有自己的应用进程 DbContext 实例,因为 EF Core 的 DbContext 不是线程安全的(请参阅第 5.11.1 节)。这一事实就是为什幺应用进程的 DbContext 对每个 HTTP 请求都有作用域生存期的原因,也是 DI 如此有用的原因之一。

5.3.4 Blazor 服务器应用进程的特殊注意事项

如果您使用 Blazor 前端与 ASP.NET Core 后端(称为 Blazor 服务器托管模型)通信,则需要更改注册和/或获取应用进程 DbContext 实例的方法。问题在于,使用 Blazor 前端,您可以并行发送数据库访问调用,这意味着多个线程将尝试使用应用进程 DbContext 的一个实例,这是不允许的。

有几种方法可以解决此问题,但最简单的方法是为每次数据库访问创建应用进程 DbContext 的新实例。 EF Core 5 提供了一个 DbContext 工厂方法,每次调用它时都会创建一个新实例(请参阅第 5.4.3 节)。 DbContext 工厂方法可防止多个线程尝试使用应用进程 DbContext 的同一实例。

使用 DbContext 工厂方法的缺点是注册到 DI 的不同类不会使用相同的 DbContext 实例。例如,第 5.3.3 节中的作用域生命周期 DbContext 实例示例会导致问题,因为 Main 类和 SubPart 类将具有应用进程 DbContext 的不同实例。此问题的一种解决方案是让 Main 类获取应用进程的 DbContext 实例,并通过创建 SubPart 本身或通过方法参数将该实例传递给 SubPart 类。

即使 DbContext 工厂方法也可能在长期服务方面出现问题。 EF Core 团队编写了有关将 EF Core 与 Blazor Server 应用进程结合使用的指南,并通过一个示例应用进程展示了一些技术;参见 http://mng.bz/yY7G

5.4 通过 DI 提供应用进程的 DbContext

现在,你已了解 DI,可以设置应用进程的 DbContext 作为服务,以便以后可以通过 DI 访问它。在启动 ASP.NET Core Web 应用进程时,通过向 DI 提供进程注册应用进程的 DbContext,使用告知 EF Core 你正在访问的数据库类型及其位置的信息来执行此操作。

5.4.1 提供有关数据库位置的信息

开发应用进程时,需要在开发计算机上运行它,并访问本地数据库进行测试。数据库的类型将由业务需求定义,但数据库在开发计算机上的位置取决于你以及你使用的任何数据库服务器。

对于 Web 应用进程,数据库的位置通常不会硬编码到应用进程中,因为当 Web 应用进程移动到其主机时,它会发生变化,真实用户可以在其中访问它。因此,位置和各种数据库配置设置通常存储为连接字符串。此字符串存储在应用进程设置文档中,ASP.NET 启动时读取该文档。ASP.NET Core 具有一系列应用进程设置文档,但现在,您将专注于三个标准文档:

  • appsetting.json ——保存开发和生产通用的设置
  • 应用设置。Development.json——保存开发构建的设置
  • 应用设置。Production.json - 保存生产构建的设置(当 Web 应用进程部署到主机供用户访问时)

注意:ASP.NET Core 中的应用进程设置文档还有很多我们没有介绍的内容。请查看 APS.NET Core 文档以获取更完整的说明。

通常,开发连接字符串存储在 appsettings.Development.json 文档中,清单 5.1 显示了一个适合在 Windows PC 上本地运行 SQL 数据库的连接字符串。

注意:Visual Studio 安装包括一个名为 SQL Server Express 的功能,它允许您使用 SQL Server 进行开发。

清单 5.1 appsettings.包含数据库连接字符串的 Development.json 文档

{
    "ConnectionStrings": { "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=EfCoreInActionDb;Trusted_Connection=True"},
    … other parts removed as not relevant to database access
}

您需要编辑您的 appsettings.Development.json 文档,用于添加本地开发数据库的连接字符串。此文档可能具有也可能没有 ConnectionStrings 部分,具体取决于是否将“身份验证”设置为“单个用户帐户”。(“单个用户帐户”选项需要其自己的数据库,因此 Visual Studio 将授权数据库的连接字符串添加到 appsetting.json 文档。可以将连接字符串称为任何你喜欢的名称;此示例在我们的应用进程中使用名称 DefaultConnection。

5.4.2 向 DI 提供进程注册应用进程的 DbContext

下一步是在启动时向 DI 提供进程注册应用进程的 DbContext。ASP.NET Core 启动时要完成的任何配置都是在恰如其分的 Startup 类中完成的。此类在 ASP.NET Core 应用进程启动时执行,并包含用于设置/配置 Web 应用进程的多种方法。

应用进程的 DbContext for ASP.NET Core 具有一个构造函数,该构造函数采用定义数据库选项的 DbContextOptions 参数。这样,在部署 Web 应用进程时,数据库连接字符串可能会更改(请参阅第 5.8 节)。提醒一下,下面是图书应用的 DbContext 构造函数的外观,在此代码片段中以粗体显示:

public class EfCoreContext : DbContext
{
  //… properties removed for clarity

  public EfCoreContext( DbContextOptions<EfCoreContext> options)
    : base(options) {}

  //… other code removed for clarity
}

下面的列表显示了如何在 ASP.NET Core 应用进程中将应用进程的 DbContext 注册为服务。此注册是在 ASP.NET Core 应用进程的 Startup 类的 ConfigureServices 方法中完成的,以及需要注册的所有 DI 服务。

清单 5.2 在 ASP.NET Core 的 Startup 类中注册 DbContext

public void ConfigureServices(IServiceCollection services)  // Startup 类中的此方法设置服务。
{
    services.AddControllersWithViews();  // 设置一系列服务,以便与控制器和视图一起使用

    var connection = Configuration.GetConnectionString("DefaultConnection");  // 从 appsettings.json 文档中获取连接字符串,该文档可在部署时更改。

    services.AddDbContext<EfCoreContext>(options => options.UseSqlServer(connection));  // 将应用进程的 DbContext 配置为使用 SQL Server 并提供连接

    //… other service registrations removed
}

第一步:是从应用进程的 Configuration 类获取连接字符串。在 ASP.NET Core 中,Configuration 类是在 Startup 类构造函数中设置的,该构造函数读取应用进程设置文档。通过这种方式获取连接字符串,您可以在将代码部署到主机时更改数据库连接字符串。第 5.8.1 节介绍了如何部署使用数据库的 ASP.NET Core 应用进程,其中介绍了此过程的工作原理。

第二步:通过 DI 使应用进程的 DbContext 可用——由 AddDbContext 方法完成,该方法将应用进程的 DbContext、EfCoreContext 和 DbContextOptions 实例注册为服务。当您在 DI 拦截的地方使用 EfCoreContext 类型时,DI 提供进程将使用 DbContextOptions 选项创建应用进程的 DbContext 实例。或者,如果您在同一个 HTTP 请求中请求多个实例,DI 提供进程将返回相同的实例。当您在第 5.6 节中开始使用应用进程的 DbContext 进行数据库查询和更新时,您将看到此过程的实际运行。

5.4.3 向 DI 提供者注册 DbContext Factory

如第 5.3.4 节所述,Blazor Server 应用进程需要仔细管理应用进程的 DbContext 实例,就像其他一些应用进程类型一样。在 EF Core 5 中,添加了 IDbContextFactory 接口以及注册 DbContext 工厂的方法,如以下清单所示。

清单 5.3 在 ASP.NET Core 的 Startup 类中注册 DbContext 工厂

public void ConfigureServices(IServiceCollection services)  // Startup 类中的此方法设置服务。
{
    services.AddControllersWithViews();  // 设置一系列服务以用于控制器和视图

    var connection = Configuration.GetConnectionString("DefaultConnection"); // 从 appsettings.json 文档中获取连接字符串,该文档可在部署时更改。

    services.AddDbContextFactory<EfCoreContext>( options => options.UseSqlServer(connection));  // 将 DbContext 工厂配置为使用 SQL Server 并提供连接

    //… other service registrations removed
}

通常,仅在前端或应用进程中将 AddDbContextFactory 方法用于 Blazor,在这些应用进程中,你无法控制对同一应用进程的 DbContext 的并行访问,这会破坏线程安全规则(请参阅第 5.11.1 节)。许多其他应用进程(如 ASP.NET Core)会为您管理并行访问,因此您可以通过 DI 获取应用进程的 DbContext 实例。

5.5 从 ASP.NET Core 调用数据库访问代码

配置应用进程 DbContext 并将其注册为 DI 服务后,即可访问数据库。在这些示例中,您将运行查询以显示书籍并运行更新数据库的命令。您将重点介绍如何从 ASP.NET Core 执行这些方法;我假设您已经掌握了前几章中如何查询和更新数据库。

注意:示例代码主要介绍如何使用 ASP.NET Core MVC,但使用 DI 的所有示例也适用于所有形式的 ASP.NET Core:Razor Pages、MVC 和 Web API。一些部分还介绍了 Blazor Server 应用进程,因为通过 DI 获取应用进程的 DbContext 实例的处理方式不同。

5.5.1 ASP.NET Core MVC 工作原理及其使用术语的总结

首先,这里快速总结了如何使用 ASP.NET Core 来实现我们的图书应用进程。要显示各种 HTML 页面,您将使用 ASP.NET Core 控制器,该控制器是处理通过 Razor 视图交付 HTML 页面的类。为此,您将创建一个名为 HomeController 的类,它继承自 ASP.NET Core 的 Controller 类。该控制器有多个链接到其方法的 Razor 视图,这些方法在 ASP.NET Core 中称为操作方法。

我们的图书应用进程的 HomeController 有一个名为 Index 的操作方法,它显示图书列表,还有一个名为 About 的操作方法,它提供网站的摘要页面。您还有其他控制器来处理结帐、现有订单、管理操作等。尽管您可以将所有数据库访问代码放入每个控制器的每个操作方法中,但我很少这样做,因为我使用称为关注点分离 (SoC) 的软件设计原则,下一小节将对此进行解释。

5.5.2 EF Core 代码位于 Book 应用进程中的什幺位置?

正如您在第 5.2 节中了解到的,我们的 Book App 是使用分层架构构建的,这意味着可以在实际应用进程中使用的架构。在本部分中,您将了解 EF Core 数据库访问代码的各个部分的放置位置以及原因。

定义关注点分离是这样的想法:软件系统必须分解为功能重叠尽可能少的部分。它与另外两个原则相关:耦合和内聚。通过耦合,您希望应用进程中的每个项目都尽可能独立,而通过内聚,应用进程中的每个项目都应该具有提供相似或强相关功能的代码。请参阅 http://mng.bz/wHJS 了解更多信息。

图 5.4 使用之前的架构图(图 5.1)映射了数据库访问代码在应用进程中的位置。气泡显示您在每一层中找到的数据库代码类型。请注意,ASP.NET Core 项目和纯业务逻辑 (BizLogic) 项目中没有 EF Core 查询/更新代码。

图 5.4 图书应用中数据库访问代码(EF Core 代码)的位置。 以这种方式分离 EF Core 代码可以更轻松地查找、理解、重构和测试。

应用 SoC 原理对整个应用都有好处。您在第 4 章中了解了拆分复杂业务逻辑的原因。但在本章中,您将看到 ASP.NET Core 项目的好处:

  • ASP.NET Core 前端是关于显示数据的,而做好这一点是一项需要大量集中精力的艰巨任务。因此,你将使用服务层来处理 EF Core 命令,并将数据库数据转换为 ASP.NET Core 前端可以轻松使用的形式,通常通过 DTO(也称为 ASP.NET Core 中的 ViewModel)进行。然后,您可以专注于提供最佳用户体验,而不是考虑是否拥有正确的数据库查询。
  • ASP.NET 控制器通常有多个页面/操作(例如,一个用于列出项目,一个用于添加新项目,一个用于编辑项目,等等),每个页面/操作都需要自己的数据库代码。通过将数据库代码移出到服务层,可以为每个数据库访问创建单独的类,而不是将代码分布在整个控制器中。
  • 如果数据库代码位于服务层而不是在 ASP.NET Core 控制器中,则对数据库代码进行单元测试要容易得多。可以测试 ASP.NET 核心控制器,但如果代码访问 HtppRequest 等属性(确实如此),则测试可能会变得复杂,因为很难复制其中一些功能来使单元测试正常工作。

注意:可以使用 Microsoft.AspNetCore.Mvc.Testing NuGet 包对完整的 ASP.NET Core 应用进程运行测试。当您测试整个应用进程时,此测试称为集成测试,而单元测试侧重于测试应用进程的一小部分。您可以在 http://mng.bz/MXa7 上找到有关集成测试的更多信息。

5.6 实现书单查询页面

现在我已经设置好了场景,您将在我们的图书应用进程中实现图书列表中的 ASP.NET 核心部分。为了提醒您网站的外观,图 5.5 显示了图书应用进程的屏幕截图,其中包含图书列表和本地管理员更新功能。

在第 2 章中,您编写了一个名为 ListBooksService 的类,用于处理转换、排序、筛选和分页要显示的书籍的复杂性。您需要在控制器 HomeController 中名为 Index 的 ASP.NET Core 操作中使用此类。主要问题是,若要创建 ListBooksService 类的实例,需要应用进程的 DbContext 的实例。

图 5.5 图书应用进程的主页,显示图书列表和管理功能,包括更改图书的出版日期

5.6.1 通过 DI 注入应用进程的 DbContext 实例

向 ASP.NET Core 应用进程(和其他类型的托管应用进程)提供应用进程的 DbContext 实例的标准方法是通过类的构造函数进行 DI 注入(参见第 5.3.2 节)。对于 ASP.NET Core 应用进程,可以在控制器中添加一个构造函数,该构造函数将应用进程的 DbContext 类作为参数(构造函数的依赖项注入)。

清单 5.4 显示了 ASP.NET Core HomeController 的开始,您可以在其中添加构造函数并将注入的 EfCoreContext 类复制到一个局部字段,该字段可用于创建列出书籍所需的 BookListService 类的实例。此代码使用第 5.3.2 节和图 5.2 中的 DI 方法,但将 Demo 类替换为应用进程的 DbContext 类 EfCoreContext。

清单 5.4 HomeController 中的 Index 操作显示书籍列表

public class HomeController : Controller
{
    private readonly EfCoreContext _context;

    public HomeController(EfCoreContext context)
    {
        _context = context;  // 应用进程的 DbContext 由 ASP.NET Core 通过 DI 提供。
    }

    public IActionResult   // ASP.NET 操作,在用户调用主页时调用
        Index (SortFilterPageOptions options)    // options 参数通过 URL 填充排序、筛选和页面选项。
    {
        var listService = new ListBooksService(_context);  // ListBooksService 是使用私有字段_context中的应用进程的 DbContext 创建的。
        
        var bookList = listService.SortFilterPage(options)  // SortFilterPage 方法使用提供的排序、筛选和页面选项进行调用。
            .ToList();  // ToList() 方法执行 LINQ 命令,使 EF Core 将 LINQ 转换为相应的 SQL 以访问数据库并将结果作为列表返回。

        return View(new BookListCombinedDto (options, bookList));  // 发送选项(用于填充页面顶部的控件)和 BookListDtos 列表以显示为 HTML 表格
    }
}

使用应用进程的 DbContext 的本地副本创建 ListBooksService 后,可以调用其 SortFilterPage 方法。此方法采用从列表页上的各种控件返回的参数,并返回 IQueryable 结果。然后,将 ToList 方法添加到结果的末尾,这会导致 EF Core 对数据库执行该 IQueryable 结果,并返回用户请求的书籍信息列表。此结果将提供给要显示的 ASP.NET Core 视图。

本可以让 SortFilterPage 方法返回 List 结果,但该方法会限制您使用同步数据库访问。正如您将在 async/await 的第 5.10 节中看到的那样,通过返回 IQueryable 结果,您可以选择使用执行查询的最终命令的正常(同步)或异步版本。

5.6.2 使用 DbContext 工厂创建 DbContext 的实例

在某些应用进程(例如 Blazor Server 应用)中(请参阅第 5.3.4 节),应用进程的 DbContext 的正常范围不起作用。在这种情况下,可以使用 DI 注入 EF Core 的 IDbContextFactory。这种分离对于 Blazor 应用进程很有用,其中 EF Core 建议使用 IDbContextFactory,并且在其他方案中可能很有用。

下面是 EF Core 团队提供的 BlazorServerEFCoreSample 中的示例。在此示例中,将 DbContext 工厂注入到 Blazor Razor 页面中,如以下清单所示。只有使用 DbContext 工厂和创建 DbContext 才有注释。

清单 5.5 将 DbContext 工厂注入 Razor 页面的示例

@page "/add"

// DbContext Factory 被注入到 Razor 页面。
@inject IDbContextFactory<ContactContext> DbFactory 
@inject NavigationManager Nav
@inject IPageHelper PageHelper
 
@if (Contact != null)
{
    <ContactForm Busy="@Busy" 
        Contact="@Contact" 
        IsAdd="true" 
        CancelRequest="Cancel" 
        ValidationResult="@(async (success) => await ValidationResultAsync(success))" />
}
@if (Success)
{
    <br />
    <div class="alert alert-success">The contact was successfully added.</div>
}
@if (Error)
{
    <br />
    <div class="alert alert-danger">Failed to update the contact (@ErrorMessage).</div>
}

@code {
    //… various fields left out
    private async Task ValidationResultAsync(bool success)
    {
        if (Busy)  return;  // 处理 Blazor Server 应用的另一种技术。在第一个请求完成之前,它不会处理额外的请求。
        if (!success)
        {
            Success = false; 
            Error = false; 
            return;
        }
        Busy = true;
        using var context = DbFactory.CreateDbContext();  // 创建应用进程的 DbContext 的新实例。请注意使用 var 进行处置。
        context.Contacts.Add(Contact);  // 新的联系信息被添加到 DbContext 中。

        try
        {
            await context.SaveChangesAsync();   // 将联系人保存到数据库
            Success = true;
            Error = false;
            // ready for the next 
            Contact = new Contact(); 
            Busy = false;
        }
        catch (Exception ex)
        {
            Success = false;
            Error = true;
            ErrorMessage = ex.Message;
            Busy = false;
        }
    }

    private void Cancel()
    {
        Nav.NavigateTo($"/{PageHelper.Page}");
    }
}

请注意,创建 DbContext 工厂的 DbContext 实例不受应用进程的服务提供商管理,因此必须由应用进程释放。在清单 5.5 所示的 Blazor Razor 页面中,using var context = ... 将在退出本地上下文变量的作用域时释放 DbContext 实例。

注意:您可以在 http://mng.bz/aorz 处找到清单 5.5 中所示的 Razor 页面

5.7 将数据库方法作为 DI 服务实现

尽管您在上一节中使用的构造函数注入方法有效,但还有另一种使用 DI 的方法可以更好地隔离数据库访问代码:参数注入。在 ASP.NET Core 中,可以通过标有属性 [FromServices] 的参数安排将服务注入到操作方法中。您可以提供控制器中每个操作方法所需的特定服务;这种方法在单元测试中既高效又简单。要了解其工作原理,您将使用服务图层中名为 ChangePubDateService 的类来更新图书的出版日期。此类允许管理员用户更改书籍的出版日期,如图 5.6 所示。

图 5.6 更改图书出版日期的两个阶段。GET 阶段调用 GetOriginal 方法向用户显示图书及其当前出版日期。然后,POST 阶段使用用户设置的日期调用 UpdateBook 方法。

您可以看到该过程有两个阶段:

  • 您向管理员用户显示当前发布日期并允许他们更改它。
  • 更新将应用于数据库,并且您告诉用户更新已成功。

若要使用 ChangePubDateService 类的参数注入,需要执行以下两项操作:

  • 向 DI 注册您的类 ChangePubDateService,以便它成为可以使用 DI 注入的服务。
  • 使用参数注入将类实例 ChangePubDate 注入到需要它的两个 ASP.NET 操作方法(GET 和 POST)中。

这种方法非常适合构建 ASP.NET Core 应用进程,多年来,我一直在所有 ASP.NET MVC 项目中使用它。除了提供良好的隔离和简化测试外,这种方法还使 ASP.NET Core 控制器操作方法更易于编写。您将在第 5.7.2 节中看到 ChangePubDate 操作方法中的代码简单而简短。

5.7.1 将您的类注册为 DI 服务

您可以通过多种方式在 ASP.NET 中向 DI 注册类。标准方法是将 IChangePubDateService 接口添加到类中。从技术上讲,您不需要接口,但使用接口是一种很好的做法,并且有助于单元测试。您还可以使用第 5.7.3 节中的接口来简化类的注册。

下面的列表显示了 IChangePubDateService 接口。不要忘记,ASP.NET Core 控制器将处理 IChangePubDateService 类型的内容,因此需要确保所有公共方法和属性在接口中都可用。

清单 5.6 在 DI 中注册类所需的 IChangePubDateService 接口

public interface IChangePubDateService
{
    ChangePubDateDto GetOriginal(int id); 
    Book UpdateBook(ChangePubDateDto dto);
}

然后,向 DI 服务注册此接口/类。在 ASP.NET Core 中执行此操作的默认方法是向 Startup 类中的 ConfigureServices 方法添加一行。此列表显示了更新后的方法,新代码以粗体显示。将 ChangePubDateService 添加为暂时性版本,因为每次请求时都希望创建一个新版本。

清单 5.7 Startup 类中 ASP.NET Core ConfigureService 方法

public void ConfigureServices (IServiceCollection services)
{
    // Add framework services. 
    services.AddControllersWithViews(); 
    var connection = Configuration.GetConnectionString("DefaultConnection"); 
    services.AddDbContext<EfCoreContext>(options => options.UseSqlServer(connection));
    
    // 将 ChangePubDateService 类注册为服务,并使用 IChangePubDateService 接口作为访问它的方式
    services.AddTransient<IChangePubDateService, ChangePubDateService>();
}

5.7.2 将 ChangePubDateService 注入 ASP.NET 操作方法

将 ChangePubDateService 类设置为可通过 DI 注入的服务后,现在您需要在 ASP.NET Core AdminController 中创建一个实例。两个 ASP.NET Core 操作方法都称为 ChangePubDate;一个是用于填写编辑页面的 GET,一个是用于进行更新的 POST。

图 5.7 显示了 DI 如何创建 ChangePubDateService 服务,该服务通过其构造函数注入了 EfCoreDbContext 的实例。然后,通过参数注入将 ChangePubDateService 注入到 AdminController 的 GET 操作中。如

图 5.7 使用 DI 提供服务通常需要 DI 提供进程先创建其他类。在这个相当简单的情况下,DI至少有四个级别。AdminController 的 ChangePubDate 称为 (底部矩形);然后,方法参数之一的 [FromServices] 属性指示 DI 提供进程创建 ChangePubDateService 类的实例。ChangePubDateService(顶部矩形)类需要 EfCoreDbContext 类的实例,因此 DI 提供进程也必须创建该实例,这反过来又需要创建 DbContextOptions,以便可以创建 EfCoreDbContext 类。

您将看到,DI 提供进程被多次调用以创建处理 HTTP 请求所需的所有类。

可以通过构造函数注入提供 ChangePubDateService 类的实例,就像使用应用进程的 DbContext 一样,但这种方法有一个缺点。AdminController 包含其他几个数据库更新命令,例如向图书添加评论、向图书添加促销等。使用 DI 构造函数注入意味着在调用这些其他命令之一时,您不必要地创建了 ChangePubDateService 类的实例。通过将 DI 参数注入到每个操作中,您只需花费创建所需单个服务的时间和内存成本。下面的列表显示了当有人单击“管理员>更改发布日期”链接想要更改发布日期时调用的 ChangePubDate ASP.NET GET 操作。

清单 5.8 AdminController 中的 ChangePubDate 操作方法

public IActionResult ChangePubDate   // 如果用户单击 Admin > Change Pub Date 链接,则调用该操作
    (int id,  // 接收用户想要更改的图书的主键
    [FromServices]IChangePubDateService  service)  // ASP.NET DI 注入 ChangePubDateService 实例。
{
    var dto = service.GetOriginal(id);  // 使用该服务设置 DTO 以向用户显示
    return View(dto);  // 显示允许用户编辑发布日期的页面
}

此列表中的第 3 行(粗体)是重要的行。您已使用参数注入通过 DI 注入 ChangePubDateService 类的实例。同一行也出现在 ChangePubDate 操作的 POST 版本中。

请注意,ChangePubDateService 类在其构造函数中需要 EfCoreContext 类,该类是应用进程的 DbContext。这很好,因为 DI 是递归的;只要注册了所需的每个类,它就会继续填充参数或其他 DI 注入。

5.7.3 改进将数据库访问类注册为服务

在离开 DI 这个话题之前,我想介绍一种通过 DI 将类注册为服务的更好方法。在前面的示例中,您将 ChangePubDateService 类转换为服务,但需要添加代码以在 ASP.NET Core 的 ConfigureServices 中将该类注册为服务。此过程有效,但非常耗时且容易出错,因为您需要添加一行代码来注册要用作服务的每个类。

在本书的第一版中,我建议使用一个名为 Autofac (https://autofaccn.readthedocs.io/en/latest) 的 DI 库,因为它有一个命令,用于将所有类注册到进程集(也称为项目)中的接口。从那时起,我就

看到 David Fowler 的一条推文,该推文链接到一组依赖注入容器基准;请参见 http://mng.bz/go2l。从该页面,我发现 ASP.NET Core DI 容器比 AutoFac 快得多!此时,我生成了一个名为 NetCore.AutoRegisterDi 的库(参见 http://mng.bz/5jDz),该库只有一个工作:使用 .NET Core DI 提供进程将所有类注册到进程集中的接口。

注意:在我创建 NetCore.AutoRegisterDi 库后,Andrew Lock 向我指出了一个名为 Scrutor 的现有库;请参阅他在 http://mng.bz/6gly 上的文章。Scrutor 具有比我的 NetCore.AutoRegisterDi 更多的选择要注册的类的功能,所以请查看 Scrutor。

如何组织向 NET CORE DI 容器注册服务

NetCore.AutoRegisterDi 库很简单:它扫描一个或多个进程集;查找具有公共接口的标准公共非泛型类;并将它们注册到 NET Core 的 DI 提供进程。它有一些简单的过滤和一些生命周期设置功能,但仅此而已(只有 ~80 行代码)。但是,与手动向 DI 提供进程注册类/接口相比,这一段简单的代码为您提供了两个好处:

  • 它节省了您的时间,因为您不必手动注册每个接口/类。
  • 更重要的是,它会自动注册您的接口/类,这样您就不会忘记。

第二个原因是我发现这个库如此有用:我不能忘记注册服务。下面的列表显示了对 NetCore.AutoRegisterDi 库的典型调用。

清单 5.9 使用 NetCore.AutoRegisterDi 将类注册为 DI 服务

//可以通过提供进程集中的类来获取对该进程集的引用。
var assembly1ToScan = Assembly.GetAssembly(typeof(ass1Class)); 
var assembly2ToScan = Assembly.GetAssembly(typeof(ass2Class));
 
service.RegisterAssemblyPublicNonGenericClasses( assembly1ToScan, assembly2ToScan)  //此方法需要零到多个进程集进行扫描。如果未提供进程集,它将扫描调用进程集。
    .Where(c => c.Name.EndsWith("Service"))  //此可选的筛选系统允许您筛选要注册的类。
    .AsPublicImplementedInterfaces();  //注册所有具有公共接口的类。默认情况下,服务注册为暂时性服务,但您可以通过添加 ServiceLifetime 参数或属性来更改该注册。

我可以在 ASP.NET Core 的 Startup 类的 Configure 方法中放置一个类似于清单 5.9 中所示的调用,该调用注册了所有进程集,但我没有这样做。我更喜欢在每个项目中添加一个扩展方法,这些项目具有需要注册为 DI 服务的类。这样,我已将每个项目的设置隔离到每个需要它的项目中的一个类中。

每个扩展方法都使用 NetCore.AutoRegisterDi 库在项目中注册标准类/服务。扩展方法还具有用于其他代码的空间,例如无法自动注册的类/服务的手动编码注册,例如泛型类/服务。

以下清单显示了服务层中扩展方法的示例。此代码需要将 NetCore.AutoRegisterDi NuGet 包添加到该项目。

清单 5.10 ServiceLayer 中处理所有 DI 服务注册的扩展方法

public static class NetCoreDiSetupExtensions  //创建一个静态类来保存我的扩展
{
    public static void RegisterServiceLayerDi   //此类位于 ServiceLayer 中,因此我为该方法指定了一个包含该进程集名称的名称。
        (this IServiceCollection services)  //NetCore.AutoRegisterDi 库了解 NET Core DI,因此可以访问 IServiceCollection 接口。
    {
        services.RegisterAssemblyPublicNonGenericClasses()  //调用不带参数的 RegisterAssemblyPublicNonGenericClasses 方法意味着它将扫描调用进程集。
            .AsPublicImplementedInterfaces();  //此方法将注册所有公共类,这些类具有具有 Transient 生存期的接口。
    }	//对于 NetCore.AutoRegisterDi 无法执行的手动编码注册,例如泛型类
}	

本书第 1 部分中的“书籍应用”包含需要在 ServiceLayer、BizDbAccess 和 BizLogic 项目中注册的类/服务。为此,将清单 5.10 中的代码复制到其他项目中,并更改方法的名称,以便可以识别每个项目。对每个方法的调用会自动注册标准服务,因为默认情况下,RegisterAssemblyPublicNonGenericClasses 会扫描从中调用它的进程集。

现在,在需要清单 5.8 的三个项目中,每个项目都有单独的版本,您需要调用每个版本来设置每个项目。为此,可以将以下代码添加到 ASP.NET Core 的 Startup 类中的 Configure 方法。

清单 5.11 在需要它们的项目中调用所有注册方法

public void ConfigureServices(IServiceCollection services)  //Startup 类中的此方法为 ASP.NET Core 设置服务。
{
    //… other registrations left out

    //您可以在此处添加注册扩展方法。 
    services.RegisterBizDbAccessDi();
    services.RegisterBizLogicDi(); 
    services.RegisterServiceLayerDi();
}

5.8 使用数据库部署 ASP.NET Core 应用进程

使用数据库开发 ASP.NET Core 应用进程后,在某些时候,您需要将其复制到 Web 服务器,以便其他人可以使用它。此过程称为将应用进程部署到主机。本部分介绍如何操作。

注意:有关 ASP.NET Core 部署的更多信息,Andrew Lock 的《ASP.NET Core in Action》 一书,第 2 版(Manning,2020 年;见 https://www.manning.com/books/asp-net-core-in-action-second-edition)有一章是关于部署的;或在 http://mng.bz/op7M 上查看 Microsoft 的在线文档。

5.8.1 了解数据库在 Web 服务器上的位置

在开发过程中在本地运行 ASP.NET Core 应用进程时,它会访问开发计算机上的数据库服务器。此示例使用 Visual Studio,它附带一个用于开发的本地 SQL Server,可通过引用 (localdb)\mssqllocaldb 获得。如第 5.4.1 节所述,该数据库的连接字符串保存在 appsettings 中。Development.json 文档。

将应用进程部署到 Web 服务器时,Visual Studio 默认情况下会重建应用进程,并将 ASPNETCORE_ENVIRONMENT 变量设置为 Production。此设置会导致您的应用进程尝试加载 appsetting.json 文档,然后加载 appsettings.Production.json 文档。 appsettings.Production.json 文档是您(或发布系统)放置主机数据库连接字符串的位置。

提示:启动时,最后读取 appsettings.Production.json 并覆盖 appsetting.json 文档中具有相同名称的任何设置。因此,如果需要,您可以将开发连接字符串设置放入 appsetting.json 文档中,但最佳实践是将其放入 appsettings.Development.json 文档中。

您可以使用 Visual Studio 的发布功能手动设置托管数据库的连接字符串;在“解决方案资源管理器”视图中右键单击 ASP.NET Core 项目,然后选择“发布”。发布应用进程时,Visual Studio 会使用您提供的连接字符串创建/更新 appsettings.Production.json 文档,并使用应用进程部署该文档。启动时,ASP.NET Core 的 Startup 类的构造函数会读取这两个文档,并使用 appsettings.Production.json 连接字符串。

大多数 Windows 托管系统都提供 Visual Studio 发布配置文档,您可以将其导入到发布功能。该配置文档使设置部署变得更加容易,因为它不仅详细说明了 ASP.NET Core 应用进程应写入的位置,而且还提供了托管数据库的连接字符串。

Azure Web App 服务等云系统具有可以在部署时覆盖 appsettings.json 文档中的属性的功能。这意味着您可以在 Azure 中设置数据库连接,其中包含数据库用户名和密码;您的用户名和密码永远不会存在于您的开发系统中,因此更加安全。

5.8.2 创建和迁移数据库

当您的应用进程及其数据库在 Web 服务器上运行时,对数据库的控制会发生变化。在开发计算机上,您几乎可以对数据库执行任何操作,但在部署到 Web 服务器后,规则可能会发生变化。根据主机或公司的业务规则,您可以对数据库执行的操作会有所不同。

例如,本书第一版中的图书应用进程版本托管在具有成本效益(便宜!)的共享托管平台(英国的 WebWiz)上,该平台不允许您的应用进程创建或删除数据库。我也用过微软的Azure云系统,可以删除和创建数据库,但是创建数据库需要很长时间。

最简单的方法适用于我遇到的所有系统,即让托管系统创建一个空数据库,然后应用命令来更改数据库结构。最简单的方法是通过 EF Core 迁移,我将对此进行描述,但还有其他方法。

警告:在开始之前,我需要警告您,更改网站的数据库结构需要谨慎对待,特别是对于需要在数据库更改期间保持工作的 24/7 网站。很多事情都可能出错,其后果可能是数据丢失或网站损坏。

本章介绍 EF Core 迁移,这是一个很好的系统,但也有其局限性。第 9 章介绍了处理数据库迁移的方法,包括更复杂的技术,并讨论了每种方法的优缺点。

5.9 使用EF Core的迁移功能更改数据库结构

本节介绍如何使用 EF Core 的迁移功能来更新数据库。您可以在开发计算机和主机上使用迁移,但如第 5.8.2 节中所述,具有挑战性的是 Web 主机上的数据库。本书有一整章(第 9 章)介绍迁移,但这一部分概述了如何在 ASP.NET Core 应用进程中使用迁移。

5.9.1 更新生产数据库

您可能还记得第 2 章,其中简要介绍了 EF Core 迁移,您可以在 Visual Studio 的包管理器控制台 (PMC) 中键入两个命令:

  • Add-Migration——在您的应用进程中创建迁移代码来创建/更新您的数据库结构
  • Update-Database——将迁移代码应用到应用进程的DbContext引用的数据库

第一个命令很好,但第二个命令将仅更新默认数据库,该数据库可能位于您的开发计算机上,而不是您的生产数据库上。当您想要将 Web 应用进程部署到某种 Web 主机,并且数据库未处于与代码匹配的正确级别时,会发生什幺情况?如果您使用 EF Core 的迁移功能,则可以通过四种方法来更新生产数据库:

  • 您可以让应用进程在启动期间检查并迁移数据库。
  • 您可以在持续集成(CI)和持续交付(CD)管道中迁移数据库。
  • 您可以使用独立的应用进程来迁移数据库。
  • 您可以提取更新数据库所需的 SQL 命令,然后使用工具将这些 SQL 命令应用到生产数据库。

最简单的选项是第一个,我将在这里进行描述。它确实有局限性,例如不能在多实例 Web 托管中工作(在 Azure 中称为横向扩展)。但让应用进程执行迁移很简单,并且是在 ASP.NET Core 应用进程中使用 EF Core 迁移的良好开端。

警告:Microsoft 建议您使用 SQL 命令更新生产数据库,这是最可靠的方法。但它需要相当多的步骤和工具,而您手头可能没有,因此我介绍了更简单的 Database.Migrate 方法。第 9 章涵盖了数据库迁移的各个方面,包括每种方法的优点和局限性。

5.9.2 让应用进程在启动时迁移数据库

让应用进程在启动时应用任何未完成的数据库迁移的优点是,您不能忘记这样做:部署新应用进程将停止旧应用进程,然后启动新应用进程。通过添加在应用进程启动时运行的代码,可以调用上下文。Database.Migrate 方法,在主应用进程启动之前将任何缺失的迁移应用到数据库——很简单,直到出错,这就是为什幺专门讨论数据库迁移的第 9 章讨论所有这些问题的原因。但现在,让我们继续采用简单的方法。

决定在启动时应用迁移后,需要决定在何处调用迁移代码。将任何启动代码添加到 ASP.NET Core 应用进程的建议方法是将代码追加到 ASP.NET Core 的 Program 类中 Main 方法的末尾。Main 方法中的正常代码如以下代码片段所示:

public static void Main(string[] args)
{
    CreateHostBuilder(args).Build().Run();
}

添加迁移代码的最佳方法是生成一个扩展方法,其中包含要运行的 EF Core 代码,并将其追加到 CreateHostBuilder(args) 之后。Build() 调用。下面的列表显示了 ASP.NET Core 的 Program 类,其中添加了一个新行(粗体)来调用扩展方法,称为 MigrateDatabaseAsync。

注意:在本节中,我将使用 async/await 命令。我在 5.10 节中介绍了 async/await。

清单 5.12 ASP.NET Core Program 类,包括迁移数据库的方法

public class Program
{
    public static async Task Main(string[] args)  //将 Main 方法更改为异步,以便可以在 SetupDatabaseAsync 方法中使用 async/await 命令。
    {
        var host = CreateHostBuilder(args).Build();   //此调用运行 Startup.Configure 方法,该方法设置设置/迁移数据库所需的 DI 服务。
        await host.MigrateDatabaseAsync();  //调用您的扩展方法迁移您的数据库
        await host.RunAsync();  //最后,你启动 ASP.NET Core 应用进程。
    }
    //… other code not shown
}

MigrateDatabaseAsync 方法应包含要在启动时运行的所有代码,以便迁移数据库并可能设定数据库的种子。下面的清单显示了如何使用此方法迁移数据库的一个示例。

清单 5.13 用于迁移数据库的 MigrateDatabaseAsync 扩展方法

public static async Task MigrateDatabaseAsync (this IHost webHost)  //创建一个包含 IHost 的扩展方法
{  //创建作用域内服务提供进程。留下 using 块后,所有服务将不可用。此方法是在 HTTP 请求之外获取服务的推荐方法。
    using (var scope = webHost.Services.CreateScope())
    {
        //创建应用进程的 DbContext 的实例,该实例的生存期仅为外部 using 语句 
        var services = scope.ServiceProvider; 
        using (var context = services.GetRequiredService<EfCoreContext>())
        {
            try
            {
                await context.Database.MigrateAsync();  //调用 EF Core 的 MigrateAsync 命令以在启动时应用任何未完成的迁移
                //Put any complex database seeding here  //如果需要,可以在此处添加方法来处理数据库的复子设定。
            }
            catch (Exception ex)  //如果发生异常,请记录信息,以便对其进行诊断。
            {
                var logger = services
                    .GetRequiredService<ILogger<Program>>();
                logger.LogError(ex, "An error occurred while migrating the database.");
  
                throw;  //重新引发异常,因为如果发生迁移数据库问题,您不希望应用进程继续运行
            }
        }
    }
}

清单开头的一系列调用是在 ASP.NET Core Startup 类的 Configure 方法内获取应用进程 DbContext 副本的推荐方法。此代码创建应用进程 DbContext 的作用域生命周期实例(请参阅第 5.3.3 节),可以安全地使用它来访问数据库。

清单 5.13 中 try 块内的关键命令(粗体)调用 EF Core 的 MigrateAsync 命令。此命令应用任何存在但尚未应用于数据库的数据库迁移。

EF6:EF Core 的数据库设置方法与 EF6.x 不同。首次使用 DbContext 时,EF6.x 使用数据库初始化进程运行各种检查,而 EF Core 在初始化时对数据库不执行任何操作。因此,您需要添加自己的代码来处理迁移。缺点是您需要编写一些代码,但优点是您可以完全控制发生的事情。

在启动期间设置初始数据库内容

除了迁移数据库之外,您可能还想同时向数据库添加默认数据,尤其是在数据库为空的情况下。此过程称为数据库播种,包括将初始数据添加到数据库或更新现有数据库中的数据。使用静态数据为数据库播种的主要方法是通过迁移,我将在第 9 章中介绍这一点。另一个选项是在迁移完成后运行一些代码。如果您有迁移播种无法处理的动态数据或复杂更新,则此选项非常有用。

迁移后运行代码的一个示例是,如果尚不存在书籍,则将示例书籍(包含作者、评论等)添加到书籍应用进程中。为此,您需要创建一个扩展方法 SeedDatabaseAsync,如以下清单所示。该代码是在调用清单 5.13 中的 Database.MigrateAsync 方法之后添加的。

清单 5.14 我们的示例 MigrateAndSeed 扩展方法

public static async Task SeedDatabaseAsync (this EfCoreContext context)  //采用应用进程的 DbContext 的扩展方法
{
    if (context.Books.Any()) return;  //如果存在现有书籍,则返回,因为您不需要添加任何书籍。

    context.Books.AddRange( EfTestData.CreateFourBooks());  //数据库没有书,所以你播种它;在这种情况下,您可以添加默认书籍。
    await context.SaveChangesAsync();  //调用 SaveChangesAsync 来更新数据库
}

在此示例 SeedDatabaseAsync 方法中,检查数据库中是否有任何书籍,然后仅在数据库为空(例如,刚刚创建)时添加它们。这个例子很简单,下面是其他的例子:

  • 启动时从文档加载数据(请参阅关联的 GitHub 存储库中 ServiceLayer 中的 SetupHelpers 类)
  • 在特定迁移后填写额外数据 - 例如,如果您添加了 FullName 属性/列,并希望从 FirstName 和 LastName 列中填充它

警告:我尝试在要更新数万行的大型数据库上执行数据库更新,就像前面的 FullName 示例一样,但失败了。之所以失败,是因为更新是在启动时通过 EF Core 完成的,并且 ASP.NET Core 应用进程启动时间太长,以至于 Azure 使 Web 应用进程超时。我现在知道我应该在迁移中使用 SQL 完成更新(请参阅第 9.5.2 节中的示例),这会快得多。

如果只想在应用新迁移时运行种子数据库方法,则可以使用 DbContext 方法 Database.GetPendingMigrations 获取将要应用的迁移列表。如果此方法返回空集合,则当前数据库中没有挂起的迁移。在执行 Database.Migrate 方法之前,必须调用 GetPendingMigrations,因为当 Migrate 方法完成时,挂起的迁移集合为空。

EF6:在 EF6.x 中,Add-Migration 命令添加了一个名为 Configuration 的类,该类包含一个名为 Seed 的方法,该方法在每次应用进程启动时运行。EF Core 使用 HasData 配置方法,该方法允许您定义要在迁移期间添加的数据(第 9 章)。

5.10 使用 async/await 获得更好的可伸缩性

Async/await 是一项功能,允许开发人员轻松使用异步编程,并行运行任务。到目前为止,在本书中,我还没有使用过 async/await,因为我没有解释这个功能。但是您需要知道,在同时发生多个请求的实际应用进程中,例如 ASP.NET Core,大多数数据库命令都将使用 async/await。

Async/await 是一个很大的话题,但在本节中,您将只了解使用 async/await 如何使 ASP.NET Core 的应用进程可扩展性受益。它通过在等待数据库服务器执行 EF Core 要求它执行的命令时释放资源来实现此目的。

注意:如果您想了解有关 async/await 的其他功能(例如并行运行任务)的更多信息,请查看 http://mng.bz/nM7K 上的 Microsoft 文档。

5.10.1 为什幺 async/await 在使用 EF Core 的 Web 应用进程中很有用

当 EF Core 访问数据库时,它需要等待数据库服务器运行命令并返回结果。对于大型数据集和/或复杂查询,此过程可能需要数百毫秒甚至几秒钟。在此期间,Web 应用进程将保留应用进程线程池中的线程。每次访问 Web 应用进程都需要线程池中的一个线程,并且有一个上限。

使用 EF Core 命令的 async/await 版本意味着在数据库访问完成之前将释放用户的当前线程,以便其他人可以使用该线程。图 5.8 显示了两种情况。在案例 A 中,两个用户使用正常的同步访问同时访问网站,并且它们发生冲突,因此线程池中需要两个线程。在案例 B 中,用户 1 的访问是长时间运行的数据库访问,它使用异步命令在等待数据库时释放线程。这允许用户 2 在用户 2 等待数据库时重用 async 命令释放的线程。

图 5.8 数据库访问的差异。在案例 A 的正常同步数据库访问中,需要两个线程来处理两个用户。在案例 B 中,用户 1 的数据库访问是通过异步命令完成的,该命令释放线程 T1,使其可供用户 2 使用。

注意:您可以在 http://mng.bz/vz7M 上阅读有关 async/await 在 ASP.NET Web 应用进程中的作用的更深入说明。

使用 async/await 可以提高您网站的可扩展性:您的 Web 服务器将能够处理更多的并发用户。缺点是 async/await 命令的执行时间稍长,因为它们运行更多的代码。需要进行一些分析才能在可伸缩性和性能之间取得适当的平衡。

5.10.2 应该在哪里使用 async/await 进行数据库访问?

Microsoft 的一般建议是在 Web 应用进程中尽可能使用异步方法,因为它们为您提供了更好的可伸缩性。在实际应用中,我就是这样做的。我没有在第 1 部分(和第 2 部分)Book App 中这样做,因为如果没有 await 语句,理解代码会更容易一些,但第 3 部分 Book App 得到了显着增强,自始至终都使用异步。

同步命令比等效的异步命令略快(实际差异见表 14.5),但时间差非常小,因此坚持Microsoft的准则“始终在 ASP.NET 应用进程中使用异步命令”是正确的选择。

5.10.3 切换到 EF Core 命令的异步/await 版本

首先,我向您展示一个调用 EF Core 命令的异步版本的方法;那我就解释一下。图 5.9 显示了一个异步方法,该方法返回数据库中的书籍总数。

图 5.9 异步方法的剖析,突出显示了代码中与普通同步方法不同的部分

EF Core 包含所有适用命令的异步版本,所有这些命令的方法名称都以 Async 结尾。如前面的异步方法示例所示,需要将“async-ness”带到调用异步 EF Core 命令的方法。

规则是,在使用异步命令后,每个调用方都必须是异步方法,或者应直接传递任务,直到它到达顶级调用方,后者必须异步处理它。ASP.NET Core 支持所有主要命令(例如控制器操作)的异步,因此这种情况在此类应用进程中不是问题。

下一个列表显示了 HomeController 中 Index 操作方法的异步版本,其中必须更改的部分才能使此命令使用异步数据库访问,异步部分以粗体显示。

清单 5.15 HomeController 中的异步索引操作方法

public async Task<IActionResult> Index   //使用 async 关键字使 Index 操作方法异步,并且返回的类型必须包装在泛型任务中。
    (SortFilterPageOptions options)
{
    var listService = new ListBooksService(_context);
 
    var bookList = await listService  //必须等待 ToListAsync 方法的结果,该方法是一个异步命令。
        .SortFilterPage(options)
        .ToListAsync();  //您可以通过替换 将 SortFilterPage 更改为异步。ToList() 和 .ToListAsync() 中。
    return View(new BookListCombinedDto (options, bookList));
}

由于将 SortFilterPage 方法设计为返回 IQueryable<T>,因此通过将 ToList 方法替换为 ToListAsync 方法,可以很简单地将数据库访问更改为异步。

提示:业务逻辑代码通常非常适合使用异步数据库的访问方法,因为它们的数据库访问通常包含复杂的读/写命令。我已经创建了 BizRunners 的异步版本,以备不时之需。您可以在 BizRunners 目录的服务层中找到它们(参见 http://mng.bz/PPlw)。

async 的另一部分是 CancellationToken,这是一种允许您手动或在超时时停止异步方法的机制。所有异步 LINQ 和 EF Core 命令(如 SavChangesAsync)都采用可选的 CancellationToken。第 5.11 节演示了在 Core 停止时如何使用 CancellationToken 停止任何重复 ASP.NET 后台任务。

5.11 运行并行任务:如何提供 DbContext

在某些情况下,运行多个代码线程很有用。我的意思是运行一个单独的任务——一组与主应用进程“同时”运行的并行代码。我把“同时”放在引号里,因为如果只有一个 CPU,两个任务需要共享它。

并行任务在各种方案中都很有用。假设您正在访问多个外部源,您需要等待这些源才能返回结果。通过使用并行运行的多个任务,您可以获得性能改进。在另一种情况下,您可能有一个长时间运行的任务,例如在后台处理订单履行。您可以使用并行任务来避免阻塞正常流程并使您的网站看起来缓慢且无响应。图 5.10 显示了一个示例后台任务,在该任务中,长时间运行的进程在另一个线程上运行,以便用户不会被阻塞。

运行并行任务并非特定于 ASP.NET Core;它可以发生在任何应用进程中。但是较大的 Web 应用进程经常使用此功能,因此我在本章中对此进行了解释。您将构建的解决方案是一个后台服务,每小时运行一次,并记录数据库中有多少条评论。这个简单的示例将向您展示如何做两件事:

  • 获取应用进程的 DbContext 实例以并行运行
  • 使用 ASP.NET Core 的 IHostedService 功能运行后台任务

图 5.10 将长时间运行的进程移动到与主网站并行运行的后台任务,使网站感觉响应速度更快。在此示例中,我使用 ASP.NET Core backgroundService 来运行长时间运行的任务。任务完成后,它使用 SignalR 更新用户的屏幕,并显示一条消息,指出长时间运行的任务已成功完成。(SignalR 是一个库,允许 ASP.NET Core 应用将消息发送到用户的屏幕。

5.11.1 获取应用进程的 DbContext 实例以并行运行

如果要并行运行使用 EF Core 的任何代码,则不能使用获取应用进程的 DbContext 的常规方法,因为 EF Core 的 DbContext 不是线程安全的;不能在多个任务中使用同一个实例。如果 EF Core 发现在两个任务中使用了相同的 DbContext 实例,它将引发异常。

在 ASP.NET Core 中,让 DbContext 在后台运行的正确方法是使用 DI 范围的服务。此作用域内服务允许你通过 DI 创建对正在运行的任务唯一的 DbContext。为此,您需要做三件事:

  • 通过构造函数注入获取 IServiceScopeFactory 的实例。
  • 将 IServiceScopeFactory 用于作用域内的 DI 服务。
  • 使用作用域内的 DI 服务获取此作用域唯一的应用进程 DbContext 实例。

下面的列表显示了后台任务中使用 IServiceScopeFactory 获取应用进程 DbContext 的唯一实例的方法。此方法计算数据库中的评论数并记录该数。

清单 5.16 后台服务中访问数据库的方法

private async Task DoWorkAsync(CancellationToken stoppingToken)  //IHostedService 将在设置的时间段过后调用此方法。
{
    using (var scope = _scopeFactory.CreateScope())  //使用 ScopeProviderFactory 创建新的 DI 范围提供者
    {	
        //由于作用域内的 DI 提供进程,创建的 DbContext 实例将不同于 DbContext 的所有其他实例。
        var context = scope.ServiceProvider
            .GetRequiredService<EfCoreContext>(); 
        var numReviews = await context.Set<Review>()
            .CountAsync(stoppingToken);  //使用异步方法对评论进行计数。将 stoppingToken 传递给异步方法,因为这样做是一种很好的做法。
        _logger.LogInformation("Number of reviews: {numReviews}", numReviews);
}

该代码的重要一点是,将 ServiceScopeFactory 提供给每个任务,以便它可以使用 DI 获取 DbContext(以及任何其他作用域服务)的唯一实例。除了解决 DbContext 线程安全问题外,如果重复运行该方法,最好具有应用进程的 DbContext 的新实例,以便上次运行的数据不会影响下一次运行。

5.11.2 在 ASP.NET Core 中运行后台服务

前面,我描述了如何获取应用进程 DbContext 的线程安全版本;现在,您将在后台服务中使用它。下面的后台示例并不像图 5.10 中所示的那幺复杂,但它涵盖了如何编写和运行后台服务。

ASP.NET Core 具有允许您在后台运行任务的功能。这种情况并不是真正的数据库问题,但我向您展示了完整性代码。(我建议你查看 Microsoft 的 ASP.NET Core 文档,了解 http://mng.bz/QmOj 中的后台任务。此列表显示了在另一个线程中运行的代码,该代码每小时调用清单 5.16 中所示的 DoWorkAsync 方法。

清单 5.17 每小时调用 DoWorkAsync 的 ASP.NET Core 后台服务

public class BackgroundServiceCountReviews : BackgroundService  //继承 BackgroundService 类意味着此类可以在后台连续运行。
{
    private static TimeSpan _period = new TimeSpan(0,1,0,0);  //保留每次调用代码之间的延迟,以记录评论数
    private readonly IServiceScopeFactory _scopeFactory;  //IServiceScopeFactory 注入用于创建新 DI 作用域的 DI 服务。
    private readonly ILogger<BackgroundServiceCountReviews> _logger;

    public BackgroundServiceCountReviews( IServiceScopeFactory scopeFactory, ILogger<BackgroundServiceCountReviews> logger)
    {
        _scopeFactory = scopeFactory;
        _logger = logger;
    }
    
    //BackgroundService 类具有一个 ExecuteAsync 方法,您可以重写该方法以添加自己的代码。
    protected override async Task ExecuteAsync (CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)  //此循环可重复调用 DoWorkAsync 方法,并延迟到进行下一次调用。
        {
            await DoWorkAsync(stoppingToken);
            await Task.Delay(_period, stoppingToken);
        }
    }

    private async Task DoWorkAsync…
    //see listing 5.16
}

需要使用 AddHostedService 方法向 NET DI 提供进程注册后台类。当图书应用进程启动时,您的后台任务将首先运行,但当您的后台任务到达调用异步方法并使用 await 语句的位置时,控制权将返回到 ASP.NET Core 代码,该代码将启动 Web 应用进程。

5.11.3 获取应用进程 DbContext 新实例的其他方法

尽管 DI 是获取应用进程的 DbContext 的推荐方法,但在某些情况下(如控制台应用进程),DI 可能未配置或不可用。在这些情况下,还有另外两个选项可用于获取应用进程的 DbContext 的实例:

  • 通过重写 DbContext 中的 OnConfiguring 方法并将用于设置 DbContext 的代码放在其中,移动应用进程 DbContext 的配置。
  • 使用与 ASP.NET Core 相同的构造函数,并手动注入数据库选项和连接字符串,就像在单元测试中一样(参见第 17 章)。

第一个选项的缺点是它使用固定的连接字符串,因此它始终访问同一个数据库,如果数据库名称或选项发生更改,这可能会使部署到另一个系统变得困难。第二个选项(手动提供数据库选项)允许从 appsettings.json 或代码中的文档读取连接字符串。

另一个需要注意的问题是,每次调用都会为您提供应用进程 DbContext 的新实例。从第 5.3.3 节中对生存期范围的讨论来看,有时您可能希望具有应用进程的 DbContext 的相同实例,以确保跟踪更改正常工作。可以通过设计应用进程来解决此问题,以便在需要协作处理数据库更新的所有代码之间传递应用进程 DbContext 的一个实例。

总结

  • ASP.NET Core 使用依赖注入 (DI) 来提供应用进程的 DbContext。使用 DI,您可以通过让 DI 根据需要创建类实例来动态链接应用进程的各个部分。
  • ASP.NET Core 的 Startup 类中的 ConfigureServices 方法是使用放置在 ASP.NET Core 应用进程设置文档中的连接字符串来配置和注册应用进程的 DbContext 版本的位置。
  • 要通过 DI 获取应用进程的 DbContext 实例以与您的代码一起使用,您可以使用构造函数注入。DI 将查看每个构造函数参数的类型,并尝试查找可以为其提供实例的服务。
  • 您的数据库访问代码可以构建为服务,并在 DI 中注册。然后,可以通过参数注入将服务注入到 ASP.NET Core 操作方法中:DI 将查找一个服务,该服务查找使用属性 [FromServices] 标记的 ASP.NET Core 操作方法的参数类型。
  • 部署使用数据库的 ASP.NET Core 应用进程需要定义一个数据库连接字符串,该字符串具有主机上数据库的位置和名称。
  • EF Core 的迁移功能提供了一种在实体类和/或 EF Core 配置发生更改时更改数据库的方法。在运行 Web 应用进程的多个实例的云托管站点上使用时,Migrate 方法存在一些限制。
  • 数据库访问代码上的异步/等待任务方法可以使您的网站处理更多的并发用户,但性能可能会受到影响,尤其是在简单的数据库访问中。
  • 如果要使用并行任务,则需要通过创建新的作用域 DI 提供进程来提供应用进程 DbContext 的唯一实例。

对于熟悉 EF6.x 的读者:

  • 在 ASP.NET Core 中获取应用进程 DbContext 实例的方式是通过 DI。
  • 与 EF6.x 相比,EF Core 采用不同的方法来创建 DbContext 的第一个实例。EF6.x 具有数据库初始值设定项,可以运行 Seed 方法。EF Core 没有这些 EF6.x 功能,但允许你编写要在启动时运行的特定代码。
  • 在 EF Core 中设定数据库种子与 EF6.x 的工作方式不同。EF Core 方法将种子设定添加到迁移中,因此仅当迁移应用于数据库时才会运行它们;有关更多信息,请参见第 9 章。