Pro Entity Framework Core 2 for ASP.NET Core MVC 翻译

第 20 章 查询

作者:Adam Freeman
翻译:陈广
日期:2019-5-25


Entity Framework Core 支持使用 LINQ 进行查询,对于 .NET 开发者来说,这样处理数据更加自然。在接下来的章节中,我描述了 Entity Framework Core 为控制查询所提供的高级功能,如果您无法使用前面章节中描述的技术获得所需的行为,这将是非常有用的。表 20-1 为本章简述。

表 20-1:高级查询功能简述

问题 回答
它们是什么? 高级查询功能允许您重写默认 Entity Framework Core 行为。
它们有何用途? 在使用现有数据库或有特定性能要求时,这些功能可能非常有用。
如何使用它们 这些功能作为 LINQ 查询的一部分应用。
是否有任何缺陷或限制? 这些功能可能会意外更改应用程序的行为或更改查询结果,因此应谨慎使用。
有没有其他选择? 这些都是大多数项目不需要的专门功能。

表 20-2 为本章摘要

表20-2:本章摘要

问题 解决方案 清单
查询只读数据 禁用更改跟踪功能 1-7
筛选所有查询产生的数据 应用查询过滤器 8-12
重写过滤器 使用IgnoreQueryFilters方法 13,14
使用搜索表达式查询数据 使用Like函数 15
执行并发查询 使用异步查询方法 16,17
加快查询重用 显式编译查询 18
检测查询的客户端评估 当查询包括客户端评估时启用异常报告 19,21

准备本章

本章,我继续使用19章创建的 AdvancedApp 项目。为了准备本章,我修改了用于创建和编辑Employee对象的视图,以便不能更改复合主键的值,如清单20-1所示。

提示:如果您不想跟随构建示例项目的过程,可以从本书的源代码库下载所有所需的文件,这些文件可在 https://github.com/apress/pro-ef-core-2-for-asp.net-core-mvc 上找到。

清单 20-1:Views/Home 文件夹下的 Edit.cshtml 文件,禁用键更改

@model Employee
@{
    ViewData["Title"] = "Advanced Features";
    Layout = "_Layout";
}

<h4 class="bg-info p-2 text-center text-white">
    Create/Edit
</h4>
<form asp-action="Update" method="post">
    <input type="hidden" asp-for="Id" />
    <div class="form-group">
        <label class="form-control-label" asp-for="SSN"></label>
        <input class="form-control" asp-for="SSN" readonly="@Model.SSN" />
    </div>
    <div class="form-group">
        <label class="form-control-label" asp-for="FirstName"></label>
        <input class="form-control" asp-for="FirstName"
               readonly="@Model.FirstName" />
    </div>
    <div class="form-group">
        <label class="form-control-label" asp-for="FamilyName"></label>
        <input class="form-control" asp-for="FamilyName"
               readonly="@Model.FamilyName" />
    </div>
    <div class="form-group">
        <label class="form-control-label" asp-for="Salary"></label>
        <input class="form-control" asp-for="Salary" />
    </div>
    <input type="hidden" asp-for="OtherIdentity.Id" />
    <div class="form-group">
        <label class="form-control-label">Other Identity Name:</label>
        <input class="form-control" asp-for="OtherIdentity.Name" />
    </div>
    <div class="form-check">
        <label class="form-check-label">
            <input class="form-check-input" type="checkbox"
                   asp-for="OtherIdentity.InActiveUse" />
            In Active Use
        </label>
    </div>
    <div class="text-center">
        <button type="submit" class="btn btn-primary">Save</button>
        <a class="btn btn-secondary" asp-action="Index">Cancel</a>
    </div>
</form>

接下来在 AdvancedApp 项目文件夹运行清单20-2所示命令,删除并重建数据库。

清单 20-2:删除并重建数据库

dotnet ef database drop --force
dotnet ef database update

使用dotnet run启动应用程序,导航至 http://localhost:5000,单击【Create】按钮,使用表20-3中的值存储三个Employee对象。

表 20-3:创建示例对象所需的数据值

SSN FirstName FamilyName Salary Other Name In Active Use
420-39-1864 Bob Smith 100000 Robert Checked
657-03-5898 Alice Jones 200000 Allie Checked
300-30-0522 Peter Davies 180000 Pette Checked

创建完所有三个对象之后,您将看到图20-1所示布局。

图20-1 运行示例应用程序

管理查询结果的更改跟踪

更改跟踪是让 Entity Framewrok Core 方便使用的功能之一。当您查询数据库,Entity Framework Core 开始跟踪它为表示数据而创建的对象。当调用SaveChanges方法时,Entity Framework Core 标识值已更改的属性,并相应地更新数据库。

尽管这个功能非常有用,但它并不总是适用于 ASP.NET Core MVC 应用程序中的每个查询。许多 HTTP 请求是由 MVC 应用程序目标 action 方法接收的,这些方法只从数据库读取数据,不做任何更改。如果您正在读取数据,那么 Entity Framework Core 为它创建的对象设置更改跟踪所必须做的工作并没有任何好处,因为永远不会有任何更改需要检测。

您可以使用表20-4中所示的方法来控制是否对查询执行更改跟踪,这些方法在IQueryable<T>对象上调用。

表 20-4:用于配置更改跟踪的方法

名称 描述
AsNoTracking() 此方法禁用对应用该方法的查询结果的更改跟踪。
AsTracking() 此方法启用对应用该方法的查询的结果进行更改跟踪。

表20-4中描述的方法用于控制单个查询的更改跟踪。默认情况下,更改跟踪是启用的,因此在清单20-3中,我禁用了对 Home 控制器所做的只读查询的跟踪。

