ASP.NET Core Razor Pages in Action 8 处理数据
本章涵盖
• 了解 Entity Framework Core 的价值及其工作原理
• 使用 Entity Framework Core 管理数据库架构
• 使用 Entity Framework Core 查询和管理数据
• 搭建与 Entity Framework Core 配合使用的 Razor Pages 并改进输出的基架
到目前为止,我们已集中精力探索 Razor Pages 框架的功能以及它们如何生成 HTML。本章与此略有不同,而是重点介绍称为 Entity Framework Core (EF Core) 的不同框架。
除了最简单的交互式 Web 应用程序之外,所有应用程序都依赖于数据的持久性和检索来支持其动态内容。该数据通常存储在某种关系数据库中并从中检索。在过去,管理这些数据访问任务所需的代码非常重复。每次要与数据库通信时,您都需要在代码中建立与数据库的连接,定义要执行的 SQL 查询,执行该查询,并在低级容器(如 Recordset、DataTable 或 DataReader)中捕获返回的数据,然后将数据处理为应用程序可以使用的某种形式。EF Core 的主要作用是将其抽象出来,因此您可以专注于编写代码来处理数据,而不是编写代码从数据库中检索数据。本章通过其上下文(派生自 DbContext 的对象)使用 EF Core 执行基本的 CRUD作,该上下文是使用 EF Core 的核心。
EF Core 的功能比仅仅替换对数据库执行命令所需的样板代码要强大得多。我们将探讨如何使用它从应用程序模型生成数据库,然后通过称为 migrations 的功能使数据库架构与模型保持同步。我们还将了解 EF Core 用于将模型映射到数据库的约定,以及如何根据需要使用配置来自定义这些映射。
本章还将介绍一个称为基架的功能。此功能结合了您的应用程序模型和数据库知识,可为模型中的特定对象快速生成工作 CRUD 页面。您将了解基架工具生成的代码的局限性,并了解如何改进它们以符合我们在上一章中介绍的一些软件工程原则。
在本章结束时,您将了解 EF Core 的角色,以及如何使用它对关系数据库执行命令、管理该数据库的架构以及生成 CRUD 页面。然而,EF Core 是一个很大的话题;在本书中,我们只触及了它的功能和使用的皮毛。为了充分利用这个出色的工具,您应该获取 Jon P. Smith (http://mng.bz/vXr4) 编写的优秀 Entity Framework Core in Action (2nd ed.) 的副本,并参阅官方文档 (https://docs.microsoft.com/en-us/ef/core/)。
8.1 什么是 Entity Framework Core?
EF Core 是一种对象关系映射 (ORM) 工具。它的作用是在对象 (应用程序模型) 和关系世界 (数据库) 之间进行映射。EF Core 适用于许多数据库,包括流行的 Microsoft SQL Server 和 MySQL 数据库系统。本书将 EF Core 与 SQLite 结合使用,SQLite 是一个开源、跨平台、基于文件的数据库。虽然缺少许多功能,但在更强大的基于服务器的系统中找到,SQLite 易于使用,无需安装或配置,并且足以满足我们将在本章中探讨的 EF Core 功能。
8.1.1 为什么选择 EF Core?
您可以使用低级、老式的 ADO.NET API 来管理与数据库的通信,但所需的代码是重复的(我已经说过了吗?)并且编写起来很无聊。一种解决方案是编写自己的 helper 库以减少重复。但是,您必须自己维护该代码。数据访问库非常丰富,除非您能找到改进现有产品的方法,否则如果您只需要继续制作 Web 应用程序,那么编写自己的库可能会浪费时间。
你可以自由使用任何你喜欢的库来管理 Razor Pages 应用程序中的数据访问,那么在所有丰富的现有库中,为什么选择 EF Core?作为 .NET 的一部分,它得到了很好的支持和测试,并享有大量的官方文档。除了官方文档之外,还有大量的社区贡献,例如书籍、博客文章和教程网站,它们探索了 EF Core 更深奥的功能及其最常见的工作流程。如果所有其他方法都失败了,并且您难以使某些内容正常工作,则可以将问题发布到 EF Core GitHub 存储库 (https://github.com/dotnet/efcore),您甚至可以从EF Core团队的一位开发人员那里得到回复。
EF Core 在 Visual Studio 中提供工具支持,可帮助你根据应用程序模型快速生成 CRUD 页面。虽然结果并不完美,但它们为开发应用程序中更普通的部分提供了一个重要的开端。基架支持也可供非 Visual Studio 用户从命令行使用。您稍后将看到它的实际效果。
8.1.2 EF Core 的工作原理是什么?
在基本层面上,EF Core 会创建一个概念模型,说明域对象及其属性(应用程序模型)如何映射到数据库中的表和列。它还了解域对象之间的关联,并可以将这些关联映射到数据库关系。它是应用程序的插入式数据层,位于域(图 8.1 的左侧)和数据存储(图 8.1 的右侧)之间。
图 8.1 EF Core 位于左侧的域对象和右侧的数据库之间,将对象及其属性和关联映射到数据库表、列和关系。
EF Core 管理业务对象与数据存储之间的通信。语言集成查询 (LINQ) 将帮助您在应用程序代码中创建查询规范,并将这些规范提供给 EF Core。EF Core 将 LINQ 查询转换为 SQL 命令,EF Core 对数据库执行这些查询,如图 8.2 所示。SQL EF 核心生成的是参数化的,这意味着它受到保护,可以抵御潜在的 SQL 注入攻击 (http://mng.bz/49qj)。您将在第 13 章中更详细地研究 SQL 注入攻击,届时您将专注于保护应用程序免受外部威胁。
图 8.2 EF Core 工作流,获取 LINQ 查询,将其转换为 SQL 以针对数据库执行,并以可在应用程序中使用的形式返回结果
如果查询旨在返回数据,EF Core 会负责将数据从数据库转换为域对象。如果您熟悉软件设计模式,则很可能将其识别为 Repository 模式 (http://mng.bz/QnYv) 的实现。
EF Core 生成的 SQL 取决于你使用的提供程序。每个数据库系统都有自己的提供程序,因此理论上,生成的 SQL 应针对特定数据库进行优化。EF Core 将所有这些隐藏在你的应用程序代码之外,因此如果你在某个时候需要更改提供程序,你的 LINQ 查询将无需修改即可工作。虽然从一个实际数据库系统迁移到另一个实际数据库系统的情况在现实世界中很少见,但如果您想将物理数据库替换为内存中数据库以进行测试,则此功能会更有用。
使用 EF Core 时采用的方法称为代码优先(而不是数据库优先),这意味着你将精力集中在开发应用程序模型上,并允许 EF Core 使用该模型作为维护数据库架构的基础,使用称为迁移的概念。如果数据库不存在,EF Core 还可以创建数据库本身。EF Core 依赖于多个约定将对象及其属性映射到数据库表和列并创建关系。在优化域模型以使用 EF Core 时,你将探索最重要的约定。除了“正常工作”的约定之外,EF Core 还提供了广泛的配置选项,使你能够控制模型映射到数据库中的表和列的方式。
8.1.3 管理关系
关系数据库系统的存在只是为了方便处理彼此相关的数据集。在数据库中,不同实体之间的关系由外键的存在来表示。在图 8.3 中,Country 和 City 通过 City 表上的 CountryId 外键以一对多的关系关联。
图 8.3 国家/地区和城市之间存在一对多关系,一个国家可以有多个城市。
按照惯例,EF Core 模型中的关系由导航属性表示。这些是类中的属性,不能映射到基元或标量值,例如字符串、布尔值、整数或日期时间类型。您现有的 City 类(请参阅下一个清单)已经具有一个符合导航属性描述的 Country 属性。
清单 8.1 具有 Country 导航属性的 City 类
public class City
{
public int Id { get; set; }
public string Name { get; set; }
public Country Country { get; set; } ❶
}
❶ Country 是一个导航属性。
这就是 EF Core 推断 Country 和 City 实体之间的一对多关系所需的全部内容,其中 Country 是关系中的主体,City 是依赖项。在此示例中,Country 属性称为引用导航属性,该属性的重数(关系一端的潜在项数)为 0 或 1。更常见的是,EF Core 关系是完全定义的,具有表示关系每一端的属性和一个表示外键值的属性。在列表 8.2 中,您将一个表示 CountryId 外键的属性添加到 City 类中,并向 Country 类添加一个集合导航属性,该类表示可能属于单个国家/地区的所有城市。作为最佳实践,您应该始终将集合导航属性作为其声明的一部分进行实例化,这样就可以避免在代码中访问它们时必须测试 null。
清单 8.2 Country 和 City 之间完全定义的一对多关系
public class City
{
public int Id { get; set; }
public string Name { get; set; }
public int CountryId { get; set; } ❶
public Country Country { get; set; } ❷
}
public class Country
{
public int Id { get; set; }
public string CountryName { get; set; }
public string CountryCode { get; set; }
public List<City> Cities { get; set; } = new List<City>(); ❸
}
❶ 外键属性
❷ 引用导航属性,表示城市所属的国家/地区
❸ 集合导航属性,表示可以属于一个国家/地区的许多城市,实例化以确保它永远不会为 null
面向对象的纯粹主义者通常不热衷于在领域类中包含外键属性的想法,因为他们认为这是关系数据库世界“渗入”领域的一个例子。如果省略外键属性,EF Core 将创建一个影子外键属性 (http://mng.bz/Xawa) 作为其概念模型的一部分。
按照约定,名为 Id 或
您尚未在模型中创建一个类,该类表示度假者可以租用的房产。因此,使用上面的信息在城市和属性之间创建一对多的关系,下面的清单显示了应该添加到 Models 文件夹的 Property 类。
示例 8.3 Property 类
public class Property
{
public int Id { get; set; } ❶
public string Name { get; set; }
public string Address { get; set; }
public int CityId { get; set; } ❷
public City City { get; set; } ❷
public int MaxNumberOfGuests { get; set; }
public decimal DayRate { get; set; }
public bool SmokingPermitted { get; set; }
public DateTime AvailableFrom { get; set; }
}
❶ 主键属性
❷ 外键和引用导航属性
您还需要修改 City 类,以包含一个集合导航属性,该属性表示属于城市的属性,在下面的清单中以粗体显示。
清单 8.4 更新 City 类以包含 Properties 的集合
public class City
{
public int Id { get; set; }
public string Name { get; set; }
public int CountryId { get; set; }
public Country Country { get; set; }
public List<Property> Properties { get; set; } = new List<Property>();
}
现在,模型已配置好,以便 EF Core 能够识别其关系,你可以开始使用 EF Core。
8.1.4 安装 Entity Framework Core
默认情况下,EF Core 不包含在 Web 应用程序项目中。需要将其作为 NuGet 的附加包进行安装。安装包的最简单方法(不依赖于所使用的 IDE)是向项目文件添加新的包引用:应用程序文件夹根目录中的 CityBreaks.csproj 文件。项目文件在资源管理器中可见,如果您使用的是 VS Code,则可以轻松访问。它在 Visual Studio 解决方案资源管理器中不可见。您需要右键单击项目名称,然后从出现的上下文菜单中选择 Edit Project File。打开文件后,将新的 PackageReference 条目添加到现有 ItemGroup 中,或创建新的 ItemGroup 节点:
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite"
➥ Version="6.0.0" />
</ItemGroup>
Visual Studio 用户应该注意到,当你以这种方式添加包时,VS 会自动运行 restore 命令并从 NuGet 获取所需的库。使用 VS Code,您需要从终端自行执行 dotnet restore 命令。C# 扩展应提示您执行此作。
或者,可以使用 dotnet add 命令在 VS Code 中添加包。命令为:
dotnet add package Microsoft.EntityFrameworkCore.Sqlite
执行该命令后,VS Code 将自动恢复所有包。
Visual Studio 用户具有用于管理包的内置工具。转到 NuGet 包管理器>工具“,然后从那里,您可以选择 Manage NuGet Packages for Solution。这将打开一个仪表板 UI,允许您管理以前安装的软件包以及搜索和安装其他软件包。或者,您可以调用 Package Manager 控制台 (PMC) 并执行用于管理包的命令。要添加软件包,请使用 install-package 命令:
install-package Microsoft.EntityFrameworkCore.Sqlite
同样,VS 将在解决包后自动还原包。
8.1.5 创建上下文
在代码中使用 EF Core 的入口点是上下文,即派生自 DbContext 的对象。它表示与数据库的会话,并提供用于与数据库通信以执行数据作(如查询和数据持久性)的 API。它还支持更高级的功能,例如模型构建和数据映射(我们将在后面介绍),以及事务管理、对象缓存和更改跟踪,这些功能在本书中不涉及。
将工作上下文交付给应用程序所需的步骤是
- 创建从 DbContext 派生的类。
- 提供连接字符串。
- 向服务容器注册上下文。
从步骤 1 开始,向项目中添加一个名为 Data 的新文件夹,并在该文件夹中添加一个名为 CityBreaksContext.cs 的新类文件,其中包含以下代码。
清单 8.5 CityBreaksContext
using Microsoft.EntityFrameworkCore;
namespace CityBreaks.Data
{
public class CityBreaksContext : DbContext
{
public CityBreaksContext(DbContextOptions options) : base(options)
{
}
}
}
该类具有一个将 DbContextOptions 对象作为参数的构造函数。在将上下文注册为服务时,您将配置此对象,并提供连接字符串。首先,您需要向应用程序添加连接字符串。为此,您将使用主配置文件。如果您还记得第 2 章,这是 appSettings.json 文件。您将添加一个名为 ConnectionStrings 的属性。此属性或部分的命名非常重要,因为它是配置 API 查找连接字符串所依赖的约定。然后,您将提供连接字符串的名称及其值。
清单 8.6 向 appSettings.json 添加连接字符串
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"CityBreaksContext": "Data source=Data/CityBreaks.db" ❶
}
}
❶ 连接字符串部分和实际连接字符串
SQLite 连接字符串很好,很简单,表示数据库文件的路径。在您的应用程序中,您将数据库文件放在 Data 文件夹中,与上下文并排。稍后运行第一次迁移时,EF Core 将创建该文件。
最后一步是向服务容器注册上下文。正如您从上一章中学到的那样,您可以在 Program.cs 文件中执行此作。在添加以下代码之前,您需要添加一些 using 指令以引入 Microsoft.EntityFrameworkCore 和 CityBreaks.Data。
清单 8.7 配置 CityBreaksContext
builder.Services.AddDbContext<CityBreaksContext>(options =>
{
options.UseSqlite(builder.Configuration.GetConnectionString
➥ ("CityBreaksContext"));
});
GetConnectionString 方法在 appSettings.json 的 ConnectionStrings 部分中找到指定的连接字符串,而 UseSqlite 方法则设置正确的数据库提供程序供 EF Core 使用。
您拥有上下文并将其注册为服务。目前,它几乎没用;这就像有一个空的数据库。您将需要一些在上下文中由 DbSet<TEntity>
属性表示的数据库表,其中 TEntity 是您希望表表示的实体。图 8.4 说明了实体、DbSet和数据库之间的关系。
图 8.4 每个实体都由一个 DbSet 表示,该 DbSet 映射到数据库中的表。
8.1.6 添加 DbSet
首先,您将 DbSet 添加到要映射到数据库表的每个实体的上下文中。按照约定,该表将采用 DbSet 属性的名称。下一个清单显示了到目前为止您在模型中创建的三个类,每个类都表示为 DbSet<TEntity>
。
列表 8.8 映射到数据库中的 Table 的 DbSet 属性
public class CityBreaksContext : DbContext
{
public CityBreaksContext(DbContextOptions options) : base(options)
{
}
public DbSet<City> Cities { get; set; }
public DbSet<Country> Countries { get; set; }
public DbSet<Property> Properties { get; set; }
}
8.1.7 配置模型
如果要在此阶段创建迁移,它将生成一个包含三个表的数据库,每个 DbSet 一个表,并且它将使用约定根据 DbSet 类型参数表示的每种类型的属性创建列。在大多数实际应用程序中,默认约定对于大多数模型都是可以接受的,尤其是在您从头开始时。对于约定不适用或 EF Core 需要帮助了解你的意图的情况,EF Core 提供了允许你替代约定的配置 API。
配置面向三个级别:模型、类型和属性。您可以在模型级别配置 EF Core 用于对象的架构。类型配置选项使您能够配置类型映射到的表名称,或者应如何指定类型之间的关系。属性配置提供了广泛的选项,用于管理各个属性映射到列的方式,包括其名称、数据类型、默认值等。
可以通过两种方式应用配置:使用特性修饰类和属性,或者使用由可链接在一起的扩展方法集合组成的 Fluent API。属性仅提供配置选项的子集。因此,对于任何相当复杂的模型,你都可能需要依赖 Fluent API 进行某些配置。因此,对所有配置使用 Fluent API 是有意义的,这样可以保持配置代码的一致性,从而更容易推理,并且集中在一个地方。
那么,您应该将 Fluent API 配置代码放在哪里呢?您有两个选项:可以在自己的上下文类中重写 DbContext OnModelCreating 方法并将配置代码放在该上下文中,也可以将配置代码放在每个实体的单独类中,然后在 OnModelCreating 方法中引用这些类。您将采用后一种方法,因为它是管理应用程序这一方面的推荐方法。
配置类派生自 IEntityTypeConfiguration<TEntity>
,它实现一种方法 Configure,该方法将 EntityTypeBuilder<TEntity>
作为参数。您将在此处放置配置代码。
首先在 Data 文件夹中创建一个新文件夹,将其命名为 Configurations。使用以下代码将名为 CountryConfiguration 的 C# 类文件添加到新文件夹中。
列表 8.9 CountryConfiguration 类
using CityBreaks.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace CityBreaks.Data.Configuration;
public class CountryConfiguration : IEntityTypeConfiguration<Country> ❶
{
public void Configure(EntityTypeBuilder<Country> builder) ❷
{
builder.Property(x => x.CountryName)
.HasMaxLength(50); ❸
builder.Property(x => x.CountryCode)
.HasColumnName("ISO 3166 code") ❹
.HasMaxLength(2); ❹
}
}
❶ 该类实现 IEntityTypeConfiguration
❷ 根据接口的要求实现 Configure 方法。
❸ 使用 HasMaxLength 方法约束 CountryName 属性的文本字段的长度。
❹ 将 CountryCode 属性映射到名为“ISO 3166 Code”的列并限制其大小。
字符串属性通常映射到 SQL Server 中的 nvarchar(max) 数据类型。您已使用 HasMaxLength 方法对支持基于文本的列的数据库中基于文本的列的大小应用限制。SQLite 不支持此方法,因此除非您使用的是 SQL Server,否则此配置将不起作用。不过,HasColumnName 方法将适用于任何数据库,并将 CountryCode 属性映射到“ISO 3166 代码”列。在配置 CountryCode 属性时,您可以看到 HasMaxLength 方法链接到 HasColumnName 方法的 Fluent API。
8.2 迁移
您几乎可以创建迁移,使数据库架构与模型保持同步。迁移工具检查上下文的 DbSet 属性,并将它们与上一个迁移生成的快照(如果有)进行比较。任何差异都会导致生成 C# 代码,这些代码在执行时被转换为 SQL,该 SQL 将更改应用于实际数据库。如果数据库尚不存在,则第一次迁移将导致创建数据库。还可以按需生成迁移 SQL 脚本,因此您可以自行将它们应用于数据库。这对于对实时数据库进行更改特别有用,因为在实时数据库中,执行 C# 代码通常很困难(如果不是不可能的话)。
配置的另一个非常有用的方面是指定种子数据的能力,该数据用于在迁移期间填充数据库。此功能具有明显的用途,因为它可以让您开始使用一组数据,而无需手动输入。在下一节中,您将了解如何将此功能与一些国家/地区数据一起使用,然后是一些城市数据。
本章 (http://mng.bz/jAra) 附带的迁移下载还包括 Property 类型的种子数据以及 cities 的一些图像(由 https://unsplash.com/ 提供)。我建议您从 GitHub 存储库获取相关代码和图像,并将其用于迁移,以便您的数据库内容与以后的示例相匹配。
8.2.1 种子数据
您将使用 Fluent API HasData 方法为实体指定种子数据,作为其配置的一部分。您必须指定主键值和外键值,以便迁移可以确定是否在迁移之外对数据进行了任何更改。此类更改将被覆盖,因此种子设定功能最适合于不会更改的静态数据。如果数据在使用种子设定功能添加后可能会更改,则可以注释掉相关代码,这样就不会在后续迁移中调用它。下面的清单显示了 CountryConfiguration 类中的 Configure 方法,该方法经过修改后包括 HasData 方法调用,该方法采用相关类型的集合。
清单 8.10 国家种子数据
public void Configure(EntityTypeBuilder<Country> builder)
{
builder.Property(x => x.CountryName)
.HasMaxLength(50);
builder.Property(x => x.CountryCode)
.HasColumnName("ISO 3166 code")
.HasMaxLength(2);
builder.HasData(new List<Country>
{
new Country {Id = 1, CountryName = "Croatia", CountryCode="hr" },
new Country {Id = 2, CountryName = "Denmark", CountryCode = "dk" },
new Country {Id = 3, CountryName = "France", CountryCode = "fr" },
new Country {Id = 4, CountryName = "Germany", CountryCode = "de" },
new Country {Id = 5, CountryName = "Holland", CountryCode = "nl" },
new Country {Id = 6, CountryName = "Italy", CountryCode = "it" },
new Country {Id = 7, CountryName = "Spain", CountryCode = "es" },
new Country {Id = 8, CountryName = "United Kingdom",
➥ CountryCode = "gb" },
new Country {Id = 9, CountryName = "United States",
➥ CountryCode = "us" }
});
}
要为城市添加种子数据,您首先需要向 City 类添加一个属性来表示图像。我将此属性命名为 Photo,但您需要将其配置为映射到名为 Image 的列。
示例 8.11 向 City 类添加 Photo 属性
public class City
{
public int Id { get; set; }
public string Name { get; set; }
public string Photo { get; set; }
public int CountryId { get; set; }
public Country Country { get; set; }
public List<Property> Properties { get; set; } = new List<Property>();
}
现在,您需要将另一个 IEntityTypeConfiguration 类添加到 Configuration 文件夹,这次名为 CityConfiguration,代码如下。
列表 8.12 City 配置类
using CityBreaks.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace CityBreaks.Data.Configuration;
public class CityConfiguration : IEntityTypeConfiguration<City>
{
public void Configure(EntityTypeBuilder<City> builder)
{
builder.Property(x => x.Photo).HasColumnName("Image");
builder.HasData(new List<City>
{
new City { Id = 1, Name = "Amsterdam", CountryId = 5,
➥ Photo = "amsterdam.jpg" },
new City { Id = 2, Name = "Barcelona", CountryId = 7,
➥ Photo ="barcelona.jpg" },
new City { Id = 3, Name = "Berlin", CountryId = 4,
➥ Photo ="berlin.jpg" },
new City { Id = 4, Name = "Copenhagen", CountryId = 2,
➥ Photo ="copenhagen.jpg" },
new City { Id = 5, Name = "Dubrovnik", CountryId = 1,
➥ Photo ="dubrovnik.jpg" },
new City { Id = 6, Name = "Edinburgh", CountryId = 8,
➥ Photo ="edinburgh.jpg" },
new City { Id = 7, Name = "London", CountryId = 8,
➥ Photo ="london.jpg" },
new City { Id = 8, Name = "Madrid", CountryId = 7,
➥ Photo ="madrid.jpg" },
new City { Id = 9, Name = "New York", CountryId = 9,
➥ Photo ="new-york.jpg" },
new City { Id = 10, Name = "Paris", CountryId = 3,
➥ Photo ="paris.jpg" },
new City { Id = 11, Name = "Rome", CountryId = 6,
➥ Photo ="rome.jpg" },
new City { Id = 12, Name = "Venice", CountryId = 6,
➥ Photo ="venice.jpg" }
});
}
}
请注意,您已使用 HasColumnName 方法将 Photo 属性映射到名为 Image 的列。
最终配置适用于 Property 类型。此配置完全由种子数据组成,该书下载中的示例包括 50 个虚构的属性详细信息。您可以根据目前所学的知识生成种子数据,也可以从本书的 GitHub 存储库 http://mng.bz/yaBd 复制配置文件的内容。
完成配置类后,您需要向 DbContext 注册它们。为此,您可以重写 CityBreaksContext 类中的 OnModelCreating 方法,然后使用 ModelBuilder ApplyConfiguration 方法注册每个类型。由于 ApplyConfiguration 方法返回 ModelBuilder,因此您可以链接这些调用。
示例 8.13 在 OnModelCreating 方法中注册配置
protected override void OnModelCreating (ModelBuilder builder)
{
builder
.ApplyConfiguration(new CityConfiguration())
.ApplyConfiguration(new CountryConfiguration())
.ApplyConfiguration(new PropertyConfiguration());
}
8.2.2 添加迁移工具
在创建迁移之前,您需要将必要的包添加到包含用于管理迁移的命令的项目中。有两个软件包可用,每个软件包都有一组不同的命令。您使用的 ID 取决于您要用于执行迁移命令的工具。
如果您是 Visual Studio 用户,则可以使用 Package Manager 控制台;在这种情况下,您将需要 PowerShell 命令包含在 Microsoft.EntityFrameworkCore.Tools 包中。或者,您也可以改用 Microsoft.EntityFrameworkCore.Design 软件包中提供的跨平台 CLI 命令。选择包后,您可以使用前面介绍的那些您喜欢的方法将包添加到项目中。如果您使用的是 CLI 命令,则还必须确保全局安装 dotnet-ef 工具,您可以使用以下命令执行此作:
dotnet tool install --global dotnet-ef
8.2.3 创建和应用迁移
安装工具和相关包后,您可以创建您的第一个迁移。您将使用以下命令之一:
[Powershell] ❶
add-migration Create ❶
[CLI] ❷
dotnet ef migrations ad Create ❷
❶ 要从 Visual Studio 的包管理器控制台中执行的 PowerShell 命令
❷ 要从包含 csproj 文件的目录中的命令提示符执行的 CLI 命令
迁移名为 Create。成功执行您使用的任何命令都会导致将一个名为 Migrations 的新文件夹添加到项目中。图 8.5 显示了 Visual Studio Code 文件资源管理器中的 Migrations 文件夹及其内容。
图 8.5 生成的 Migrations 文件夹,包含三个文件
新文件夹包含三个文件:
- [Timestamp]_Create.cs - 包含一个名为 Create 的类,该类具有两个方法:Up 和 Down。Up 方法将更改应用于数据库,而 Down 方法则还原这些更改。
- [Timestamp]_Create.Designer.cs - 包含 EF Core 使用的元数据。
- CityBreaksContextModelSnapshot.cs - 模型的当前快照。当您添加另一个迁移时,此快照将用作基准来确定已更改的内容。
前两个文件特定于迁移。将为其他迁移添加新的 VPN。模型快照文件将针对每次新的迁移进行更新。如果查看第一个文件中 Up 方法的内容,则 C# 代码应该是不言自明的。在将迁移应用到数据库之前,您可以根据需要自由修改此版本。例如,在以后的迁移中,您可能希望能够在过程中执行一些自定义 SQL,例如引入非种子数据。在本书中,我没有介绍这一点,但知道该功能在您需要时可用是很有用的。但是,我通常会查看迁移代码,以确保我打算应用的更改反映在那里。不止一次,通过查看迁移代码,我意识到我没有正确配置属性。在这种情况下,您可以调整模型配置,然后使用 remove-migration (PowerShell) 或 dotnet ef migrations remove (CLI) 命令删除现有迁移,然后再使用与以前相同的命令将其添加回去。
生成迁移并检查它是否按您的要求运行后,您将使用以下命令之一应用它:
[Powershell]
update-database
[CLI]
dotnet ef database update
执行这些命令之一后,您应该会看到已在 Data 文件夹中创建了一个 SQLite 数据库文件:CityBreaks.db。使用您喜欢的任何工具(我使用 SQLite 的跨平台 DB 浏览器;https://sqlitebrowser.org/),请查看 schema(图 8.6)。除了每个模型类的表之外,数据库还包括一个名为 __EFMigrationsHistory 的表。
图 8.6 新数据库包含名为 __EFMigrationsHistory 的表。
此表跟踪已应用于数据库的迁移。目前,它包含一条记录,该记录由您刚刚应用的迁移的名称以及使用的 EF Core 版本组成。
8.3 查询数据
您的数据库已填充种子数据,现在是开始使用它的时候了。你将使用 LINQ 来表达你希望 EF Core 对数据库执行的命令。LINQ 包含一组 IEnumerable 类型的扩展方法,这些方法支持对集合进行选择和筛选作。您作的集合是上下文中的 DbSet 对象。
EF Core 负责将 LINQ 查询转换为要针对数据库执行的 SQL。生成的 SQL 取决于所使用的提供程序,同时考虑到特定于数据库的功能。
在编写 LINQ 查询时,可以采用以下两种方法之一。您可以使用 query 语法或 method 语法。查询语法看起来类似于 SQL 语句,有些人对它感觉更舒服。以下示例显示了用于获取属于主键值为 1 的国家/地区的所有城市的查询语法:
var data = from c in _context.Cities where c.CountryId == 1 select c;
但是,就像数据注释属性仅提供模型配置选项的子集一样,查询语法并不总是足够的。某些查询只能使用方法调用来表示。
我更喜欢方法语法,它包括将调用链接到 IEnumerable 类型 (http://mng.bz/M0RB) 上的扩展方法。使用查询语法,您的代码在编译时会转换为方法调用,因此这两种方法之间没有性能差异。在本书中,我们只使用方法语法。如果您有兴趣了解有关查询语法的更多信息,官方 LINQ 文档提供了许多示例:http://mng.bz/aPNm。
8.3.1 检索多条记录
您要做的第一件事是为城市创建一个新服务,这些城市将使用数据库作为其数据源。将一个新的类文件添加到 Services 文件夹,将此类文件命名为 CityService.cs。此类将实现您在上一章中创建的 ICityService 接口,并将 CityBreaksContext 作为依赖项。该类的初始代码如下面的清单所示。
Listing 8.14 CityService 类
using CityBreaks.Data;
using CityBreaks.Models;
using Microsoft.EntityFrameworkCore;
namespace CityBreaks.Services;
public class CityService : ICityService
{
private readonly CityBreaksContext _context;
public CityService(CityBreaksContext context) => _context = context;
public async Task<List<City>> GetAllAsync()
{
var cities = _context.Cities ❶
.Include(c => c.Country) ❷
.Include(c => c.Properties); ❷
return await cities.ToListAsync(); ❸
}
}
❶ 查询的入口点是 DbSet。
❷ 使用 Include 方法指定要包含在查询中的相关数据。
❸ ToListAsync 方法调用会导致查询执行。
LINQ 查询由两个阶段组成:规范阶段和执行阶段。在我们的示例中,查询的规范发生在 GetAllAsync 方法的前三行中。执行将延迟到最后一行,此时调用 ToListAsync 方法。只有在该点上,才会调用数据库。这种延迟执行的能力使您能够通过添加其他条件来继续编写规范。例如,您可能希望筛选查询以仅返回法国的城市,您可以在采用表示筛选条件的谓词的 Where 方法调用中执行此作:
var cities = _context.Cities
.Where(c => c.Country.CountryName == "France")
.Include(c => c.Country)
.Include(c => c.Properties);
您希望返回所有城市,包括其相关的国家/地区和属性,以便您可以在应用程序的主页上显示详细信息。但是,您只想包含当前可用的属性,因此将过滤器应用于 Include 方法:
var cities = _context.Cities
.Include(c => c.Country)
.Include(c => c.Properties.Where(p => p.AvailableFrom < DateTime.Now));
下一步是更新 Index.cshtml.cs 文件内容,并将现有内容替换为以下代码,该代码将 ICityService 注入构造函数中,并使用它来获取城市数据。
清单 8.15 修改后的 Index Model 主页代码
using CityBreaks.Models;
using CityBreaks.Services;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace CityBreaks.Pages
{
public class IndexModel : PageModel
{
private readonly ICityService _cityService;
public IndexModel(ICityService cityService)
{
_cityService = cityService;
}
public List<City> Cities { get; set; }
public async Task OnGetAsync() => Cities =
➥ await _cityService.GetAllAsync();
}
}
然后,从主页中删除列表框(如果第 5 章中它仍然存在)并将其替换为以下内容。
列表 8.16 更新了主页 Razor 代码
<h1>City Breaks</h1>
<div class="container">
<div class="row">
@foreach (var city in Model.Cities) ❶
{
<div class="col-4 p-3" style="text-shadow: rgb(0, 0, 0) 1px 1px
➥ 1px">
<div class="card p-3 shadow" ❷
➥ style="background:url(/images/cities/@city.Photo) ❷
➥ no-repeat center;background-size: cover;height:240px;"> ❷
<h3>
<a class="text-white text-decoration-none" ❸
➥ asp-page="/City" asp-route-name="@city.Name"> ❸
➥ @city.Name</a> ❸
<img ❹
➥ src="/images/flags/@(city.Country.CountryCode).png"❹
➥ aria-label="@($"{city.Name}, ❹
➥ {city.Country.CountryName}")"> ❹
</h3>
<h6 class="text-white"> ❺
➥ @city.Properties.Count()properties</h6> ❺
</div>
</div>
}
</div>
</div>
❶ 遍历所有城市。
❷ 使用每个城市的 Photo 属性设置背景图像。
❸ 输出城市的名称。
❹ 引用城市的 Country 属性,并使用其 CountryCode 呈现相应的国旗图标。
❺ 使用 Count() 方法呈现与每个城市关联的属性总数。
现在,您只需将 Program.cs 中现有的 SimpleCityService 注册替换为指定新 CityService 作为用于 ICityService 的实现的注册:
builder.Services.AddScoped<ICityService, CityService>();
完成此作后,您可以运行应用程序并享受新的主页(图 8.7)
图 8.7 主页显示数据库中的数据。
在继续之前,请导航到 /property-manager /create 以确保已填充 select-city 列表。以前,该数据来自 SimpleCityService,现在它来自数据库。您不仅有一个连接到应用程序的工作数据库,而且还有一个很好的示例,说明松散耦合如何使应用程序能够轻松进行更改。您不必修改 Create 页面的任一文件中的代码即可使其与数据库一起使用。您所要做的就是更改服务注册。
8.3.2 选择单个记录
现在,您已经有了一个选择多条记录的良好工作示例,您将修改 City 页面,以根据 URL 中传递的值检索单个记录。首先,您需要更新 ICityService 以包含一个名为 GetByNameAsync 的新方法,该方法将字符串作为参数并返回 Task<City>
。
清单 8.17 向 ICityService 添加新方法
public interface ICityService
{
Task<List<City>> GetAllAsync();
Task<City> GetByNameAsync(string name);
}
您有两个服务实现此接口;您将不会再次使用 SimpleCityService,因此您可以安全地删除它或使用 NotImplementedException 为满足编译器接口约定的方法创建一个存根:
public Task<City> GetByNameAsync(string name) => throw new
NotImplementedException();
如果您选择采用后一种方式,则需要记住对以后添加到 ICityService 接口的所有其他方法执行相同的作。接下来,您将在 CityService 类中提供一个有效的实现。
列表 8.18 使用名称作为条件返回单个城市的查询
public async Task<City> GetByNameAsync(string name)
{
return await _context.Cities
.Include(c => c.Country)
.Include(c => c.Properties.Where(p => p.AvailableFrom <
➥ DateTime.Now))
.SingleOrDefaultAsync(c => c.Name == name);
}
该查询与前一个查询的不同之处仅在于用于导致执行的方法。这一次,您将使用 SingleOrDefaultAsync 方法。此方法期望数据库中有零个或一个匹配的记录。如果没有匹配的记录,该方法将返回默认值,在本例中为 null。如果多条记录与条件匹配,则会引发异常。如果预计只有一条匹配记录,则可以使用 SingleAsync 方法,该方法在没有匹配项的情况下返回异常。如果您认为可能有多个记录与条件匹配,则应使用 FirstAsync 或 FirstOrDefaultAsync 方法,具体取决于是否有可能没有匹配项。这将根据数据库的默认顺序或通过 OrderBy 方法指定的顺序返回第一个匹配项。例如:
return _context.Cities.OrderBy(c => c.Name).FirstAsync(c => c.Name == name);
您在此处使用 SingleOrDefaultAsync 方法,因为您无法完全控制传递给该方法的值。您可能认为这样做是因为您的代码从来自数据库的数据生成链接。但是,当您将这些数据作为 URL 的一部分包含在其中时,您就是在向外界公开该数据,并且不能相信它不会被无辜或其他方式更改。接下来,您需要更改 CityModel 类的代码,因此将 \Pages\City.cshtml.cs 的内容替换为以下代码。
Listing 8.19 修订后的 CityModel 代码
using CityBreaks.Models;
using CityBreaks.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace CityBreaks.Pages;
public class CityModel : PageModel
{
private readonly ICityService _cityService;
public CityModel(ICityService cityService)
{
_cityService = cityService;
}
[BindProperty(SupportsGet = true)]
public string Name { get; set; }
public City City { get; set; }
public async Task<IActionResult> OnGetAsync()
{
City = await _cityService.GetByNameAsync(Name);
if(City == null)
{
return NotFound();
}
return Page();
}
}
您注入 ICityService 并使用它来检索与传递给 URL 中的页面的名称匹配的城市。您应该预料到结果可能为 null,在这种情况下,您将让用户知道没有匹配的页面。现在剩下的就是显示匹配记录的详细信息(如果找到)。
为了给细节增加一些视觉趣味,你将加入一些来自 Font Awesome (https://fontawesome.com) 的免费图标。您需要在 Pages\Shared \ _Layout.cshtml 文件中添加指向其图标的 CDN 版本的链接。在结束 </head>
标记之前添加以下代码行:
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css" />
现在,将 Pages\City.cshtml 文件的内容更改为以下内容。
Listing 8.20 渲染指定 City 的细节
@page "{name}"
@model CityBreaks.Pages.CityModel
@{
ViewData["Title"] = $"{Model.City.Name} Details";
}
<h3>@Model.City.Name</h3>
@foreach (var property in Model.City.Properties)
{
<div class="mb-3">
<h5>@property.Name</h5>
<p class="mb-1">@property.Address</p>
<i class="fas fa-euro-sign text-muted"></i>
➥ @property.DayRate.ToString("F2")<br>
@if (!property.SmokingPermitted)
{
<i class="fas fa-smoking-ban text-danger"></i>
}
@for (var i = 1; i <= property.MaxNumberOfGuests; i++)
{
<i class="fas fa-bed text-info"></i>
}
</div>
}
该代码遍历城市中所有可用的属性并呈现其详细信息,使用 Font Awesome 图标显示禁止吸烟标志(如果不允许吸烟),并使用多个床图标表示允许的最多客人数。您还使用了欧元图标来表示货币。您现在所要做的就是启动应用程序并单击主页上的一个城市(图 8.8)。
图 8.8 City(城市)页面返回 404 Not Found(未找到)。
哦!那里发生了什么?好吧,如果您使用的是 SQL Server 而不是 SQLite,则很可能可以看到所选城市的详细信息。SQLite 的问题在于,默认情况下,字符串比较区分大小写。您将一个小写值从 URL 传递给 service 方法,后者将其与数据库中的混合大小写值进行比较。要解决此问题,您可以使用 EF.Functions.Collate 方法来指定 SQLite 应该用于此比较的排序规则。英 孚。函数包含一组有用的方法,这些方法可转换为数据库函数,并且仅适用于 EF Core 中使用的 LINQ 查询。该文档提供了它们的完整列表 (http://mng.bz/gRrv)。打开 CityService,并更改 GetByNameAsync 方法,如下所示。
Listing 8.21 指定要用于查询的排序规则
public async Task<City> GetByNameAsync(string name)
{
name = name.Replace("-"," "); ❶
return await _context.Cities
.Include(c => c.Country)
.Include(c => c.Properties.Where(p =>
➥ p.AvailableFrom < DateTime.Now))
.SingleOrDefaultAsync(c =>
➥ EF.Functions.Collate(c.Name, "NOCASE") == name); ❷
}
❶ 将页面路由参数 transformer 添加的连字符替换为空格,以便它们与数据库条目匹配。
❷ 使用 EF。Functions.Collate 为 SQLite 指定 NOCASE 的排序规则。
完成此校正后,您应该能够查看所选城市的详细信息(图 8.9)。
图 8.9 显示所选城市的详细信息。
伟大!此时,您已经检索了数据的单个实例以及集合。您还检索了相关数据。接下来,我们将专注于 CRUD 的其他方面:创建新记录、更新记录和删除记录。同时,我们将研究另一个可以加快这些示例开发的功能:脚手架。
8.4 脚手架 CRUD 页面
基架是一种用于在设计时生成代码的技术,用于在使用 EF Core 时支持多种常见的应用程序方案。基架工具支持使用表 8.1 中的模板生成与数据库交互的 Razor 页面。
表 8.1 基架工具可用的模板
此外,基架工具支持空模板,这相当于每次从 Visual Studio 对话框或命令行向应用程序添加新的 Razor 页面时所看到的模板。基架工具生成的代码很少可用于生产。它仅提供一个起点。您将使用该工具为 Property 类生成所有 CRUD 页面,然后查看生成的代码以了解其缺点以及您需要采取哪些措施来解决任何问题。
在使用基架工具之前,必须安装包含模板的 NuGet 包:Microsoft.VisualStudio.Web.CodeGeneration.Design。如果您使用的是 Visual Studio,则当您使用基架时,IDE 将尝试添加对此包的最新稳定版本的引用。但是,根据我的经验,Visual Studio 在安装包后会报告一个错误,要求您再次指定基架选择。因此,我通常会手动添加包引用。假设您要使用命令行进行基架。在这种情况下,您仍然需要使用我们已经介绍的用于管理 NuGet 的任何可用方法手动添加包。
8.4.1 Visual Studio 基架说明
基架内置于 Visual Studio 中,可从 Add (添加) 对话框访问。右键单击 Pages\PropertyManager 文件夹,然后从关联菜单中选择添加。然后选择 New Scaffolded Item...从第二个菜单组。从出现的对话框中选择 Razor Pages,然后选择 Razor Pages using Entity Framework (CRUD)。接下来,单击 Add 按钮。在下一个对话框中,选择 Property (CityBreaks.Models) 作为 Model 类,并选择 CityBreaksContext 作为 Data 上下文类。将其他选项保留为默认值;也就是说,Reference Script Libraries 和 Use a Layout Page 都应该被选中。将布局页面输入留空。参见图 8.10。
图 8.10 Visual Studio 中的基架对话框
单击 Add 按钮。系统将提示您替换与 Create 页面相关的现有文件。单击 Yes (是)。然后,代码生成器应运行。您可能会发现,基架工具添加了对 Microsoft.EntityFrameworkCore.SqlServer 包的引用。在使用 SQLite 数据库时,您的应用程序不需要这样做;只有基架工具需要它。完成基架后,您可以根据需要删除此引用。如果使用的是 SQL Server,则此引用已存在,并且应用程序需要此引用。
8.4.2 从命令行搭建基架
若要从命令行搭建项,必须先安装 dotnet-aspnet-codegenerator 工具。这是一个全局工具,类似于您之前安装的 dotnet-ef 工具。使用以下命令安装该工具:
dotnet tool install --global dotnet-aspnet-codegenerator
安装该工具后,您就可以使用它了。命令名称与工具相同:dotnet-aspnet-codegenerator。该命令需要您要使用的生成器的名称,后跟您要应用的选项。Razor Pages 生成器的名称是 razorpage。表 8.2 中详细介绍了 Razor Pages 生成器选项。
表 8.2 Razor Pages 生成器选项
您可以通过指定 Razor 页面的名称以及要使用的模板的名称(以下选项之一)来基于现有模板搭建各个页面的基架:Empty、Create、Edit、Delete、Details 或 List。或者你可以省略 name 和 template;在这种情况下,生成器将搭建除 empty template 之外的所有 Template。
您希望为 Property 类搭建所有 CRUD 页面的基架,并且希望使用 CityBreaksContext 作为数据上下文。您还希望生成的文件被放置在 Pages\PropertyManager 文件夹中,并且您希望它们使用该文件夹的默认布局页面。您不会为页面指定命名空间;相反,脚手架将根据项目名称和文件夹路径生成一个:CityBreaks.Pages.PropertyManager。您希望包含不显眼的验证脚本,并且希望指定上下文使用 SQLite。将所有这些放在一起,您的命令如下所示:
dotnet aspnet-codegenerator razorpage -m Property
-dc CityBreaksContext -outDir
Pages\PropertyManager -udl -scripts -sqlite
此命令必须在包含项目文件的文件夹中执行。完成后,页面将显示在图 8.11 所示的指定文件夹中。
图 8.11 基架式 CRUD 页面
8.4.3 使用基架页
无论您采用哪种方法来搭建 CRUD 页面的基架,您现在都应该得到相同的结果。您可能会注意到的第一件事是它们无法构建。在撰写本文时,基架工具中存在一个错误,该错误导致 @ 字符(不能用作有效的 C# 标识符)作为参数应用于 Edit、Delete、Details 和 Index PageModel 类的 Include 方法中:
Property = await _context.Properties
.Include(@ => @.City).FirstOrDefaultAsync(m => m.Id == id);
这需要替换为另一个字符 — 比如 p:
Property = await _context.Properties
.Include(p => p.City).FirstOrDefaultAsync(m => m.Id == id);
完成该更改后,您可以检查各个页面中的代码并寻求改进它。在本章中,我将重点介绍 Edit (编辑) 页面的文件。一旦您了解了此页面中需要解决的问题范围,您就可以很好地对其他页面进行适当的更改。
首先,我将展示 EditModel 代码的前几行。您将注意到的第一件事是 EditModel 依赖于 EF Core 上下文。
清单 8.22 脚手架 EditModel 构造函数
private readonly CityBreaks.Data.CityBreaksContext _context;
public EditModel(CityBreaks.Data.CityBreaksContext context)
{
_context = context;
}
参考上一章,这违反了依赖倒置原则,因为上下文不是抽象的。您的 PageModel 类与您选择的数据访问技术紧密耦合,例如,如果您在单元测试中实例化此类的实例,它将调用上下文的连接字符串中定义的任何数据库。这不是单元测试。这是一个集成测试。理想情况下,您应该将上下文替换为服务,或者更确切地说,将其抽象替换为服务。
生成的代码的下一个主要问题会打开一个潜在的攻击媒介。生成的代码使整个 Property 类成为绑定目标:
[BindProperty]
public Property Property { get; set; }
如果您回想一下第 5 章,您应该将页面上的绑定目标的范围限制为仅您希望用户设置的那些属性。如果公开更多,则应用程序容易受到过度发布攻击。您确实希望将 Property 类的所有当前属性公开给模型绑定器,但情况可能并非总是如此。将来,您可能会向类添加更多属性。默认情况下,它们将公开给模型绑定,因为您使用 BindProperty 属性修饰了整个类。因此,作为最佳实践,您应该首先单独或通过 InputModel 显式公开属性。下一个清单显示了基架 OnGetAsync 处理程序方法。
清单 8.23 脚手架 EditModel OnGetAsync 处理程序
public async Task<IActionResult> OnGetAsync(int? id)
{
if (id == null)
{
return NotFound();
}
Property = await _context.Properties
.Include(p => p.City).FirstOrDefaultAsync(m => m.Id == id);
if (Property == null)
{
return NotFound();
}
ViewData["CityId"] = new SelectList(_context.Cities, "Id", "Id");
return Page();
}
我不喜欢无用的代码,此处理程序的开头有一个很好的示例。该方法采用一个可为 null 的参数,该参数表示要编辑的项的标识。然后,一个代码块检查是否传递了值,如果没有,则返回 404。您有一种机制,可以确保只有在提供号码时才能访问此页面。它被称为 route constraints,你在第 4 章中了解了它。相反,您可以将 id 设为此页面的必需路由参数,并将其限制为数字类型,从而不需要第一个代码块,因为如果未提供数字,框架将返回 404。
此代码块将创建一个填充了 City 数据的 SelectList。SelectList 被分配给 ViewData,正如您可能还记得第 3 章中的那样,它是一个弱类型字典。从 ViewData 检索到的对象需要强制转换为其正确的类型,以便在代码中再次使用。理想情况下,SelectList 应该是 PageModel 的一个属性,因此在 Razor 页面中使用对象时,无需使用强制转换。
获取 city 数据的代码不是异步的。作为性能最佳实践,您应该始终致力于在 ASP.NET Core Web 应用程序中使用异步 API(如果它们可用 http://mng.bz/epwV)。大多数进行进程外调用 (I/O) 的库(例如,支持与数据库通信、发送电子邮件、文件处理等的库)都提供异步 API。
异步代码的原因
想象一下,您正在安装一个厨房。您用 DIY 套件构建橱柜,但您会发现其中一扇门缺少铰链。你决定在得到铰链之前不能前进,所以你开车去商店拿一个。您开车去商店的所有时间都被浪费了,并增加了您的任务延迟。您正在同步工作,在进入下一个任务之前完成一个任务。
对于这种情况,更有效的方法是致电 store 并让他们提供一个 hinge。这样,您就可以继续执行其他任务,同时等待您委派给商店的配送任务已完成的通知(门铃响起)。这就是异步编程的工作原理。
Web 服务器的可用线程数有限,在高负载情况下,所有可用线程都可能正在使用中。发生这种情况时,服务器无法处理新请求,直到线程被释放。使用同步代码时,许多线程可能会在实际上没有执行任何工作时被占用,因为它们正在等待 I/O(如数据库调用)完成。使用异步代码时,当进程等待 I/O 完成时,其线程将被释放供服务器用于处理其他请求。因此,异步代码使服务器资源能够更有效地使用,并且服务器能够无延迟地处理更多流量。
生成代码的下一个主要问题乍一看可能并不明显,但当您运行应用程序并导航到 /property-manager/ edit?id=1 时,它很快就会变得清晰,如图 8.12 所示。城市选择列表中显示的值是键值,而不是城市名称。
图 8.12 键值显示在选择列表中,而不是城市名称中。
您的补救计划将执行以下作:
- 将注入的上下文交换为服务,从而启用松散耦合。
- 绑定到单个属性以降低安全风险。
- 减少对 ViewData 的依赖。
- 尽可能使用异步代码。
8.5 创建、修改和删除数据
在本节中,您将创建一个 PropertyService 以满足基架页面处理的方案的要求。您将添加用于创建和编辑 Property 实体的方法,并根据其键值检索单个实例。您暂时不会生成用于删除实体的方法。稍后,您将快速查看从数据库中删除项目所需的代码,但对于此应用程序,您将使用软删除,将项目标记为已删除,而不实际删除它。
在 EF Core 中,可以直接使用 DbContext 的 Add、Update 和 Remove 方法对 DbContext 执行导致添加、更新或删除数据的作。这些方法中的每一种都将要作的实体作为参数,并将其状态设置为 Added、Modified 或 Deleted 之一。您将调用 DbContext 的异步 SaveChangesAsync 方法,以将更改提交到数据库。上下文将根据实体的状态生成相应的 SQL。此工作流如图 8.13 所示。
图 8.13 Add 方法将实体的状态设置为 Added。调用 SaveChangesAsync 时,EF Core 会生成一个 SQL INSERT 语句,并针对数据库执行该语句。
因此,让我们首先为封装这些作的 Property 实体创建服务类。第一步是将名为 IPropertyService 的新接口添加到“服务”文件夹中,其中包含以下代码。
清单 8.24 带有 CRUD 方法的 IPropertyService 接口
using CityBreaks.Models;
namespace CityBreaks.Services
{
public interface IPropertyService
{
Task<Property> CreateAsync(Property property);
Task<List<Property>> GetAllAsync();
Task<Property> FindAsync(int id);
Task<Property> UpdateAsync(Property property);
Task DeleteAsync(int id);
}
}
现在,将一个名为 PropertyService 的新类添加到实现该接口的 Services 文件夹中。
清单 8.25 在 PropertyService 中实现 CRUD 方法
using CityBreaks.Data;
using CityBreaks.Models;
using Microsoft.EntityFrameworkCore;
namespace CityBreaks.Services;
public class PropertyService : IPropertyService
{
private readonly CityBreaksContext _context;
public PropertyService(CityBreaksContext context) =>
_context = context;
public async Task<Property> FindAsync(int id) =>
await _context.Properties
.FindAsync(id);
public async Task<List<Property>> GetAllAsync() =>
await _context.Properties
.Include(x => x.City)
.ToListAsync();
public async Task<Property> CreateAsync(Property property)
{
_context.Add(property);
await _context.SaveChangesAsync();
return property;
}
public async Task<Property> UpdateAsync(Property property)
{
_context.Update(property);
await _context.SaveChangesAsync();
return property;
}
}
第一种方法使用 FindAsync 方法检索单个实体。此方法与您目前看到的以 First 和 Single 开头的方法不同。它需要一个值,该值表示要检索的实体的键,但不能将其与 Include 方法一起使用。编辑项目时,您不一定需要其关联数据;您只需要 Foreign key 值。FindAsync 方法非常适合此目的。
CreateAsync 方法使用 DbContext.Add 方法获取上下文以开始跟踪属性实体。EntityState 应用于上下文跟踪的所有实体,这是一个指定实体当前状态的枚举。使用 Add 方法时,将分配 EntityState.Added 值。这告诉上下文应该将实体添加为新记录,并且生成的 SQL 是 INSERT 语句。
DbContext.Add 方法是在 EF Core 中引入的。在早期版本的 EF 中,对相关的 DbSet 执行数据作,等效的
_context.Properties.Add(property)
UpdateAsync 方法使用 DbContext.Update 方法,该方法指示上下文开始跟踪处于 EntityState.Modified 状态的实体。DbContext .Update 方法也是 EF Core 中的新增功能。在早期版本的 EF 中,您必须将修改后的实体附加到上下文,并将其状态显式设置为 EntityState.Modified,这类似于已应用于基架代码中现有 OnPostAsync 处理程序的模式:
_context.Attach(Property).State = EntityState.Modified;
当实体处于 Modified 状态时,EF Core 会生成一个 SQL UPDATE 语句,该语句会导致修改实体的所有非键值。我们将了解如何控制 SQL,以便它仅在您稍后实施软删除时更新单个属性值。
CreateAsync 和 UpdateAsync 方法都包含同一行:
await _context.SaveChangesAsync();
SaveChangesAsync 方法会导致对数据的所有挂起更改写入数据库。它返回一个 int,表示受作影响的行数。当您使用 Add 方法创建新记录时,生成的 SQL 会检索新创建记录的主键值,EF Core 会将其分配给跟踪的实体。您的数据库作非常简单,只涉及一个命令。可以设置多个作,并通过对 SaveChangesAsync 的一次调用同时提交所有作。默认情况下,EF Core 使用事务来执行这些作,因此,如果其中任何一个作失败,所有其他作都将回滚,从而保持数据库不变。
该服务几乎已准备好替换 PageModel 中的 DbContext作。在代码中使用该服务之前,必须先将其注册到服务容器。转到 Program.cs,并添加以下注册:
builder.Services.AddScoped<IPropertyService, PropertyService>();
8.5.1 修改数据
转到基架 EditModel 类,您将进行以下更改,以将现有的私有字段替换为 IPropertyService 和 ICityService 的新私有字段。注入的上下文将替换为服务。您还需要添加一个 using 指令来引用 CityBreaks.Services。
示例 8.26 注入 IPropertyService 代替 DbContext
private readonly IPropertyService _propertyService;
private readonly ICityService _cityService;
public EditModel(IPropertyService propertyService, ICityService cityService)
{
_propertyService = propertyService;
_cityService = cityService;
}
在清单 8.27 所示的步骤中,您将 Property 绑定目标替换为表示要向用户公开的值的单个绑定目标。您还可以添加一个公共 SelectList 属性,以替换当前对 city 下拉列表采用的 ViewData 方法。最后,将 OnGet 处理程序参数 (id) 替换为一个公共属性,该属性使您能够在两个处理程序方法中使用该值,并且您将确保在请求使用 GET 方法时可以将其绑定到该值。
清单 8.27 将绑定到实体替换为绑定到属性
public SelectList Cities { get; set; }
[BindProperty(SupportsGet = true)]
public int Id { get; set; }
[BindProperty, Display(Name = "City")]
public int CityId { get; set; }
[BindProperty, Required]
public string Name { get; set; }
[BindProperty, Required]
public string Address { get; set; }
[BindProperty, Display(Name = "Maximum Number Of Guests")]
public int MaxNumberOfGuests { get; set; }
[BindProperty, Display(Name = "Daily Rate")]
public decimal DayRate { get; set; }
[BindProperty, Display(Name = "Smoking?")]
public bool SmokingPermitted { get; set; }
[BindProperty, Display(Name = "Available From")]
public DateTime AvailableFrom { get; set; }
您需要在 OnGetAsync 处理程序中填充 Cities SelectList 属性,如果存在 ModelState 错误,则需要在 OnPostAsync 处理程序中再次填充。您已经为此建立了一个减少重复的模式。在列表 8.28 中,你添加了一个私有方法,该方法返回一个 SelectList,该方法使用异步代码到 PageModel 类的末尾。
清单 8.28 用于填充 SelectList 对象的可重用私有方法
private async Task<SelectList> GetCityOptions()
{
var cities = await _cityService.GetAllAsync();
return new SelectList(cities, nameof(City.Id), nameof(City.Name));
}
现在,你将看到许多红色波浪线,指示编译器错误。首先在 OnGetAsync 方法中处理这些问题,方法是将整个方法块替换为以下代码,该代码使用该服务获取要编辑的 Property 实例,并将其值分配给公共 PageModel 属性。
清单 8.29 修改后的 OnGetAsync 方法
public async Task<IActionResult> OnGetAsync()
{
var property = await _propertyService.FindAsync(Id);
if (property == null)
{
return NotFound();
}
Address = property.Address;
AvailableFrom = property.AvailableFrom;
CityId = property.CityId;
DayRate = property.DayRate;
MaxNumberOfGuests = property.MaxNumberOfGuests;
Name = property.Name;
SmokingPermitted = property.SmokingPermitted;
Cities = await GetCityOptions();
return Page();
}
对于相对简单的实体,将值从数据库中检索到的实体映射到 PageModel 属性的代码是可管理的。您可以想象,对于具有更多属性的实体,编写和维护这种类型的代码将非常费力。可以使用一些工具来帮助显著减少此代码(在许多情况下,减少到一行代码),例如流行的 AutoMapper (https://automapper.org/),这是我的首选选项。在本书中,您不会使用这样的工具,但我建议您为自己的应用程序探索这种节省大量时间的工具。
这样,在移动页面的 Razor 部分之前,只需整理 OnPostAsync 方法即可。基架代码将捕获 DbUpdateConcurrencyException(如果引发),这表示您正在编辑的项不再存在;在您从数据库中检索它并提交您的修改之间,其他人已经删除了它。这不是您需要担心的情况,因为您不会从数据库中删除条目。所以你的任务很简单。检查 ModelState,如果有效,则将发布的值作为 Property 实例传递给服务的 UpdateAsync 方法。
示例 8.30 更新的 OnPostAsync 方法
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
{
Cities = await GetCityOptions();
return Page();
}
var property = new Property
{
Address = Address,
AvailableFrom = AvailableFrom,
CityId = CityId,
DayRate = DayRate,Id = Id,
MaxNumberOfGuests = MaxNumberOfGuests,
Name = Name,
SmokingPermitted = SmokingPermitted
};
await _propertyService.UpdateAsync(property);
return RedirectToPage("./Index");
}
您已到达转换的最后一部分:Razor 页面本身。这里不需要太多更改。您需要做的就是
• 添加路由模板。
• 从标记帮助程序模型表达式中删除 Property 前缀。
• 更新 cities select 列表中的 city 数据源。
除非在 URL 中传递数字,否则您不希望访问此页面,因此路由模板必须包含约束。因此,您将以下模板添加到 @page 指令中:
@page "{id:int}"
您在 URL 中有 id,并且您已在 PageModel 中启用了与 route 参数的绑定,因此您不再需要表单中的 hidden 字段:
<input type="hidden" asp-for="Property.Id" />
您可以将其注释掉或完全删除。接下来,找到 cities select list 的 HTML 部分,如下面的清单所示。
Listing 8.31 脚手架选择列表 HTML
<div class="form-group">
<label asp-for="Property.CityId" class="control-label"></label>
<select asp-for="Property.CityId" class="form-control"
➥ asp-items="ViewBag.CityId"></select>
<span asp-validation-for="Property.CityId" class="text-danger"></span>
</div>
所有对 Property 的引用都应具有红色波浪线,表示编译器错误。您需要删除它们,以及页面中其他标签帮助程序中的那些。您还需要更新 asp-items 属性以引用 Model.Cities 而不是 ViewBag.CityId。修改后的版本如下面的清单所示。
列表 8.32 修改后的选择列表
<div class="form-group">
<label asp-for="CityId" class="control-label"></label>
<select asp-for="CityId" class="form-control"
➥ asp-items="Model.Cities"></select>
<span asp-validation-for="CityId" class="text-danger"></span>
</div>
现在,您可以测试修订版了。运行应用程序,然后导航到 /property-manager。您将被带到脚手架索引页面,其中列出了所有属性(图 8.14)。
图 8.14 原始基架索引页
请记住,这是未修改的基架版本,因此它在 City 列中显示键值,而不是 Name 列。键值也显示在 Create 页面的选择列表中,您可以通过单击页面标题下方的 Create New 链接来访问该列表。它们也会显示在每个属性的 Details (详细信息) 页面上。单击其中一个属性的 Edit 链接,查看它与基架的 Create 页面有何不同。城市的名称出现在选择列表中,表单标签是用户友好的(图 8.15)。
图 8.15 修改后的 Edit 页面
通过将 Available From date 设置为将来的日期来更改该属性。提交这些更改,并在您将重定向到 Index 页面时确认您的修订有效,该页面应显示修订日期。然后导航到主页,并确认指定城市的房产数量已减少 1。
8.5.2 删除数据
基架页面包括一个用于删除实体的页面。DeleteModel 类中的 OnPostAysnc 方法包含实际从数据库中删除该条目的代码。了解它的工作原理很重要,因为它不是最优的。
清单 8.33 DeleteModel 中的基架 OnPostAsync 方法
public async Task<IActionResult> OnPostAsync(int? id)
{
if (id == null) ❶
{ ❶
return NotFound(); ❶
} ❶
Property = await _context.Properties.FindAsync(id); ❷
if (Property != null)
{
_context.Properties.Remove(Property); ❸
await _context.SaveChangesAsync(); ❹
}
return RedirectToPage("./Index");
}
❶ 检查是否已将键值传递给方法
❷ 这将从数据库中检索匹配的条目。上下文开始跟踪它。
❸ DbSet.Remove 方法将实体的状态设置为 Deleted。
❹ SaveChangesAsync 将更改提交到数据库。
我们已经讨论了如何使用路由约束来替换此方法开始时的 null 检查。基架代码的另一个次优功能是,它会导致对数据库执行两个命令。第一个命令从数据库中检索项目,以便上下文可以开始跟踪它。该代码使用 DbSet.Remove 方法将实体的状态设置为 Deleted。第二个命令在调用 SaveChangesAsync 时执行,由一个 SQL DELETE 语句组成,该语句将其从数据库中删除。
实际上根本不需要检索实体。您可以使用所谓的存根来表示要删除的实体。存根是仅分配了其键值的实体。假设您已经修改了此页面,以使用表示键值的受约束路由参数,而不是可为 null 的处理程序参数。下一个清单显示了如何使用存根来表示要在 OnPostAsync 方法中删除的实体。
示例 8.34 删除由 stub 表示的实体
public async Task<IActionResult> OnPostAsync()
{
var property = new Property { Id = Id }; ❶
_context.Remove(property ); ❷
await _context.SaveChangesAsync();
return RedirectToPage("./Index");
}
❶ 创建一个存根,仅分配其 key 值。
❷ 将存根传递给 DbContext.Remove 方法,该方法将实体标记为 Deleted。
采用这种方法,您可以显著降低代码的复杂性,并将实现目标所需的数据库调用次数减半。请注意,您还使用了 DbContext.Remove 方法,而不是基架生成的 DbSet.Remove 方法。与 DbContext Add 和 Update 方法一样,Remove 方法是 EF Core 的新增功能,可帮助你减少代码。
删除实体时,所有依赖数据都将丢失或孤立。根据外键的设置方式,删除作将级联到所有依赖数据,并且还会删除该数据,或者将其外键值更新为 null,从而导致孤立数据。如果依赖数据是业务关键型数据(如 orders),则不需要这样做。您显然希望保留有关住宿预订的历史数据,例如,尽管它已被推平。如果它被意外删除,您甚至可能需要恢复它。因此,您将更频繁地使用软删除,即以某种方式将记录标记为已删除,而不是完全删除记录,这就是 DbContext.Remove 方法的结果。在本章的最后一节中,您将向 Property 类添加一个新属性,该属性表示实体标记为已删除的日期和时间。您将添加新的迁移以更新数据库架构,然后修改 Delete 页面以适应您修订后的删除管理策略。
第一步是将可为 null 的 DateTime 属性添加到 Property 类:
public DateTime? Deleted { get; set; }
为此,我通常使用 DateTime 而不是 bool,因为不可避免地会有人询问项目何时被删除。在没有更复杂的日志记录的情况下,至少这可以帮助我回答这个问题。
我将借此机会强调关于过度发布攻击的观点。您刚刚向类中添加了一个不希望用户直接设置的新属性。如果允许模型绑定器直接绑定到类的实例,则会向用户公开此属性。通过仅将单个属性指定为绑定目标,可以防止用户设置 Deleted 属性的值。
添加 Deleted 属性后,您可以使用包管理器控制台或命令行添加新的迁移,这将检测您对模型所做的更改,并通过相应地修改数据库架构来反映这些更改。Powershell 和命令行选项都显示在下面的清单中。
列表 8.35 添加迁移
[Powershell] ❶
add-migration AddedDeleteToProperty ❶
[CLI] ❷
dotnet ef migrations add AddedDeleteToProperty ❷
❶ 要从 Visual Studio 的包管理器控制台中执行的 Powershell 命令
❷ 要从包含 csproj 文件的目录中的命令提示符执行的 CLI 命令
执行后,您可以检查迁移代码中的 Up 方法,以确保它将添加可为 null 的 Delete 列。在 SQLite 中,这将是一个 TEXT 类型。
清单 8.36 新迁移的 Up 方法
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "Deleted",
table: "Properties",
type: "TEXT",
nullable: true);
}
现在,您可以使用以下命令之一应用迁移:
[Powershell]
update-database
[CLI]
dotnet ef database update
完成后,向 IPropertyService 接口添加新方法:
Task DeleteAsync(int id);
然后向 PropertyService 类添加一个实现。
列表 8.37 Delete 方法实现
public async Task DeleteAsync(int id)
{
var property = new Property { Id = id, Deleted = DateTime.Now }; ❶
_context.Attach(property).Property(p => p.Deleted).IsModified = true; ❷
await _context.SaveChangesAsync(); ❸
}
❶ 创建一个存根来表示要修改的项。
❷ 将实体附加到上下文,并指定应修改的属性。
❸ 提交更改。
此方法提供了另一个 stub 有用的示例。您只想更新此实体的数据库中的 Deleted 列。如果将整个实体传递给 Update 方法,则所有属性都包含在生成的 SQL UPDATE 语句中。为避免这种情况,您可以使用 Attach 告诉上下文开始跟踪您的实体,并将其状态设置为 Unchanged。然后,将 Deleted 属性显式设置为 modified。将单个属性设置为已修改时,UPDATE 语句中仅包含这些属性。您可以通过将 DeleteModel 中注入的上下文替换为 IPropertyService 来利用此方法。
列表 8.38 修改后的 DeleteModel 依赖于 IPropertyService
public class DeleteModel : PageModel
{
private readonly IPropertyService _propertyService; ❶
public DeleteModel(IPropertyService propertyService) ❶
{ ❶
_propertyService = propertyService; ❶
} ❶
public Property Property { get; set; } ❷
[BindProperty(SupportsGet = true)] ❸
public int Id { get; set; } ❸
public async Task<IActionResult> OnGetAsync() ❹
{ ❹
Property = await _propertyService.FindAsync(Id); ❹
if (Property == null) ❹
{ ❹
return NotFound(); ❹
} ❹
return Page(); ❹
} ❹
public async Task<IActionResult> OnPostAsync() ❺
{ ❺
await _propertyService.DeleteAsync(Id); ❺
return RedirectToPage("./Index"); ❺
} ❺
}
❶ 注入的上下文将替换为 IPropertyService。
❷ 从 Property 属性中删除不必要的 BindProperty 属性。
❸ 为 key 值添加 bound 属性,替换处理程序参数。
❹ 删除对 key 值的 null 检查,因为您将改用路由约束。
❺ 使用服务“删除”实体。
与 “编辑 ”页一样,如果 URL 中包含整数值,则只希望访问 “删除 ”页,因此转到 Delete.cshtml 并添加路由约束作为路由模板的一部分:
@page "{id:int}"
在测试之前,还有两件事要做。第一种方法是更改 PropertyService 中的 GetAllAsync 方法,以排除具有分配给其 Deleted 属性的值的属性。
清单 8.39 从结果集中排除已删除的属性
public async Task<List<Property>> GetAllAsync() =>
await _context.Properties
.Where(p => !p.Deleted.HasValue)
.Include(x => x.City)
.ToListAsync();
然后,您更改 PropertyManager\Index.cshtml.cs 文件中的 IndexModel 类,以从属于 IPropertyService 而不是上下文,以便您可以使用新方法填充页面。
清单 8.40 修改后的 PropertyManager IndexModel
public class IndexModel : PageModel
{
private readonly IPropertyService _propertyService;
public IndexModel(IPropertyService propertyService)
{
_propertyService = propertyService;
}
public IList<Property> Property { get;set; }
public async Task OnGetAsync()
{
Property = await _propertyService.GetAllAsync();
}
}
现在运行应用程序,并导航到 /property-manager。观察列表中的第一个属性。如果您使用的是上一章下载中提供的种子数据,则列表中的第一个属性应该是 Hotel Paris。点击 Delete 链接将带您进入 Delete 页面,该页面要求您确认是否要删除此项目(图 8.16)。
图 8.16 “删除”页面
单击 Delete 按钮,然后观察 Hotel Paris 不再在列表中。作为最后的检查,使用您熟悉的任何数据库工具查看 Properties 表中的数据,以确认相关记录仍然存在 — 尽管现在 Deleted 列中有一个值(图 8.17)。
图 8.17 数据库视图显示 “deleted” 记录仍然存在。
这是一个很长的章节,但我们只真正触及了 EF Core 可以做什么的皮毛。我将再次推荐 Jon P. Smith 的 Entity Framework Core in Action(第 2 版;http://mng.bz/WMeg)作为了解有关如何使用 EF Core 以及官方文档 (https://docs.microsoft.com/en-us/ef/) 的更多信息的绝佳资源。
您已经将应用程序向前推进,因为它现在可以与数据库交互。但是,如果访问该站点的任何人知道 CRUD 页面的 URL,就可以添加和修改数据。在接下来的两章中,我们将介绍如何对用户进行身份验证,以便您了解他们是谁,然后保护未经授权的用户对这些页面的访问。
总结
Entity Framework Core 是 Microsoft 提供的一种对象关系映射 (ORM) 工具,它充当插入式数据层,抽象出使用关系数据库所需的样板代码。
使用 EF Core 的主要入口点是通过上下文,即派生自 DbContext 的对象。
EF Core 上下文跟踪对象并根据其状态生成 SQL。
实体通过 DbSet 对象映射到数据库表。
EF Core 将 LINQ 查询转换为 SQL,然后针对数据库执行 SQL。
约定驱动对象和数据库之间的 EF Core 映射。
您可以使用 configuration 自定义映射。
EF Core 迁移使你能够使模型和数据库架构彼此保持同步。
您可以使用种子设定将数据添加到数据库中,作为迁移的一部分。
基架使您能够根据 EF Core 的映射快速生成 CRUD 页面。