首发于 C# .NET
8. LINQ查询_07_EF Core

8. LINQ查询_07_EF Core

EF Core

我们使用 EF Core 来演示解释查询。 现在让我们来看看这项技术的主要特点。

EF Core Entity Classes

EF Core 允许您使用任何类来表示数据,只要它包含您要查询的每个列的公共属性即可。

例如,我们可以定义以下实体类来查询和更新数据库中的 Customers 表:

public class Customer
    public int ID { get; set; }
    public string Name { get; set; }

DbContext

定义实体类后,下一步是子类化 DbContext。该类的一个实例代表您使用数据库的会话。通常,您的 DbContext 子类将为模型中的每个实体包含一个 DbSet<T> 属性:

public class NutshellContext : DbContext
    public DbSet<Customer> Customers { get; set; }
    ... properties for other tables ...

DbContext 对象做三件事:

  • 它作为一个工厂来生成您可以查询的 DbSet<> 对象。
  • 它会跟踪您对实体所做的任何更改,以便您可以将它们写回(请参阅“更改跟踪”)
  • 它提供了虚拟方法,您可以重写这些方法来配置连接和模型。

Configuring the connection

通过覆盖 OnConfiguring 方法,您可以指定数据库提供程序和连接字符串:

public class NutshellContext : DbContext
    protected override void OnConfiguring (DbContextOptionsBuilder optionsBuilder) =>
        optionsBuilder.UseSqlServer(@"Server=(local);Database=Nutshell;Trusted_Connection=True");

在此示例中,连接字符串被指定为字符串文字。生产应用程序通常会从配置文件(如 appsettings.json)中检索它。

UseSqlServer 是在属于 Microsoft.EntityFramework.SqlServer NuGet 包的程序集中定义的扩展方法。软件包可用于其他数据库提供程序,包括 Oracle、MySQL、PostgreSQL 和 SQLite。

在 OnConfiguring 方法中,您可以启用其他选项,包括延迟加载(请参阅“延迟加载”)。

Configuring the Model

默认情况下,EF Core 是基于约定的,这意味着它从您的类和属性名称推断数据库模式 schema

您可以通过覆盖 OnModelCreating 和调用 ModelBuilder 参数上的扩展方法,使用 fluent api 覆盖默认值。例如,我们可以为我们的 Customer 实体显式指定数据库表名,如下所示:

protected override void OnModelCreating (ModelBuilder modelBuilder) =>
		modelBuilder.Entity<Customer>()
			    .ToTable ("Customer"); // Table is called 'Customer'

如果没有此代码,EF Core 会将此实体映射到名为“Customers”而不是“Customer”的表,因为我们的 DbContext 中有一个名为 Customers 的 DbSet<Customer> 属性:

public DbSet<Customer> Customers { get; set; }
以下代码将所有实体映射到与实体类名称(通常为单数)而不是 DbSet<T> 属性名称(通常为复数)相匹配的表名称:
protected override void OnModelCreating (ModelBuilder modelBuilder)
    foreach (IMutableEntityType entityType in
             modelBuilder.Model.GetEntityTypes())
        modelBuilder.Entity (entityType.Name)
	            .ToTable (entityType.ClrType.Name);

Fluent API 提供了用于配置列的扩展语法。在下一个示例中,我们使用两种常用方法:

  • HasColumnName,它将属性映射到不同名称的列
  • IsRequired,指示列不可为空