清单 20-3:Controllers 文件夹下的 HomeController.cs 文件,禁用更改跟踪

using AdvancedApp.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Linq;

namespace AdvancedApp.Controllers
{
    public class HomeController : Controller
    {
        private AdvancedContext context;
        public HomeController(AdvancedContext ctx) => context = ctx;

        public IActionResult Index()
        {
            return View(context.Employees.AsNoTracking());
        }

        public IActionResult Edit(string SSN, string firstName, string familyName)
        {
            return View(string.IsNullOrWhiteSpace(SSN)
                ? new Employee() : context.Employees.Include(e => e.OtherIdentity)
                .AsNoTracking()
                .First(e => e.SSN == SSN
                    && e.FirstName == firstName
                    && e.FamilyName == familyName));
        }

        [HttpPost]
        public IActionResult Update(Employee employee)
        {
            if (context.Employees.Count(e => e.SSN == employee.SSN
                && e.FirstName == employee.FirstName
                && e.FamilyName == employee.FamilyName) == 0)
            {
                context.Add(employee);
            }
            else
            {
                context.Update(employee);
            }
            context.SaveChanges();
            return RedirectToAction(nameof(Index));
        }
    }
}

我在IndexEdit action 方法中向查询添加了AsNotTracking方法。AsTrackingAsNotTracking方法应用于IQueryable<T>对象,这意味着它们必须包含在创建查询的方法链中,比如将结果缩小为单个对象的First方法。

禁用更改跟踪没有明显的效果,但 Entity Framework Core 不再设置用于只读操作的对象的跟踪。

从更改跟踪中删除单个对象

在 ASP.NET Core MVC 应用程序中使用 MVC 模型绑定器创建的对象执行更新时,会出现一个常见的问题,该对象与使用更改跟踪的 Entity Framework Core 查询加载的对象具有相同的主键。为了演示这个问题,我修改了 Home 控制器中的Update方法,如清单20-4所示。

清单 20-4:Controllers 文件夹下的 HomeController.cs 文件,混合对象

...
[HttpPost]
public IActionResult Update(Employee employee)
{
    if (context.Employees.Find(employee.SSN, employee.FirstName,
        employee.FamilyName) == null)
    {
        context.Add(employee);
    }
    else
    {
        context.Update(employee);
    }
    context.SaveChanges();
    return RedirectToAction(nameof(Index));
}
...

我已在清单中更改了查询,以便它使用Find方法来确定是否已使用键。这是一个人为的问题,因为原始代码显示将 lambda 表达式传递给 LINQ Count方法是有效的,但这是一个非常常见的问题,即使您已经看到了一种避免它的技术,也值得演示这个问题。

使用dotnet run启动应用程序,并导航至 http://localhost:5000。单击某个雇员的【Edit】按钮,然后单击【Save】按钮;您将看到图20-2所示的错误。

图20-2 更改跟踪功能导致的异常

错误信息报告只能跟踪一个具有特定键的对象。由于 Entity Framework Core 已将作为Find方法的结果创建的Employee对象置于更改跟踪中,因此出现了此问题,并且该对象与 MVC 模型绑定器创建的并传递给Update方法的Employee对象具有相同的主键。

避免此问题的最简单方法是使用不受更改跟踪影响的简单结果的查询,例如,清单中依赖于 LINQ Count方法的原始代码。Entity Framework Core 只对实体对象执行更改跟踪,因此任何产生非实体结果(如int值)的查询都不受更改跟踪的影响。您还可以使用前面一节中描述的AsNoTracking方法,该方法将排除由来自更改跟踪的查询创建的所有对象。

如果这两种方法都不合适,则可以显式地从更改跟踪中删除对象。这并不能避免 Entity Framework Core 为跟踪对象而执行的工作,但它确实防止 Entity Framework Core 抛出异常。

在清单20-5中,我修改了Update方法,以便从更改跟踪中删除 Entity Framework Core 创建的Employee对象,这样它就不会与 MVC模型绑定器 创建的Employee对象发生冲突。

清单 20-5:Controllers 文件夹下的 HomeController.cs 文件,在跟踪中移除对象

...
[HttpPost]
public IActionResult Update(Employee employee)
{
    Employee existing = context.Employees.Find(employee.SSN,
        employee.FirstName, employee.FamilyName);
    if (existing == null)
    {
        context.Add(employee);
    }
    else
    {
        context.Entry(existing).State = EntityState.Detached;
        context.Update(employee);
    }
    context.SaveChanges();
    return RedirectToAction(nameof(Index));
}
...

被跟踪的对象被传递到 context 对象的Entry方法,并且State属性被分配给EntityState.Detached值。其结果是 Entity Framework Core 从更改跟踪中删除对象,这意味着它不再与对象冲突,其主键与 MVC 模型绑定器从 HTTP 请求创建的主键相同。

更改默认更改跟踪行为

如果大多数查询没有修改对象,那么可以更简单地禁用 context 对象所做的所有查询的跟踪,并使用AsTracking方法来针对那些需要它的查询进行启用。

在清单 20-6 中,我对AdvancedContext类的所有查询禁用了跟踪。示例应用程序中只有一个 context,但是清单中的更改不会影响其他 context,每个 context 必须以相同的方式配置。

清单 20-6:Models 文件夹下的 AdvancedContext.cs 文件,禁用更改跟踪

using Microsoft.EntityFrameworkCore;

namespace AdvancedApp.Models
{
    public class AdvancedContext : DbContext
    {
        public AdvancedContext(DbContextOptions<AdvancedContext> options)
            : base(options)
        {
            ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
        }

        public DbSet<Employee> Employees { get; set; }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Employee>().Ignore(e => e.Id);
            modelBuilder.Entity<Employee>()
                .HasKey(e => new { e.SSN, e.FirstName, e.FamilyName });
            modelBuilder.Entity<SecondaryIdentity>()
                .HasOne(s => s.PrimaryIdentity)
                .WithOne(e => e.OtherIdentity)
                .HasPrincipalKey<Employee>(e => new {
                    e.SSN,
                    e.FirstName,
                    e.FamilyName
                })
                .HasForeignKey<SecondaryIdentity>(s => new {
                    s.PrimarySSN,
                    s.PrimaryFirstName,
                    s.PrimaryFamilyName
                });
        }
    }
}

ChangeTracker属性由DbContext类定义,它返回一个ChangeTracker对象,该对象的QueryTrackingBehavior属性是使用同名枚举配置的。表20-5显示了QueryTrackingBehavior枚举的值。

表 20-5:QueryTrackingBehavior 值

名称 描述
NoTracking 此值禁用 context 对象所做查询的更改跟踪。
TrackAll 此值启用 context 对象所做查询的更改跟踪。

如果默认禁用更改跟踪,则必须在依赖跟踪以检测更改的任何查询中使用AsTracking方法。在清单20-7中,我修改了 Home 控制器的Update方法中的查询,以便将Salary属性的值应用于从数据库读取的Employee对象,只有在允许 Entity Framework Core 使用更改跟踪来检测修改后的值时,才能工作。

清单 20-7:Controllers 文件夹下的 HomeController.cs 文件,启用跟踪

...
[HttpPost]
public IActionResult Update(Employee employee)
{
    Employee existing = context.Employees
        .AsTracking()
        .First(e => e.SSN == employee.SSN && e.FirstName == employee.FirstName
            && e.FamilyName == employee.FamilyName);
    if (existing == null)
    {
        context.Add(employee);
    }
    else
    {
        existing.Salary = employee.Salary;
    }
    context.SaveChanges();
    return RedirectToAction(nameof(Index));
}
...

没有AsTracking方法,Entity Framework Core 将无法检测更改,也不能更新数据库。

使用查询过滤器

查询过滤器应用于应用程序中针对特定实体类的所有查询。查询筛选器的一个有用的应用是实现“软删除”功能,它标记被删除的对象而不将其从数据库中删除,如果数据被错误删除,则允许还原数据。

注意:我在第22章描述了高级特性 —— 真删除数据

作为准备,我向Employee类添加了一个属性,该属性将指示存储在数据库中的对象何时被用户软删除,如清单20-8所示。

清单 20-8:Models 文件夹下的 Employee.cs 文件,添加属性

namespace AdvancedApp.Models
{
    public class Employee
    {
        public long Id { get; set; }
        public string SSN { get; set; }
        public string FirstName { get; set; }
        public string FamilyName { get; set; }
        public decimal Salary { get; set; }
        public SecondaryIdentity OtherIdentity { get; set; }
        public bool SoftDeleted { get; set; } = false;
    }
}

下一步是添加一个查询过滤器,将软删除的雇员对象排除在查询结果外,如清单20-9所示。我还注释掉了禁用更改跟踪代码以保持示例尽可能简单。

清单 20-9:Models 文件夹下的 AdvancedContext.cs 文件,定义查询过滤器

using Microsoft.EntityFrameworkCore;

namespace AdvancedApp.Models
{
    public class AdvancedContext : DbContext
    {
        public AdvancedContext(DbContextOptions<AdvancedContext> options)
            : base(options)
        {
            //ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
        }

        public DbSet<Employee> Employees { get; set; }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Employee>()
                .HasQueryFilter(e => !e.SoftDeleted);

            modelBuilder.Entity<Employee>().Ignore(e => e.Id);
            modelBuilder.Entity<Employee>()
                .HasKey(e => new { e.SSN, e.FirstName, e.FamilyName });
            modelBuilder.Entity<SecondaryIdentity>()
                .HasOne(s => s.PrimaryIdentity)
                .WithOne(e => e.OtherIdentity)
                .HasPrincipalKey<Employee>(e => new {
                    e.SSN,
                    e.FirstName,
                    e.FamilyName
                })
                .HasForeignKey<SecondaryIdentity>(s => new {
                    s.PrimarySSN,
                    s.PrimaryFirstName,
                    s.PrimaryFamilyName
                });
        }
    }
}

查询过滤器是通过Entity方法选择类,然后调用HasQueryFilter方法来创建的。过滤器应用于对所选类的所有查询,只有 lambda 表达式返回true的对象才会包含在查询结果中。在清单中,我定义了一个查询过滤器,它选择SoftDeleted值为falseEmployee对象。

要实现软删除功能,我更新了Home控制器,如清单20-10所示。我添加了Delete action 用于设置Employee对象的SoftDeleted属性为true,它将确保软删除对象不会被查询过滤器排除。

清单 20-10:Controllers 文件夹下的 HomeController.cs 文件,支持软删除

using AdvancedApp.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Linq;

namespace AdvancedApp.Controllers
{
    public class HomeController : Controller
    {
        private AdvancedContext context;
        public HomeController(AdvancedContext ctx) => context = ctx;

        public IActionResult Index()
        {
            return View(context.Employees.AsNoTracking());
        }

        public IActionResult Edit(string SSN, string firstName, string familyName)
        {
            return View(string.IsNullOrWhiteSpace(SSN)
                ? new Employee() : context.Employees.Include(e => e.OtherIdentity)
                .AsNoTracking()
                .First(e => e.SSN == SSN
                    && e.FirstName == firstName
                    && e.FamilyName == familyName));
        }