protected override void OnModelCreating (ModelBuilder modelBuilder) => 	
        modelBuilder.Entity<Customer> (entity => {
             entity.ToTable ("Customer"); 		
             entity.Property (e => e.Name) 					
            .HasColumnName ("Full Name") // Column name is 'Full Name' 					
            .IsRequired(); // Column is not nullable 
您可以通过将特殊属性应用于您的实体类和属性(“数据注释”)来配置您的模型,而不是使用流畅的 API。这种方法不够灵活,因为配置必须在编译时固定,而且不够强大,因为有些选项只能通过流畅的 API 进行配置。

表 8-1 列出了 Fluent API 中一些最重要的方法。

Creating the database

EF Core 支持代码优先方法,这意味着您可以从定义实体类开始,然后让 EF Core 创建数据库。执行后者的最简单方法是在 DbContext 实例上调用以下方法:

dbContext.Database.EnsureCreated();

仅创建数据库,但对其进行配置,以便 EF Core 可以在您的实体类发生更改时自动更新架构。您可以在 Visual Studio 的包管理器控制台中启用迁移,并要求它使用以下命令创建数据库:

Install-Package Microsoft.EntityFrameworkCore.Tools
Add-Migration InitialCreate
Update-Database

第一个命令安装工具以从 Visual Studio 中管理 EF Core。第二条命令生成一个特殊的 C# 类,称为代码迁移,其中包含创建数据库的指令。最后一条命令针对项目应用程序配置文件中指定的数据库连接字符串运行这些指令。

Using DbContext

定义实体类和子类 DbContext 后,您可以实例化您的 DbContext 并查询数据库,如下所示:

using var dbContext = new NutshellContext();
Console.WriteLine (dbContext.Customers.Count());
// Executes "SELECT COUNT(*) FROM [Customer] AS [c]"

您还可以使用 DbContext 实例写入数据库。以下代码在 Customer 表中插入一行:

using var dbContext = new NutshellContext();
Customer cust = new Customer()
    Name = "Sara Wells"
dbContext.Customers.Add (cust);
dbContext.SaveChanges(); // Writes changes back to database

以下查询数据库中刚刚插入的客户:

using var dbContext = new NutshellContext();
Customer cust = dbContext.Customers
		.Single (c => c.Name == "Sara Wells")

以下更新该客户的姓名并将更改写入数据库:

cust.Name = "Dr. Sara Wells";
dbContext.SaveChanges();

对象跟踪 Object Tracking

DbContext 实例会跟踪它实例化的所有实体,因此只要您请求表中的相同行,它就可以将相同的实体反馈给您。换句话说,上下文在其生命周期中永远不会发出两个独立的实体,它们引用表中的同一行(其中一行由主键标识)。此功能称为对象跟踪 object tracking

为了说明这一点,假设姓名按字母顺序排在首位的客户的 ID 也最小。在下面的示例中,a 和 b 将引用同一个对象:

using var dbContext = new NutshellContext ();
Customer a = dbContext.Customers.OrderBy (c => c.Name).First();
Customer b = dbContext.Customers.OrderBy (c => c.ID).First();

考虑当 EF Core 遇到第二个查询时会发生什么。它首先查询数据库并获取一行。然后它读取该行的主键并在上下文的实体缓存中执行查找。看到匹配项,它返回现有对象而不更新任何值。因此,如果另一个用户刚刚在数据库中更新了该客户的姓名,则新值将被忽略。这对于避免意外的副作用(Customer 对象可能在别处使用)以及管理并发性至关重要。如果您已更改 Customer 对象的属性但尚未调用 SaveChanges,则您不希望自动覆盖您的属性。

要从数据库中获取新信息,您必须实例化一个新上下文或调用 Reload 方法,如下所示:

dbContext.Entry (myCustomer).Reload();

最佳做法是为每个工作单元使用一个新的 DbContext 实例,这样就很少需要手动重新加载实体。

Disposing DbContext

尽管 DbContext 实现了 IDisposable,但您(通常)可以在不处置实例的情况下逃脱。处置 Disposing 会强制处置上下文的连接——但这通常是不必要的,因为只要您从查询中检索完结果,EF Core 就会自动关闭连接。

由于惰性求值,过早地处理上下文实际上可能会出现问题。考虑以下:

IQueryable<Customer> GetCustomers (string prefix)
    using (var dbContext = new NutshellContext ())
        return dbContext.Customers.Where (c => c.Name.StartsWith (prefix));
foreach (Customer c in GetCustomers ("a"))
    Console.WriteLine (c.Name);

这将失败,因为查询是在我们枚举它时求值的——这是在处理它的 DbContext 之后。

但是,有一些关于不处理上下文的注意事项:

  • 它依赖于连接对象在 Close 方法上释放所有非托管资源。尽管这适用于 SqlConnection,但如果您调用 Close 但不调用 Dispose,第三方连接理论上有可能保持资源打开(尽管这可能会违反 IDbConnection.Close 定义的契约)。
  • 如果您在查询中手动调用 GetEnumerator(而不是使用 foreach),然后无法处理枚举器或使用序列,连接将保持打开状态。在这种情况下,处置 DbContext 提供了备份。
  • 有些人觉得处理上下文(以及所有实现 IDisposable 的对象)更整洁。

如果要显式处理上下文,则必须将 DbContext 实例传递到 GetCustomers 等方法中,以避免出现上述问题。在 ASP.NET Core MVC 等上下文实例通过依赖注入 (DI) 提供的场景中,DI 基础结构将管理上下文生命周期。它将在工作单元(例如在控制器中处理的 HTTP 请求)开始时创建,并在该工作单元结束时释放。

更改跟踪 Change Tracking

当您更改通过 DbContext 加载的实体中的属性值时,EF Core 会识别变化并在调用 SaveChanges 时相应地更新数据库。为此,它会创建通过 DbCon 文本子类加载的实体状态的快照,并在调用 SaveChanges 时将当前状态与原始状态进行比较(或者当您手动查询更改跟踪时,您稍后会看到) .您可以枚举 DbContext 中的跟踪更改,如下所示:

foreach (var e in dbContext.ChangeTracker.Entries())
    Console.WriteLine ($"{e.Entity.GetType().FullName} is {e.State}");
    foreach (var m in e.Members)
        Console.WriteLine ($" {m.Metadata.Name}: '{m.CurrentValue}' modified: {m.IsModified}");

当您调用 SaveChanges 时,EF Core 使用 ChangeTracker 中的信息构建 SQL 语句,这些语句将更新数据库以匹配对象中的更改,发出插入语句以添加新行,更新语句以修改数据,以及删除语句以删除那些从 DbContext 子类的对象图中删除的行。任何 TransactionScope 都受到尊重;如果不存在,它将所有语句包装在一个新事务中。

您可以通过在您的实体中实施 INotifyPropertyChanged 和可选的 INotifyPropertyChanging 来优化更改跟踪。前者允许 EF Core 避免将修改后的实体与原始实体进行比较的开销;后者允许 EF Core 避免完全存储原始值。实现这些接口后,在配置模型时调用 ModelBuilder 上的 HasChangeTrackingStrategy 方法,以激活优化的更改跟踪。

导航属性 Navigation Properties

导航属性允许您执行以下操作:

  • 无需手动连接即可查询相关表
  • 无需显式更新外键即可插入、删除和更新相关行

例如,假设客户可以进行多次购买。我们可以用以下实体表示 Customer 和 Purchase 之间的一对多关系:

public class Customer
    public int ID { get; set; }
    public string Name { get; set; }
    // Child navigation property, which must be of type ICollection<T>:
    public virtual List<Purchase> Purchases {get;set;} = new List<Purchase>();
public class Purchase
    public int ID { get; set; }
    public DateTime Date { get; set; }
    public string Description { get; set; }
    public decimal Price { get; set; }
    public int CustomerID? { get; set; } // Foreign key field
    public Customer Customer { get; set; } // Parent navigation property

EF Core 能够从这些实体中推断出 CustomerID 是 Customer 表的外键,因为名称“CustomerID”遵循流行的命名约定。如果我们要求 EF Core 从这些实体创建一个数据库,它会在 Purchase.CustomerID 和 Customer.ID 之间创建一个外键约束。

如果 EF Core 无法推断关系,您可以在 OnModelCreating 方法中显式配置它,如下所示:
modelBuilder.Entity<Purchase>()
	    .HasOne (e => e.Customer)
	    .WithMany (e => e.Purchases)
	    .HasForeignKey (e => e.CustomerID);

设置好这些导航属性后,我们可以编写如下查询:

var customersWithPurchases = Customers.Where (c => c.Purchases.Any());

我们将在第 9 章详细介绍如何编写此类查询。

Adding and removing entities from navigation collections

将新实体添加到集合导航属性时,EF Core 会在调用 SaveChanges 时自动填充外键:

Customer cust = dbContext.Customers.Single (c => c.ID == 1);
Purchase p1 = new Purchase { Description="Bike", Price=500 };
Purchase p2 = new Purchase { Description="Tools", Price=100 };
cust.Purchases.Add (p1);
cust.Purchases.Add (p2);
dbContext.SaveChanges();

在此示例中,EF Core 自动将 1 写入每个新购买的 CustomerID 列,并将数据库为每次购买生成的 ID 写入 Purchase.ID

当您从集合导航属性中删除实体并调用保存更改时,EF Core 将清除外键字段或从数据库中删除相应的行,具体取决于关系的配置或推断方式。在这种情况下,我们已将 Purchase.CustomerID 定义为可为空的整数(以便我们可以表示没有客户的购买或现金交易),因此从客户那里删除购买将清除其外键字段,而不是将其从数据库。

Loading navigation properties

当 EF Core 填充实体时,它不会(默认情况下)填充其导航属性:

using var dbContext = new NutshellContext();
var cust = dbContext.Customers.First();
Console.WriteLine (cust.Purchases.Count); // Always 0

一种解决方案是使用 Include 扩展方法,它指示 EF Core 立即加载导航属性:

var cust = dbContext.Customers
            .Include (c => c.Purchases)
	    .Where (c => c.ID == 2).First();

另一种解决方案是使用投影。当您只需要使用某些实体属性时,此技术特别有用,因为它减少了数据传输:

var custInfo = dbContext.Customers
		.Where (c => c.ID == 2)
		.Select (c => new
		    Name = c.Name,
		    Purchases = c.Purchases.Select (p => new { p.Description, p.Price })
		.First();

这两种技术都会告知 EF Core 您需要哪些数据,以便可以在单个数据库查询中获取这些数据。

也可以手动指示 EF Core 根据需要填充导航属性:

dbContext.Entry (cust).Collection (b => b.Purchases).Load();
// cust.Purchases is now populated.

这称为显式加载 explicit loading 。与前面的方法不同,这会产生额外的数据库往返。

Lazy loading

加载导航属性的另一种方法称为延迟加载 lazy loading 。启用后,EF Core 通过为每个实体类生成一个代理类来按需填充导航属性,该代理类会拦截访问未加载的导航属性的尝试。为此,每个导航属性都必须是虚拟的,并且定义它的类必须是可继承的(不是密封的)。此外,发生延迟加载时必须未释放上下文,以便可以执行额外的数据库请求。

您可以在 DbContext 子类的 OnConfiguring 方法中启用延迟加载,如下所示:

protected override void OnConfiguring (DbContextOptionsBuilder optionsBuilder)
    optionsBuilder.UseLazyLoadingProxies();

(您还需要添加对 Microsoft.EntityFramework Core.Proxies NuGet 包的引用。)

延迟加载的代价是,每次访问未加载的导航属性时,EF Core 都必须向数据库发出额外请求。如果您发出许多此类请求,性能可能会因往返次数过多而受到影响。

启用延迟加载后,类的运行时类型是从实体类派生的代理。例如:
using var dbContext = new NutshellContext();
var cust = dbContext.Customers.First();
Console.WriteLine (cust.GetType());
// **Castle.Proxies.CustomerProxy**

延迟执行 Deferred Execution

EF Core 查询会延迟执行,就像本地查询一样。这允许您逐步构建查询。但是,EF Core 有一个方面具有特殊的延迟执行语义,那就是当子查询出现在 Select 表达式中时。

对于本地查询,您会得到双重延迟执行,因为从功能的角度来看,您正在选择一系列查询。因此,如果您枚举外部结果序列但从未枚举内部序列,则子查询将永远不会执行。

使用 EF Core,子查询与主外部查询同时执行。这可以防止过多的往返。

例如,以下查询在到达第一个 foreach 语句后在单次往返中执行:

using var dbContext = new NutshellContext ();
var query = from c in dbContext.Customers
		select
		from p in c.Purchases
		select new { c.Name, p.Price };
foreach (var customerPurchaseResults in query)
    foreach (var namePrice in customerPurchaseResults)
        Console.WriteLine ($"{ namePrice.Name} spent { namePrice.Price}");

您显式投影的任何导航属性都在一次往返中完全填充:

var query = from c in dbContext.Customers
	    select new { c.Name, c.Purchases };
foreach (var row in query)
    foreach (Purchase p in row.Purchases) // No extra round-tripping
        Console.WriteLine (row.Name + " spent " + p.Price);

但是,如果我们在没有预先加载或投影的情况下枚举导航属性,则适用延迟执行规则。在以下示例中,EF Core 在每次循环迭代时执行另一个 Purchases 查询(假设启用了延迟加载):

foreach (Customer c in dbContext.Customers.ToArray())
    foreach (Purchase p in c.Purchases) // Another SQL round-trip