        [HttpPost]
        public IActionResult Update(Employee employee)
        {
            Employee existing = context.Employees
                .AsTracking()
                .First(e => e.SSN == employee.SSN && e.FirstName == employee.FirstName
                    && e.FamilyName == employee.FamilyName);
            if (existing == null)
            {
                context.Add(employee);
            }
            else
            {
                existing.Salary = employee.Salary;
            }
            context.SaveChanges();
            return RedirectToAction(nameof(Index));
        }

        [HttpPost]
        public IActionResult Delete(Employee employee)
        {
            context.Attach(employee);
            employee.SoftDeleted = true;
            context.SaveChanges();
            return RedirectToAction(nameof(Index));
        }
    }
}

要允许用户使用软删除功能,我向 Index.cshtml 视图添加了新元素,如清单20-11所示,它将向Delete action 方法发送包含Employee主键值的 HTTP POST 请求。

清单 20-11:Views/Home 文件夹下的 Index.cshtml 文件,添加元素

@model IEnumerable<Employee>
@{
    ViewData["Title"] = "Advanced Features";
    Layout = "_Layout";
}
<h3 class="bg-info p-2 text-center text-white">Employees</h3>
<table class="table table-sm table-striped">
    <thead>
        <tr>
            <th>Key</th>
            <th>SSN</th>
            <th>First Name</th>
            <th>Family Name</th>
            <th>Salary</th>
            <th></th>
        </tr>
    </thead>
    <tbody>
        <tr class="placeholder"><td colspan="7" class="text-center">No Data</td></tr>
        @foreach (Employee e in Model)
        {
            <tr>
                <td>@e.Id</td>
                <td>@e.SSN</td>
                <td>@e.FirstName</td>
                <td>@e.FamilyName</td>
                <td>@e.Salary</td>
                <td class="text-right">
                    <form>
                        <input type="hidden" name="SSN" value="@e.SSN" />
                        <input type="hidden" name="Firstname" value="@e.FirstName" />
                        <input type="hidden" name="FamilyName"
                               value="@e.FamilyName" />
                        <button type="submit" asp-action="Delete" formmethod="post"
                                class="btn btn-sm btn-danger">
                            Delete
                        </button>
                        <button type="submit" asp-action="Edit" formmethod="get"
                                class="btn btn-sm btn-primary">
                            Edit
                        </button>
                    </form>
                </td>
            </tr>
        }
    </tbody>
</table>
<div class="text-center">
    <a asp-action="Edit" class="btn btn-primary">Create</a>
</div>

form元素及其内容用于删除和编辑功能。每个按钮元素都配置了 action 和 HTTP 方法,用户单击它时应该使用该方法,以替换我以前使用的锚元素。

在 AdvancedApp 项目文件夹下运行清单20-12所示的命令,创建新的迁移并应用至数据库。

清单 20-12:创建并应用数据库迁移

dotnet ef migrations add SoftDelete
dotnet ef database update

要查看软删除的效果,使用dotnet run启动应用程序,导航至 http://localhost:5000,删除一个Employee对象。删除对象后,它将从Employee对象表中消失,如图20-3所示。

图20-3 软删除数据

重写查询过滤器

只有在有删除对象的工具时,才能对象进行软删除。这意味着我需要重写过滤器,以便能够查询数据库中的软删除对象,并将它们呈现给用户。我在 Controllers 文件夹中添加了一个名为 DeleteController.cs 的类文件,并使用它来定义如清单20-13所示的控制器。

清单 20-13:Controllers 文件夹下的 DeleteController.cs 文件的内容

using AdvancedApp.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Linq;

namespace AdvancedApp.Controllers
{
    public class DeleteController : Controller
    {
        private AdvancedContext context;
        public DeleteController(AdvancedContext ctx) => context = ctx;

        public IActionResult Index()
        {
            return View(context.Employees.Where(e => e.SoftDeleted)
                .Include(e => e.OtherIdentity).IgnoreQueryFilters());
        }

        [HttpPost]
        public IActionResult Restore(Employee employee)
        {
            context.Employees.IgnoreQueryFilters()
                .First(e => e.SSN == employee.SSN
                    && e.FirstName == employee.FirstName
                    && e.FamilyName == employee.FamilyName).SoftDeleted = false;
            context.SaveChanges();
            return RedirectToAction(nameof(Index));
        }
    }
}

IndexRestore action 方法都需要查询软删除对象,而查询过滤器排除了它们。为了确保这些查询能够访问它们所需的数据,我调用了IgnoreQueryFilters方法,如下所示:

...
return View(context.Employees.Where(e => e.SoftDeleted)
    .Include(e => e.OtherIdentity).IgnoreQueryFilters());
...

此方法在不应用查询筛选器的情况下进行查询。为了向控制器提供一个视图,我创建了 Views/Delete 文件夹,并向它添加了一个名为 Index.cshtml 的文件,内容如清单20-14所示。

清单 20-14:Views/Delete 文件夹下的 Index.cshtml 文件的内容

@model IEnumerable<Employee>
@{
    ViewData["Title"] = "Advanced Features";
    Layout = "_Layout";
}
<h3 class="bg-info p-2 text-center text-white">Deleted Employees</h3>
<table class="table table-sm table-striped">
    <thead>
        <tr>
            <th>SSN</th>
            <th>First Name</th>
            <th>Family Name</th>
            <th></th>
        </tr>
    </thead>
    <tbody>
        <tr class="placeholder"><td colspan="4" class="text-center">No Data</td></tr>
        @foreach (Employee e in Model)
        {
            <tr>
                <td>@e.SSN</td>
                <td>@e.FirstName</td>
                <td>@e.FamilyName</td>
                <td class="text-right">
                    <form method="post">
                        <input type="hidden" name="SSN" value="@e.SSN" />
                        <input type="hidden" name="FirstName" value="@e.FirstName" />
                        <input type="hidden" name="FamilyName"
                               value="@e.FamilyName" />
                        <button asp-action="Restore"
                                class="btn btn-sm btn-success">
                            Restore
                        </button>
                    </form>
                </td>
            </tr>
        }
    </tbody>
</table>

使用dotnet run启动应用程序并导航至 http://localhost:5000/delete;您将看到一个软删除对象列表。单击【Restore】按钮以将对象的SoftDeleted属性设置为false,这会将其还原到由 Home 控制器提供的主数据表中,如图20-4所示。

图20-4 还原软删除对象

使用搜索模式进行查询

Entity Framework Core 支持 SQL 的LIKE表达式,这意味着可以使用搜索模式进行查询。在清单 20-15 中,我更改了 Home 控制器的Index action,以便它接收用于创建LIKE查询的搜索项参数。

清单 20-15:Controllers 文件夹下的 HomeController.cs 文件,使用搜索模式

using AdvancedApp.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Linq;

namespace AdvancedApp.Controllers
{
    public class HomeController : Controller
    {
        private AdvancedContext context;
        public HomeController(AdvancedContext ctx) => context = ctx;

        public IActionResult Index(string searchTerm)
        {
            IQueryable<Employee> data = context.Employees;
            if (!string.IsNullOrEmpty(searchTerm))
            {
                data = data.Where(e => EF.Functions.Like(e.FirstName, searchTerm));
            }
            return View(data);
        }

        public IActionResult Edit(string SSN, string firstName, string familyName)
        {
            return View(string.IsNullOrWhiteSpace(SSN)
                ? new Employee() : context.Employees.Include(e => e.OtherIdentity)
                .AsNoTracking()
                .First(e => e.SSN == SSN
                    && e.FirstName == firstName
                    && e.FamilyName == familyName));
        }

        [HttpPost]
        public IActionResult Update(Employee employee)
        {
            Employee existing = context.Employees
                .AsTracking()
                .First(e => e.SSN == employee.SSN && e.FirstName == employee.FirstName
                    && e.FamilyName == employee.FamilyName);
            if (existing == null)
            {
                context.Add(employee);
            }
            else
            {
                existing.Salary = employee.Salary;
            }
            context.SaveChanges();
            return RedirectToAction(nameof(Index));
        }

        [HttpPost]
        public IActionResult Delete(Employee employee)
        {
            context.Attach(employee);
            employee.SoftDeleted = true;
            context.SaveChanges();
            return RedirectToAction(nameof(Index));
        }
    }
}

LINQ 中没有对LIKE的直接支持,这会导致语法上的尴尬。EF.Functions.Like方法用于访问Where子句中的Like功能,并接收将被匹配的属性和作为参数的搜索项。在清单中,我使用了Like方法搜索其FirstName值与 action 方法接收的搜索项参数相匹配的Employee对象。搜索项可以用四个通配符来表示,它们在表20-6中描述。

表 20-6:SQL LIKE 通配符

通配符 描述
% 此通配符匹配 0 或多个字符组成的字符串
_ 此通配符匹配任意单个字符
[chars] 此通配符与集合中的任意单个字符匹配
[^chars] 此通配符匹配任意不在集合内的单个字符。

要查看搜索是如何工作的,使用dotnet run启动应用程序,确保所有示例Employee对象已经从软删除中恢复,导航至以下 URL:

http://localhost:5000?searchTerm=%[ae]%

在 URL 查询字符串中指定的搜索项将匹配包含字母AE的任何名称。如果检查应用程序生成的日志消息,您将看到 Entity Framework Core 已发送到数据库服务器的查询。

...
SELECT [e].[SSN], [e].[FirstName], [e].[FamilyName], [e].[Salary], [e].[SoftDeleted]
FROM [Employees] AS [e]
WHERE ([e].[SoftDeleted] = 0) AND [e].[FirstName] LIKE @__searchTerm_1
..

查询的重要部分是LIKE关键字,我已经突出显示了这个关键字。这将确保只从数据库中读取与搜索词匹配的对象。

在使用表20-3中的数据创建的三个对象中,只有AlicePeter将被搜索词匹配,产生如图20-5所示的结果。

图20-5 在查询中使用搜索项

避免 LIKE 评估陷阱

必须注意将EF.Functions.Like方法仅应用于IQueryable<T>对象。您必须避免对IEnumerable<T>对象使用如下方式调用LIKE方法:

...
public IActionResult Index(string searchTerm) 
{
    IEnumerable<Employee> data = context.Employees;
    if (!string.IsNullOrEmpty(searchTerm)) 
    {
        data = data.Where(e => EF.Functions.Like(e.FirstName, searchTerm));
    }
    return View(data);
}
...

结果是一样的,但如果检查发送到数据库服务器的查询,则会发现查询中没有包含LIKE关键字。

...
SELECT [e].[SSN], [e].[FirstName], [e].[FamilyName], [e].[Salary], [e].[SoftDeleted]
FROM [Employees] AS [e]
WHERE [e].[SoftDeleted] = 0
...

Entity Framework Core 将检索搜索项可能匹配的所有对象,在应用程序中处理它们,并丢弃那些不需要的对象。对于示例应用程序,这意味着查询将加载一个额外的对象,但在实际项目中,加载并丢弃的数据量可能很大。


进行异步查询

大多数使用 Entity Framework Core 的查询都是同步的。在大多数应用程序中,同步查询是完全可以接受的,因为查询是 ASP.NET Core MVC action 方法执行的唯一活动,该方法也是同步的。

Entity Framework Core 也可以异步执行查询,如果您使用异步 action 方法,并且该 action 方法需要同时执行多个活动,而这些活动中只有一个是数据库查询,则该查询非常有用。异步查询有用的一组环境非常具体,事实上,大多数 ASP.NET Core MVC 项目都不需要使用它们。

在清单20-16中,我已经重写Index action 以便它变为异步的,并且利用 Entity Framewrok Core 对异步查询的支持。

清单 20-16:Controllers 文件夹下的 HomeController.cs 文件,进行异步查询

using AdvancedApp.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Linq;
using System.Threading.Tasks;
using System.Net.Http;

namespace AdvancedApp.Controllers
{
    public class HomeController : Controller
    {
        private AdvancedContext context;
        public HomeController(AdvancedContext ctx) => context = ctx;

        public async Task<IActionResult> Index(string searchTerm)
        {
            IQueryable<Employee> employees = context.Employees;
            if (!string.IsNullOrEmpty(searchTerm))
            {
                employees = employees.Where(e =>
                    EF.Functions.Like(e.FirstName, searchTerm));
            }
            HttpClient client = new HttpClient();
            ViewBag.PageSize = (await client.GetAsync("http://apress.com"))
                .Content.Headers.ContentLength;
            return View(await employees.ToListAsync());
        }

        // ...其它省略...
}

异步查询的局限性使得很难创建一个有用的示例。在清单中,我使用HttpClient类向 apress.com 发送异步 HTTP GET 请求,同时还查询数据库。


避免并发查询陷阱

Microsoft 特别警告不要使用 context 对象来执行多个异步请求,因为 DbContext 类的编写并不是为了适应这些请求。这导致一些有进取心的开发人员使用依赖注入来接收两个 context 对象,每个对象用于执行并发异步请求。

...
public HomeController(AdvancedContext ctx, AdvancedContext ctx2) {
...

这种方法的问题在于 ASP.NET Core MVC 依赖注入功能只会创建一个 context 对象,并使用它来解决这两个依赖关系,这意味着始终只存在一个 context 对象。我的建议是接受异步查询支持的限制。


Entity Framework Core 提供了一系列强制对查询进行异步计算的方法。表20-7中描述了最常用的异步方法,但是对于强制查询计算的所有方法都有异步等效方法,因此LastAsync方法是Last方法的异步对应。创建查询不需要异步方法的版本(如Where),因为它们在不执行查询的情况下构建查询。

表 20-7:执行异步查询的常用方法

名称 描述
LoadAsync() 此方法强制异步执行查询,但对结果不做任何操作。这是与Load方法相对应的,通常与修复过程一起使用。
ToListAsync() 此方法查询数据库并返回一个对象列表
ToArrayAsync() 此方法查询数据库并返回一个对象数组
ToDictionaryAsync(key) 此方法查询数据库并返回一个用指定的属性作为键值来源的字典对象
CountAsync() 此方法返回与指定谓词匹配的数据库中存储的对象数量。如果没有指定谓词,则返回与查询匹配的存储对象数量。
FirstAsync(predicate) 此方法返回与指定谓词匹配的第一个对象
ForEachAsync(function) 此方法为查询匹配的每个对象调用指定的函数

注意ForEachAsync方法没有对应的同步版本,但可用于为从查询结果创建的每个对象调用函数。

在清单中,我使用ToListAsync方法来异步地查询数据库,并将其产生的List<Employee>传递给View方法。List<T>类实现了IEnumerable<T>接口,这意味着现有视图可以在不进行任何更改的情况下枚举对象。为了显示清单20-16中的异步 HTTP 请求从 apress.com 读取的字节数,我将清单20-17中所示的元素添加到 Index 视图中。

清单 20-17:Views/Home 文件夹下的 Index.cshtml 文件,添加元素

@model IEnumerable<Employee>
@{
    ViewData["Title"] = "Advanced Features";
    Layout = "_Layout";
}
<h3 class="bg-info p-2 text-center text-white">Employees</h3>
<table class="table table-sm table-striped">

    <!-- ...此处省略... -->

</table>
@if (ViewBag.PageSize != null)
{
    <h4 class="bg-info p-2 text-center text-white">
        Page Size: @ViewBag.PageSize bytes
    </h4>
}
<div class="text-center">
    <a asp-action="Edit" class="btn btn-primary">Create</a>
</div>

要测试异步查询,使用dotnet run启动应用程序,并导航至 http://localhost:5000。您将看到如图20-6所示的 page size 元素,以及同时从数据库中检索的员工数据。(您可能会看到不同数量的字节显示,因为 apress.com 经常被更新。)

图20-6 进行并发查询

显式编译查询

最引人注目的 Entity Framework Core 功能之一是将 LINQ 查询转换为 SQL 的方式。翻译过程可能很复杂,为了提高性能,Entity Framework Core 自动保存它处理过的查询的缓存,并为其处理的每个查询创建哈希表示,以确定是否有缓存的译本可用。如果存在,则使用缓存译本;如果没有,则创建一个新的译本,并将其放入缓存中供将来使用。

您可以通过查询的显式译本来提高此过程的性能,这样 Entity Framework Core 就不必创建哈希代码并检查缓存。这被称为查询的显式编译(explicitly compiling)。在清单20-18中,我更新了 Home 控制器,以便显式编译Index action 执行的查询。

清单 20-18:Controllers 文件夹下的 HomeController.cs 文件,编辑一个查询

using AdvancedApp.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Linq;
using System.Threading.Tasks;
using System.Net.Http;
using System;
using System.Collections.Generic;

namespace AdvancedApp.Controllers
{
    public class HomeController : Controller
    {
        private AdvancedContext context;

        private static Func<AdvancedContext, string, IEnumerable<Employee>> query
            = EF.CompileQuery((AdvancedContext context, string searchTerm)
                => context.Employees
                    .Where(e => EF.Functions.Like(e.FirstName, searchTerm)));

        public HomeController(AdvancedContext ctx) => context = ctx;

        public IActionResult Index(string searchTerm)
        {
            return View(string.IsNullOrEmpty(searchTerm)
                ? context.Employees : query(context, searchTerm));
        }

        // ...其它省略...
    }
}

生成编译查询的语句可能难以阅读。编译是使用EF.CompileQuery方法执行的,如下所示:

...
EF.CompileQuery((AdvancedContext context, string searchTerm)
    => context.Employees.Where(e => EF.Functions.Like(e.FirstName, searchTerm)));
...

CompileQuery方法的参数是 lambda 表达式,它接收查询中使用的 context 对象和参数,并返回IQueryable<T>作为结果。在清单中,lambda 表达式接收一个AdvancedContext对象和一个字符串,并使用它们创建一个IQueryable<Employee>,它将使用LIKE功能查询数据库。

EF.CompileQuery方法的结果是Func<AdvancedContext, string, IEnumerable<Employee>>对象,它表示接受 context 和字符串并生成一系列Employee对象的函数。

...
private static Func<AdvancedContext, string, IEnumerable<Employee>> query
    = EF.CompileQuery((AdvancedContext context, string searchTerm)
        => context.Employees.Where(e => EF.Functions.Like(e.FirstName, searchTerm)));
...

请注意,编译的函数返回一个IEnumerable<T>对象,这意味着对结果执行的任何进一步操作都将在内存中执行,而不是基于发送到数据库的请求。这是有意义的,因为这个过程的目的是创建一个不可变的查询,这意味着您必须确保所需查询的每个方面都包含在传递给CompileQuery方法的表达式中。

执行查询是通过调用CompiledQuery方法返回的函数来完成的,如下所示:

...
return View(string.IsNullOrEmpty(searchTerm )
    ? context.Employees : query(context, searchTerm));
...

执行编译查询的方式没有明显的差别,但在幕后,Entity Framework Core 可以跳过创建查询的哈希表示的过程,并检查它是否以前已经翻译过。


避免过度查询陷阱

注意,我在显式编译查询之外检查Index action 方法的searchTerm参数是否为null。在定义查询表达式时,常见的错误是包含要在应用程序中执行的检查,如下所示:

...
EF.CompileQuery((AdvancedContext context, string searchTerm)
    => context.Employees.Where(e => string.IsNullOrEmpty(searchTerm)
        || EF.Functions.Like(e.FirstName, searchTerm)));
...

问题是 Entity Framework Core 将对null值的检查合并到 SQL 查询中,如下所示,这可能不是您想要的:

...
SELECT [e].[SSN], [e].[FirstName], [e].[FamilyName], [e].[Salary],
    [e].[SoftDeleted]
FROM [Employees] AS [e]
WHERE ([e].[SoftDeleted] = 0) AND ((@__searchTerm IS NULL
    OR (@__searchTerm = N'')) OR [e].[FirstName] LIKE @__searchTerm)
...

这是与我在下一节中描述的客户端评估陷阱相反的问题,但这两个问题都强调了检查 Entity Framework 生成的 SQL 查询的重要性,以确保它们精确地针对您想要的数据。


避免客户端评估陷阱

当您开始使用 Entity Framework Core 时,您可能需要一段时间才能确信 LINQ 查询将被转换为所需的 SQL。正如这本书所演示的,有许多潜在的陷阱可能导致太多的数据或太少的数据被从数据库检索到。有一个潜在的错误非常常见,以至于 Entity Framework Core 在将查询转换为 SQL 时会警告您。

当 Entity Framework Core 无法查看 LINQ 查询的所有细节并无法将其完全转换为 SQL 时,就会出现此问题。这通常发生在对查询进行重构时,以便在整个应用程序中更一致地使用选择一组数据对象的代码。Entity Framework Core 将查询拆分为由数据库服务器执行查询的部分和由客户端应用程序执行的查询。这不仅增加了应用程序必须执行的处理量,而且可能极大地增加查询从数据库检索的数据量。

为了演示,我在 Controllers 文件夹中添加了一个名为 QueryController.cs 的类文件,并使用它定义了如清单20-19所示的控制器。

清单 20-19:Controllers 文件夹下的 QueryController.cs 文件的内容

using AdvancedApp.Models;
using Microsoft.AspNetCore.Mvc;
using System.Linq;

namespace AdvancedApp.Controllers
{
    public class QueryController : Controller
    {
        private AdvancedContext context;
        public QueryController(AdvancedContext ctx) => context = ctx;

        public IActionResult ServerEval()
        {
            return View("Query", context.Employees.Where(e => e.Salary > 150_000));
        }

        public IActionResult ClientEval()
        {
            return View("Query", context.Employees.Where(e => IsHighEarner(e)));
        }

        private bool IsHighEarner(Employee e)
        {
            return e.Salary > 150_000;
        }
    }
}

控制器定义两个 action,用于查询薪资值大于150,000Employee对象。ServerEval方法将过滤器表达式直接放在 LINQ 表达式的Where子句中,而ClientEval方法使用一个单独的方法,该方法表示一个典型的重构过程,它允许将选择高收入者的标准放到单独方法中。

为了向这两个 action 提供一个视图,我创建了 Views/Query 文件夹,并向其添加了一个名为 Query.cshtml 的文件,其包含清单20-20所示内容。

清单 20-20:Views/Query 文件夹下的 Query.cshtml 文件的内容

@model IEnumerable<Employee>
@{
    ViewData["Title"] = "Advanced Features";
    Layout = "_Layout";
}
<h3 class="bg-info p-2 text-center text-white">Employees</h3>
<table class="table table-sm table-striped">
    <thead>
        <tr>
            <th>SSN</th>
            <th>First Name</th>
            <th>Family Name</th>
            <th>Salary</th>
        </tr>
    </thead>
    <tbody>
        <tr class="placeholder"><td colspan="4" class="text-center">No Data</td></tr>
        @foreach (Employee e in Model)
        {
            <tr>
                <td>@e.SSN</td>
                <td>@e.FirstName</td>
                <td>@e.FamilyName</td>
                <td>@e.Salary</td>
            </tr>
        }
    </tbody>
</table>

要了解查询执行方式的不同,请使用dotnet run启动应用程序,运行并导航到 http://localhost:5000/query/servereval 和 http://localhost:5000/query/clienteval,这两种方法都将产生图20-7所示的结果。

图20-7 查询结果

要理解这两个 action 方法之间的区别,必须检查发送到数据库服务器的查询。ServerEval action 的结果是这个查询:

...
SELECT [e].[SSN], [e].[FirstName], [e].[FamilyName], [e].[Salary], [e].[SoftDeleted]
FROM [Employees] AS [e]
WHERE ([e].[SoftDeleted] = 0) AND ([e].[Salary] > 150000.0)
...

此查询中的WHERE子句将只检索Salary值超过150,000Employee对象。这意味着只有随后将显示给用户的对象才会从数据库中检索。

相反,这里是由ClientEval action 生成的查询:

...
SELECT [e].[SSN], [e].[FirstName], [e].[FamilyName], [e].[Salary], [e].[SoftDeleted]
FROM [Employees] AS [e]
WHERE [e].[SoftDeleted] = 0
..

Entity Framework Core 无法查看控制器的IsHighEarner方法,并将它包含的逻辑合并到 SQL 查询中。相反,Entity Framework Core 将它可以看到的查询部分转换为 SQL,然后通过IsHighEarner方法传递它接收的对象,以生成查询结果。没有由IsHighEarner方法选择的对象被丢弃,其结果是从数据库读取更多的数据,而应用程序需要更多的工作来生成所需的结果。在示例应用程序中,这意味着读取和创建一个额外的对象,但在实际应用程序中,差异可能很大。

抛出客户端评估异常

当必须在客户端中计算查询的一部分时,Entity Framework Core 将在日志记录输出中显示警告消息,如下所示:

The LINQ expression 'where value(AdvancedApp.Controllers.QueryController)
.IsHighEarner([e])' could not be translated and will be evaluated locally

在日志记录消息流中,这很容易被忽略,您可能会发现,客户端对查询的评估传递时不被注意,直到在生产应用程序中出现性能问题。在开发期间,当在客户端中计算查询的一部分时,接收异常可能很有用,这将使问题更加明显。在清单20-21中,我更改了 Entity Framework Core 配置,以便抛出异常。

清单 20-21:AdvancedApp 文件夹下的 Startup.cs 文件,配置异常

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.EntityFrameworkCore;
using AdvancedApp.Models;
using Microsoft.EntityFrameworkCore.Diagnostics;

namespace AdvancedApp
{
    public class Startup
    {
        public Startup(IConfiguration config) => Configuration = config;
        public IConfiguration Configuration { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddMvc();
            string conString = Configuration["ConnectionStrings:DefaultConnection"];
            services.AddDbContext<AdvancedContext>(options =>
                options.UseSqlServer(conString).ConfigureWarnings(warning =>
                    warning.Throw(RelationalEventId.QueryClientEvaluationWarning)));
        }

        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            app.UseDeveloperExceptionPage();
            app.UseStatusCodePages();
            app.UseStaticFiles();
            app.UseMvcWithDefaultRoute();
        }
    }
}

ConfigureWarnings方法用于使用 lambda 表达式配置 Entity Framework Core 产生的警告,该表达式接收定义表20-8中方法的WarningsConfigurationsBuilder对象。

表 20-8:WarningsConfigurationBuilder 方法

名称 描述
Ignore(event) 此方法告诉 Entity Framework Core 忽略指定事件
Log(enent) 此方法告诉 Entity Framework Core 记录指定事件
Throw(event) 此方法告诉 Entity Framework Core 对指定事件抛出异常

表20-8中的方法与RelationalEventId枚举一起使用,它定义了表示可能在 Entity Framework Core 应用程序中遇到的诊断事件的值。几乎有 30 个不同事件的值,尽管其中大多数与数据库连接和事务的生命周期以及迁移的创建和应用有关。您可以在 https://docs.microsoft.com/en-us/ef/core/api/microsoft.entityframeworkcore.infrastructure.relationaleventid 查看完整的事件清单。我在清单20-20中使用的值是QueryClientEvaluationWarning,它表示当请求的一部分由客户端计算时,由 Entity Framework Core 触发的事件。要查看更改的效果,请使用dotnet run启动应用程序,然后导航到 http://localhost:5000/query/clienteval。您将看到图20-8中的异常,而不是容易忽略的警告。

图20-8 客户端查询计算异常

总结

在本章中,我描述了 Entity Framework Core 为查询数据提供的高级功能。我解释了如何控制更改跟踪功能,如何使用查询过滤器,如何使用 SQL 的LIKE的功能执行查询,以及如何显式编译查询。在结束本章时,我演示了重构查询所引起的一个常见问题,以及如何配置应用程序,以便您知道何时会在自己的项目中遇到此问题。在下一章中,我将描述存储数据的高级功能。

;

© 2018 - IOT小分队文章发布系统 v0.3