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

第 12 章 执行数据操作

作者:Adam Freeman
翻译:陈广
日期:2019-1-10


示例应用程序中的数据模型太过简单,无法表现真实项目,但它让我很容易地解释如何使用 Entity Framework Core 执行四个核心操作(创建、读取、更新和删除数据)。在后面的章节中,我将向您展示如何创建更复杂的数据模型,但是,正如您将在本章中看到的那样,即使是一个简单的模型也可以揭示 Entity Framework Core 是如何工作的。表12-1为基本数据操作简述。

表 12-1:基础数据操作简述

问题 回答
它们是什么? 基础数据操作从一个数据库中读取、存储、更新和删除数据
它们有何用途? 这些操作是使用 Entity Framework Core 的基本构建块。
如何使用它们 数据库 context 类定义了一个属性,该属性提供用于存储、更新和删除数据的方法,这些方法可以执行 LINQ 查询从数据库读取数据。
是否有任何缺陷或限制? 这些操作可能效率低下,需要数据库服务器执行更多的工作,除非采取步骤使用更改检测等功能。
有没有其他选择? 如果您正在使用 Entity Framework Core,那么这些特性是处理数据的基础。

表 12-2:本章摘要

问题 解决方案 清单
从数据库获取单个对象 使用由DbSet<T>对象定义的Find方法返回 context 类 5
从数据库获取给定类型的所有对象 枚举由 context 类属性返回的DbSet<T>对象 6
获取对象的子集 使用 LINQ 表示选择所需对象的约束 7-10
向数据库添加一个对象 将对象传递给DbSet<T>.Add方法并调用SaveChanges 11-12
在数据库中更新对象 将对象传递给DbSet<T>.Update方法并调用SaveChanges 13
使用更改检测最小化更新 查询数据库以获取基线对象,更改它的属性值,并调用SaveChanges
在不查询基线数据的情况下使用更改检测 将对象的原始状态包含在 HTTP 请求中,并使用它提供基线。 16-19
从数据库移除对象 将对象传递给DbSet<T>.Remove方法并调用SaveChanges 21

准备本章

本章使用第11章创建的 DataApp 项目。要准备本章,在 DataApp 项目文件夹下打开一个命令提示符或 PowerShell 窗口,并运行清单12-1所示的命令。

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

清单 12-1:重置数据库

dotnet ef database drop --force

清单 12-2:准备数据库

dotnet ef database update

要为数据库添加种子数据,选择【工具】➤【SQL Server】➤【New Query】,并在【服务器名称】字段输入(localdb)\MSSQLLocalDB`。确保【身份验证】字段选中【Windwos 身份验证】,单击【数据库名称】菜单,将显示一个完整的通过使用 LocalDB 创建的数据库清单,在下拉列表中选择【DataAppDb】。单击【连接】以打开一个到数据库的连接。

提示:【历史记录】选项卡跟踪使用 Visual Studio 连接到的数据库,并允许您重新连接到数据库,而无需再次输入连接详细信息。

在编辑器中输入清单12-3所示的 SQL。在 Visual Studio 【SQL】菜单中选择【Execute】。Visual Studio 将要求数据库服务器执行 SQL,这将确保示例应用程序有使用的数据。

清单 12-3:数据库播种

USE DataAppDb
INSERT INTO Products (Name, Category, Price)
VALUES
	('Kayak', 'Watersports', 275),
	('Lifejacket', 'Watersports', 48.95),
	('Soccer Ball', 'Soccer', 19.50),
	('Corner Flags', 'Soccer', 34.95),
	('Stadium', 'Soccer', 79500),
	('Thinking Cap', 'Chess', 16),
	('Unsteady Chair', 'Chess', 29.95),
	('Human Chess Board', 'Chess', 75),
	('Bling-Bling King', 'Chess', 1200)

执行 SQL 后,您将看到以下结果,这表明向数据库中添加了9行数据:

(9 row(s) affected)

启动示例应用程序

在 DataApp 文件夹下通过运行清单12-4所示的命令启动应用程序。

清单 12-4:启动示例应用程序

dotnet run

打开浏览器窗口,并导航至 http://localhost:5000。 ASP.NET Core MVC 应用程序将使用 Entity Framework Core 从数据库检索数据 ,并生成如图12-1所示的响应。

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

读取数据

要理解 Entity Framework Core 是如何工作的,最好的方法是查询数据库并检索其中包含的数据。在接下来的部分中,我将解释如何查询单个实体对象、所有对象以及部分对象。

通过键值读取对象

本章中使用的数据操作的关键是DbSet<T>类,它用作数据库 context 类定义的属性的结果。以下是在第11章中定义的EFDatabaseContext类的Products属性的定义:

public DbSet<Product> Products { get; set; }

像这样的属性有两个角色。第一个角色是告诉 Entity Framework Core,Product类是一个实体类,这意味着Product对象将存储在数据库中。当 Entity Framework Core 创建迁移时,这是非常重要的信息,因为它会影响为将数据存储在数据库中而必须创建的表和行。

第二个角色是提供一个属性,允许在数据库上执行操作,这意味着可以使用 Entity Framework Core 创建、读取、更新和删除Product对象。DbSet<T>类实现接口并定义使这些操作成为可能的方法。这些方法中的第一个也是最基本的称为Find,表12-3对其进行了描述,以供快速参考。

表 12-3:按键值查询的DbSet<T>方法

名称 描述
Find(key) 此方法读取表中拥有指定键值的行,并返回表示它的对象。如果不存在指定键值的行,则返回null。如19章描述,如果表需要多个键值可以指定多个参数:Find(key1, key2, key3)

当您拥有一个键值并希望获取与之关联的对象,Find方法是有用的。在示例应用程序中,这意味着您拥有分配给存储在数据库中的Product对象的Id属性值,并且希望检索完整的Product对象。

这就是存储库中GetProduct方法的用途,表示Find方法可以用于实现该方法,如清单12-5所示。

清单 12-5:Models 文件夹下的 EFDataRepository.cs 文件,通过键值查询

using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;

namespace DataApp.Models
{
    public class EFDataRepository : IDataRepository
    {
        private EFDatabaseContext context;

        public EFDataRepository(EFDatabaseContext ctx)
        {
            context = ctx;
        }

        public Product GetProduct(long id)
        {
            return context.Products.Find(id);
        }
        public IEnumerable<Product> GetAllProducts()
        {
            Console.WriteLine("GetAllProducts");
            return context.Products;
        }
        public void CreateProduct(Product newProduct)
        {
            Console.WriteLine("CreateProduct: "
                + JsonConvert.SerializeObject(newProduct));
        }
        public void UpdateProduct(Product changedProduct)
        {
            Console.WriteLine("UpdateProduct : "
                + JsonConvert.SerializeObject(changedProduct));
        }
        public void DeleteProduct(long id)
        {
            Console.WriteLine("DeleteProduct: " + id);
        }
    }
}

当用户在应用程序的 MVC 部分启动编辑过程时,GetProduct方法用于提供Product对象的当前属性值。使用dotnet run启动应用程序,使用浏览器导航至 http://localhost:5000,并单击其中一项的【Edit】按钮。显示的表单将使用从Find方法创建的Product对象中获取的数据填充,如图12-2所示。

图12-2 查询单个对象

如果在命令提示符上检查应用程序的日志输出,您将看到发送到数据库的 SQL 查询。

SELECT TOP(1) [e].[Id], [e].[Category], [e].[Name], [e].[Price]
FROM [Products] AS [e]
WHERE [e].[Id] = @__get_Item_0

该查询为表中Id列具有指定值的表中的第一行检索Product类定义的每个属性的值。这些值由Find方法用于创建它返回的Product对象。然后将该对象传递给应用程序的 MVC 部分,以便将其显示给用户。

查询所有对象

要检索存储在数据库中的所有数据对象,您可以读取 context 类的Products属性的值,该属性返回DbSet<Product>对象。DbSet<T>类实现了IQueryable<T>IEnumerable<T>接口,这意味着您可以使用foreach循环枚举从数据库读取的Product对象序列。

存储库类已经包括了通过我在第11章中定义的GetAllProducts方法读取所有数据对象的支持,如下所示:

public IEnumerable<Product> GetAllProducts() {
	return context.Products;
}

Entity Framework Core 在枚举DbSet<T>属性之前不会从数据库读取数据,这可能会引起混乱。为了演示 Entity Framework Core 如何延迟读取数据,更新 Home 控制器上的Index action,如清单12-6所示。

清单 12-6:Controllers 文件夹下的 HomeController.cs 文件,查询所有对象

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

namespace DataApp.Controllers
{
    public class HomeController : Controller
    {
        private IDataRepository repository;

        public HomeController(IDataRepository repo)
        {
            repository = repo;
        }

        public IActionResult Index()
        {
            var products = repository.GetAllProducts();
            System.Console.WriteLine("Property value has been read");
            return View(products);
        }
        public IActionResult Create()
        {
            ViewBag.CreateMode = true;
            return View("Editor", new Product());
        }
        
		// ...其它省略...
    }
}

清单12-6中的System.Console.WriteLine语句在读取Products属性时以及在将值传递给View方法之前输出一条消息。

使用dotnet run重启应用程序,并请求 http://localhost:5000。检查应用程序生成的日志输出,您将看到清单12-6中的语句出现在 SQL 查询发送到数据库之前,如下所示:

...
Property value has been read
...
info: Microsoft.EntityFrameworkCore.Database.Sql[1]
    Executed DbCommand (6ms) [Parameters=[], CommandType='Text',
        CommandTimeout='30']
    SELECT [p].[Id], [p].[Category], [p].[Name], [p].[Price]
    FROM [Products] AS [p]
...

只有在对序列执行操作时,例如使用foreach循环的枚举,或者当使用 LINQ 将序列转换为数组或列表时(使用ToArrayToList方法),才会从数据库检索数据。

查询特定对象

context 对象定义的DbSet<T>属性还允许使用 LINQ to Entities 功能创建更复杂的查询。DbSet<T>类实现了IQueryable<T>接口(在11章进行了解释),它用于创建从数据库中选择对象的查询。这允许使用 LINQ 查询和处理数据,但好处是查询由数据库服务器执行,以便只从数据库读取匹配的数据对象。

当使用DBSet<T>属性时,在枚举对象序列之前不会将查询发送到数据库。这意味着可以通过跨多个代码语句将多个 LINQ 方法链接起来构建查询,这非常适合于 MVC 应用程序模型。

作为演示,编辑 Index.cshtml 视图以添加一个 HTML 表单,该表单将允许用户筛选表中显示的产品列表,如清单12-7所示。

清单 12-7:Views/Home 文件夹下的 Index.cshtml 文件,添加筛选

@model IEnumerable<Product>
@{
    ViewData["Title"] = "Products";
    Layout = "_Layout";
}

<div class="m-1 p-2">
    <form asp-action="Index" method="get" class="form-inline">
        <label class="m-1">Category:</label>
        <select name="category" class="form-control">
            <option value="">All</option>
            <option selected="@(ViewBag.category == "Watersports")">
                Watersports
            </option>
            <option selected="@(ViewBag.category == "Soccer")">Soccer</option>
            <option selected="@(ViewBag.category == "Chess")">Chess</option>
        </select>
        <label class="m-1">Min Price:</label>
        <input class="form-control" name="price" value="@ViewBag.price" />
        <button class="btn btn-primary m-1">Filter</button>
    </form>
</div>

<table class="table table-sm table-striped">
    <thead>
        <tr><th>ID</th><th>Name</th><th>Category</th><th>Price</th></tr>
    </thead>
    <tbody>
        @foreach (var p in Model)
        {
            <tr>
                <td>@p.Id</td>
                <td>@p.Name</td>
                <td>@p.Category</td>
                <td>$@p.Price.ToString("F2")</td>
                <td>
                    <form asp-action="Delete" method="post">
                        <a asp-action="Edit"
                           class="btn btn-sm btn-warning" asp-route-id="@p.Id">
                            Edit
                        </a>
                        <input type="hidden" name="id" value="@p.Id" />
                        <button type="submit" class="btn btn-danger btn-sm">
                            Delete
                        </button>
                    </form>
                </td>
            </tr>
        }
    </tbody>
</table>
<a asp-action="Create" class="btn btn-primary">Create New Product</a>

新元素向用户提供选择类别的select元素和指定最低价格的input元素。当单击【Filter】按钮时,这些元素的值将包含在发送给应用程序的 GET 请求中。

要接收应用程序中的筛选条件,请编辑 Home 控制器上的Index操作,如清单12-8所示。

清单 12-8:Controllers 文件夹下的 HomeController.cs 文件,接收筛选条件

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

namespace DataApp.Controllers
{
    public class HomeController : Controller
    {
        private IDataRepository repository;

        public HomeController(IDataRepository repo)
        {
            repository = repo;
        }

        public IActionResult Index(string category = null, decimal? price = null)
        {
            var products = repository.GetFilteredProducts(category, price);
            ViewBag.category = category;
            ViewBag.price = price;
            return View(products);
        }
        public IActionResult Create()
        {
            ViewBag.CreateMode = true;
            return View("Editor", new Product());
        }
        
        // ...其它省略...
    }
}

Index action 方法定义了两个可选参数,它们被传递给一个名为GetFilteredProducts的存储库方法。要创建Index action 使用的GetFilteredProducts方法,请扩展存储库接口,如清单12-9所示。

提示:对于清单12-8中的price参数,我使用了一个可空的decimal值来区分用户输入的是空(在这种情况下,参数为null)还是0(在这种情况下,参数将为0)。对于这两种情况,使用常规的decimal参数都会得到零值。

清单 12-9:Models 文件夹下的 IDataRepository.cs 文件,添加方法

using System.Collections.Generic;
using System.Linq;

namespace DataApp.Models
{
    public interface IDataRepository
    {
        Product GetProduct(long id);
        IEnumerable<Product> GetAllProducts();
        IEnumerable<Product> GetFilteredProducts(string category = null,
            decimal? price = null);
        void CreateProduct(Product newProduct);
        void UpdateProduct(Product changedProduct);
        void DeleteProduct(long id);
    }
}

最后一步是实现基于用户提供的值构建 LINQ 查询的方法。编辑存储库类以实现GetFilteredProducts方法,如清单12-10所示。

清单 12-10:Models 文件夹下的 EFDataRepository.cs 文件,创建一个查询

using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;

namespace DataApp.Models
{
    public class EFDataRepository : IDataRepository
    {
        private EFDatabaseContext context;

        public EFDataRepository(EFDatabaseContext ctx)
        {
            context = ctx;
        }

        public Product GetProduct(long id)
        {
            return context.Products.Find(id);
        }
        public IEnumerable<Product> GetAllProducts()
        {
            Console.WriteLine("GetAllProducts");
            return context.Products;
        }
        public IEnumerable<Product> GetFilteredProducts(string category = null,
            decimal? price = null)
        {
            IQueryable<Product> data = context.Products;
            if (category != null)
            {
                data = data.Where(p => p.Category == category);
            }
            if (price != null)
            {
                data = data.Where(p => p.Price >= price);
            }
            return data;
        }
        
        //...其它省略...
    }
}

该方法的实现首先读取 context 的Product属性值,并将其分配给一个IQueryable<Product>变量。正如在第11章中所解释的,使用IQueryable<Product>接口可以确保数据筛选是在数据库中完成的,而不是加载所有对象,然后对它们进行筛选。

该查询是基于是否已收到categoryprice参数的值而生成的。如果存在参数值,则使用 LINQ Where方法更新IQuerable<Product>变量的值。在视图中枚举IQueryable<T>对象之前不会查询数据库,这就是如何使用 LINQ 方法在多行代码中选择性地组合查询。

要查看更改的效果,请重新启动应用程序,请求 http://localhost:5000,并使用图12-3所示的表单字段来筛选应用程序显示的数据。

图12-3 在示例应用程序中筛选数据

可以生成四种可能的查询类型。

  • 当所有参数为空时,查询所有对象
  • 在未指定最低价格时,查询指定类别的对象
  • 查询所有类别的大于最低价格的对象
  • 查询指定类别以及大于最低价格的对象

通过选择类别值并在价格字段中输入数字,您可以看到将发送到数据库服务器的每个查询。例如,如果选择一个类别,但不输入最低价格,则将发送以下查询:

SELECT [p].[Id], [p].[Category], [p].[Name], [p].[Price]
FROM [Products] AS [p]
WHERE [p].[Category] = @__category_0

这是在选择了类别和最低价格时使用的查询:

SELECT [p].[Id], [p].[Category], [p].[Name], [p].[Price]
FROM [Products] AS [p]
WHERE ([p].[Category] = @__category_0) AND ([p].[Price] >= @__price_1)

关键是数据的选择由发送到数据库的查询执行,确保只将匹配的数据返回给应用程序。

存储新数据

下一步是添加在数据库中存储新对象的功能。编辑存储库类以实现CreateProduct方法,如清单12-11所示。

清单 12-11:Models 文件夹下的 EFDataRepository.cs 文件,存储数据

...
public void CreateProduct(Product newProduct)
{
    newProduct.Id = 0;
    context.Products.Add(newProduct);
    context.SaveChanges();
}
...

示例应用程序中有两个Product对象来源:MVC 模型绑定过程和数据库 context 对象。模型绑定过程在接收到 HTTP POST 请求时创建Product对象,context 对象在从数据库读取数据时创建Product对象。

Entity Framework Core 负责由数据库 context 对象创建的Product对象,但没有 MVC 模型绑定器创建的Product对象的可见性。在 context 的DbSet<T>属性上调用的Add方法使 Entity Framework Core 知道在应用程序的其他位置创建的Product对象,以便可以将其写入数据库。

提示DbSet<T>属性还定义了一个AddRange方法,可用于在单个方法调用中存储多个对象,如第13章所述。

SaveChanges方法保存了由 Entity Framework Core 管理的Product对象到数据库的未完成的更改。这包括传递给Add方法的任何Product对象。清单12-11中的代码的作用是使 Entity Framework Core 感知到Product对象并将其存储在数据库中。

要查看效果,请使用dotnet run重新启动应用程序,使用浏览器导航到 http://locahost:5000,并单击【Create】按钮。填写表单字段并单击【Save】按钮。

当 Home 控制器上的Create action 方法被调用去处理 HTTP POST 请求,它接收一个由模型绑定器创建的Product对象作为其参数。action 方法调用存储库上的CreateProduct方法,这让 Entity Framework Core 通过Add方法感知到Product对象,并使用SaveChanges方法将其存储进数据库。新数据将显示在产品表中,如图12-4所示。

图12-4 存储新数据

理解关键字分配

注意,CreateProduct方法显式地将Product对象的Id属性值设置为0:

newProduct.Id = 0;

在创建表中的新行时,数据库服务器将为新对象分配主键值,如果Id属性的值不是0,则会引发异常。在清单12-11中,我显式地将Id属性设置为0,以确保不使用从 HTTP 请求接收的值。

注意:我本可以将 MVC 模型绑定配置为忽略 HTTP 请求中Id属性的值,但这假定了模型绑定是传递给CreateProduct方法的Product对象的唯一来源。我希望确保应用程序的 Entity Framework Core 部分是健壮的,这就是为什么我在清单12-11中显式地将属性设置为零的原因。

当 Entity Framework Core 存储一个新对象,它立即执行 SQL 查询,以发现数据库服务器分配给新表行的Id列的值。在创建新产品时记录到命令提示符的 SQL 语句中可以看到这一点,如下所示:

INSERT INTO [Products] ([Category], [Name], [Price])
VALUES (@p0, @p1, @p2);
SELECT [Id]
FROM [Products]
WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();

INSERT语句告诉数据库服务器在 Products 表中创建一个新行,并为CategoryNamePrice列提供值。SELECT语句查询用于创建新行的Id列的值。返回的值用于更新Product对象,确保该对象与数据库中的表示形式一致。

您可以通过在SaveChanges方法之后读取Id属性的值来了解它是如何工作的,如清单12-12所示。

清单 12-12:Models 文件夹下的 EFDataRepository.cs 文件,决定主键值

public void CreateProduct(Product newProduct) {
    newProduct.Id = 0;
    context.Products.Add(newProduct);
    context.SaveChanges();
    Console.WriteLine($"New Key: {newProduct.Id}");
}

使用dotnet run重新启动应用程序,运行并重复创建新数据项的过程。检查应用程序的输出,您将看到有一条消息显示分配给新产品的键值,如下所示:

New Key: 11

键值也显示在浏览器显示的产品列表中。

更新数据

修改现有数据的过程类似于存储新数据,但需要做一些工作才能使其高效工作。有三种不同的方法来处理更新,在下面的部分中将对此进行描述。

更新完整对象

执行更新的最简单方法是将 MVC 模型绑定过程创建的Product对象置于 Entity Framework Core 的管理下,类似于存储新数据的过程。要以这种方式添加对更新数据的支持,请在EFDataRepository类中编辑UpdateProduct方法,如清单12-13所示。

清单 12-13:Models 文件夹下的 EFDataRepository.cs 文件,更新数据

public void UpdateProduct(Product changedProduct)
{
    context.Products.Update(changedProduct);
    context.SaveChanges();
}

DbSet<T>.Update方法用于让 Entity Framework Core 知道一个Product对象已经被更改,context 的SaveChanges方法将对象写入数据库。

提示DbSet<T>属性还定义了一个UpdateRange方法,可用于在单个方法调用中更新多个对象,如15章所述。

要查看效果,使用dotnet run命令重启应用程序,单击【Stadium】产品的【Edit】按钮,将名称字段值改为【Stadium (Large)】。单击【Save】按钮;数据库将更新,当浏览器重定向至Index action 后,产品列表将反映更改。

图12-5 更新对象

这种方法的优点是简单:处理更新只需要两行代码。缺点是,Entity Framework Core 知道Product对象已经更改,但没有足够的信息来确定只有一个属性已经更改。这意味着所有Product对象的属性的值必须存储在数据库中,在数据库中可以看到所有三个属性都被更新。

UPDATE [Products] SET [Category] = @p0, [Name] = @p1, [Price] = @p2
WHERE [Id] = @p3;

只更改了一个属性,但所有三个属性都在数据库中更新。对于简单对象,编写未更改的属性不是一个重要的问题。对于具有复杂数据模型的实际项目,这种方法可能效率低下。

提示:如果检查应用程序的控制台输出,您将看到UPDATE语句是发送到数据库服务器的三个语句的中间那个。首先是SET NOCOUNT ON,它禁用报告查询影响多少行的功能,以提高性能。接下来,UPDATE语句更新表中修改的行。第三个语句是SELECT @@ROWCOUNT,它报告有多少行受到更新的影响。Entity Framework Core 始终检查预期的行数是否已更改。

在更新前查询现有数据

Entity Framework Core 能够精确地计算出对象中的哪些属性已被修改,这可用于避免将未更改的数据写入数据库。要了解 Entity Framework Core 是如何处理更改检测的,请修改存储库中的updateProductMethod方法,如清单12-14所示。

清单 12-14:Models 文件夹下的 EFDataRepository.cs 文件,使用更改检测

public void UpdateProduct(Product changedProduct)
{
    Product originalProduct = context.Products.Find(changedProduct.Id);
    originalProduct.Name = changedProduct.Name;
    originalProduct.Category = changedProduct.Category;
    originalProduct.Price = changedProduct.Price;
    context.SaveChanges();
}

本例中有两个Product对象。changedProduct对象作为 action 方法参数接收,并由 MVC 模型绑定器使用 HTTP POST 请求数据创建。originalProduct对象由 Entity Framework Core 创建,作为Find方法的结果,并表示当前数据库中的数据。

Entity Framework Core 使用数据库中的数据跟踪它创建的对象,并在属性值发生变化时计算出来。为了利用这个特性,我将changedProduct对象的属性值赋值给originalProduct对象,然后调用SaveChanges方法。Entity Framework Core 将检查originalProduct对象的属性值,以确定自创建对象以来它们是否已经更改,并且只更新不同的属性值。

重新启动应用程序,再次单击【Stadium】产品的【Edit】按钮,并将【Name】字段从【Stadium (Large)】更改为【Stadium (Small)】。单击【Save】按钮时,日志消息将显示只更新已更改属性的 SQL 语句,如下所示:

UPDATE [Products] SET [Name] = @p0
WHERE [Id] = @p1;

更改检测过程标识了Name属性已被更改,发送到数据库服务器的语句仅对该属性执行更新。

理解更改检测

数据库 context 的基类DbContext定义了返回EntityEntry对象的Entry方法。Entity Framework Core 使用此对象来检测其创建的对象中的更改。表12-4描述了最有用的EntityEntry属性。

表 12-4:有用的 EntityEntry 属性

名称 描述
state 此属性返回一个EntityState枚举值,指示对象的状态。它的值有:AddedDeletedDetachedModifiedUnchanged
OriginalValues 此属性返回原始属性值集合,由属性名称索引。
CurrentValues 此属性返回当前属性值集合,由属性名索引。

这些属性可用于检查实体对象的状态,从而揭示更改跟踪过程的工作方式。清单12-15更改了存储库的UpdateProduct方法,以便使用EntityEntry对象写出更改跟踪信息。

清单 12-15:Models 文件夹下的 EFDataRepository.cs 文件,检查跟踪细节

using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using Microsoft.EntityFrameworkCore.ChangeTracking;

namespace DataApp.Models
{
    public class EFDataRepository : IDataRepository
    {
        private EFDatabaseContext context;

        public EFDataRepository(EFDatabaseContext ctx)
        {
            context = ctx;
        }

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

        public void UpdateProduct(Product changedProduct)
        {
            Product originalProduct = context.Products.Find(changedProduct.Id);
            originalProduct.Name = changedProduct.Name;
            originalProduct.Category = changedProduct.Category;
            originalProduct.Price = changedProduct.Price;
            EntityEntry entry = context.Entry(originalProduct);
            Console.WriteLine($"Entity State: {entry.State}");
            foreach (string p_name in new string[]
                { "Name", "Category", "Price" })
            {
                Console.WriteLine($"{p_name} - Old: " +
                    $"{entry.OriginalValues[p_name]}, " +
                    $"New: {entry.CurrentValues[p_name]}");
            }
            context.SaveChanges();
        }
        public void DeleteProduct(long id)
        {
            Console.WriteLine("DeleteProduct: " + id);
        }
    }
}

UpdateProduct方法的更改获取由Find方法创建的Product对象的EntityEntry,并写出State属性的值以及NameCategoryPrice属性的当前和原始值。

若要查看跟踪信息,请使用dotnet run启动应用程序,运行并编辑表中所示的产品之一。单击【Save】按钮时,浏览器发送给应用程序的数据将应用到实体对象,跟踪数据将写入命令提示符。

Entity State: Modified
Name - Old: Kayak, New: Green Kayak
Category - Old: Watersports, New: Watersports
Price - Old: 275.00, New: 275.00

这些跟踪数据显示,我编辑了Kayak产品的Name属性,将其更改为Green KayakState属性返回Modified值,Entity Framework Core 只会将值已更改的属性写入数据库,从而避免更新未更改的值。

在单个数据库操作中进行更新

前面的示例避免将未更改的属性写入数据库,但它通过查询数据库中的当前值来实现这一点,但这是将一种低效率转换为另一种。此时,您可能会想知道用于填充 HTML 表单字段的产品对象发生了什么变化,为什么不能将其用于更改检测?


选择更新策略

如果您的数据模型相对简单,实体对象几乎没有属性,那么您应该使用最简单的更新策略,并将收到的对象写入数据库,如清单12-13所示,即使这意味着值是在未更改时写入的。这是一种效率低下的方法,但效率低下的程度将很小。

对于更复杂的数据模型,决策将取决于数据库服务器容量和网络流量的相对成本。如果您的主要开销是网络流量,那么您应该执行一个额外的数据库读取操作,如清单12-14所示。这将增加对数据库的需求,但将减少通过网络向浏览器发送和从浏览器发送的数据量。如果您的主要开销是提供数据库服务器,那么您应该将原始数据值包含在 HTML 表单中,如清单12-17所示。这种方法避免了数据库操作,但通过将表单中包含的数据值加倍来做到这一点,并且可以要求第21章中描述的特性来检测基准数据何时已被另一用户更改。


应用程序接收到的每个 HTTP 请求都由 Home 控制器的一个新实例处理,每个控制器对象得到一个新的存储库对象和一个新的数据库 context 对象。当 HTTP 请求被处理时,控制器、存储库和数据库对象以及从数据库中检索的任何Product对象都会被丢弃。这意味着每个请求都必须从数据库中检索它所需的数据,即使来自同一个客户端的早期请求执行相同的查询时也是如此。

但是,执行更新的第三种策略是利用原始的读取操作,将它获得的数据包含在发送给客户端的响应中,并使用它来避免更新未更改的值。

首先,编辑 Editor.cshtml 文件以添加清单12-16所示的隐藏input元素。

清单 12-6:Views/Shared 文件夹下的 Editor.cshtml 文件,添加元素

@model DataApp.Models.Product
@{
    ViewData["Title"] = ViewBag.CreateMode ? "Create" : "Edit";
    Layout = "_Layout";
}

<form asp-action="@(ViewBag.CreateMode ? "Create" : "Edit")" method="post">
    <input name="original.Id" value="@Model?.Id" type="hidden" />
    <input name="original.Name" value="@Model?.Name" type="hidden" />
    <input name="original.Category" value="@Model?.Category" type="hidden" />
    <input name="original.Price" value="@Model?.Price" type="hidden" />
    <div class="form-group">
        <label asp-for="Name"></label>
        <input asp-for="Name" class="form-control" />
    </div>
    <div class="form-group">
        <label asp-for="Category"></label>
        <input asp-for="Category" class="form-control" />
    </div>
    <div class="form-group">
        <label asp-for="Price"></label>
        <input asp-for="Price" class="form-control" />
    </div>
    <div class="text-center">
        <button class="btn btn-primary" type="submit">Save</button>
        <a asp-action="Index" class="btn btn-secondary">Cancel</a>
    </div>
</form>

这些元素的name属性以original为前缀,后面跟着句点,它告诉 MVC 模型绑定器,这些元素应该用作名称为original的 action 方法参数的属性。这些元素将在浏览器发送 POST 请求时提供原始数据值。

要接收原始数据,编辑 Home 控制器并向Edit方法添加一个参数,如清单12-17所示。该参数的名称是original,对应于清单12-16中的input元素,MVC 模型绑定器将使用来自这些input元素的值创建一个Product对象,为应用程序提供了对原始值的轻松访问。

清单 12-17:Controllers 文件夹下的 HomeController.cs 文件,绑定原始值

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

namespace DataApp.Controllers
{
    public class HomeController : Controller
    {
        private IDataRepository repository;

        public HomeController(IDataRepository repo)
        {
            repository = repo;
        }

        public IActionResult Index(string category = null, decimal? price = null)
        {
            var products = repository.GetFilteredProducts(category, price);
            ViewBag.category = category;
            ViewBag.price = price;
            return View(products);
        }
        public IActionResult Create()
        {
            ViewBag.CreateMode = true;
            return View("Editor", new Product());
        }
        [HttpPost]
        public IActionResult Create(Product product)
        {
            repository.CreateProduct(product);
            return RedirectToAction(nameof(Index));
        }
        public IActionResult Edit(long id)
        {
            ViewBag.CreateMode = false;
            return View("Editor", repository.GetProduct(id));
        }
        [HttpPost]
        public IActionResult Edit(Product product, Product original)
        {
            repository.UpdateProduct(product, original);
            return RedirectToAction(nameof(Index));
        }
        [HttpPost]
        public IActionResult Delete(long id)
        {
            repository.DeleteProduct(id);
            return RedirectToAction(nameof(Index));
        }
    }
}

MVC 模型绑定器将创建两个Product对象,其中一个包含用户可能编辑过的input元素的值,另一个包含原始值。修改后的Edit方法将两个对象传递给存储库,存储库必须进行更新才能接收它们。更改IDataRepository接口定义的updateProductMethod方法,如清单12-18所示。

清单 12-18:Models 文件夹下的 IDataRepository.cs 文件,更新方法

using System.Collections.Generic;
using System.Linq;

namespace DataApp.Models
{
    public interface IDataRepository
    {
        Product GetProduct(long id);
        IEnumerable<Product> GetAllProducts();
        IEnumerable<Product> GetFilteredProducts(string category = null,
            decimal? price = null);
        void CreateProduct(Product newProduct);
        void UpdateProduct(Product changedProduct, Product originalProduct = null);
        void DeleteProduct(long id);
    }
}

要跟踪更改,编辑EFDataRepository实现类,将可选参数添加到UpdateProduct方法中,并使用它检测更改,如清单12-19所示。

清单 12-19:Models 文件夹下的 EFDataRepository.cs 文件,跟踪更改

...
public void UpdateProduct(Product changedProduct, Product originalProduct = null)
{
    if (originalProduct == null)
    {
        originalProduct = context.Products.Find(changedProduct.Id);
    }
    else
    {
        context.Products.Attach(originalProduct);
    }
    originalProduct.Name = changedProduct.Name;
    originalProduct.Category = changedProduct.Category;
    originalProduct.Price = changedProduct.Price;
    context.SaveChanges();
}
...

如果UpdateProduct接收到originalProduct参数,则使用DbSet<T>.Attach方法将其置于 Entity Framework Core 的管理之下,该方法设置 Entity Framework Core 更改跟踪过程,并将关联的EntityEntry.State属性设置为Unmodified

将其他Product对象的属性值复制到跟踪对象,更改检测过程将确保只将更改的值存储在数据库中。

重新启动应用程序,再次单击【Stadium】产品的【Edit】按钮,并将名称字段从【Stadium (Small)】更改为【Stadium (Regular)】。单击【Save】按钮时,日志消息将显示只更新已更改的属性的 SQL 语句,如下所示:

UPDATE [Products] SET [Name] = @p0
WHERE [Id] = @p1;

由于在 HTTP POST 请求中包含了基线数据,因此不需要额外的查询来计算更改的内容。

提示:有关如何检测基准数据自从数据库读取以来更改的详细信息,请参见第21章。

删除数据

最后要实现的数据操作是删除,与更新相比,删除是一个相对简单的过程。要添加对从数据库中删除行的支持,请在存储库类中编辑DeleteProduct方法,以添加清单12-20所示的代码。

清单 12-20:Models 文件夹下的 EFDataRepository.cs 文件,删除数据

public void DeleteProduct(long id)
{
    Product p = context.Products.Find(id);
    context.Products.Remove(p);
    context.SaveChanges();
}

DbSet类定义接受实体对象的Remove方法。当调用SaveChanges方法时,Entity Framework Core 将要求数据库从数据表中删除行。通过检查应用程序的输出,可以看到Remove方法是如何生成 SQL 命令的,该输出将包含如下条目:

DELETE FROM [Products]
    WHERE [Id] = @p0;

清单12-20中的代码的问题是,它使用Find方法查询数据库以获得应该删除的产品。这是可行的,但是它会产生一个数据库操作,可以通过直接创建一个Product对象来避免这种操作,如清单12-21所示。

提示DbSet<T>属性还定义了一个RemoveRange方法,该方法可用于在单个方法调用中删除多个对象,如第16章所示。

清单 12-21:Models 文件夹下的 EFDataRepository.cs 文件,删除数据

...
public void DeleteProduct(long id)
{
    context.Products.Remove(new Product { Id = id });
    context.SaveChanges();
}
...

只有键用于标识将被删除的数据库中的行,因此可以通过创建一个仅具有Id值的新Product对象并将其传递给Remove方法来执行删除操作。要查看效果,请重新启动应用程序,导航到 http://localhost:5000,并单击【Delete】按钮从数据库中删除产品,如图12-6所示。结果是相同的,但不需要在删除数据之前读取数据。

图12-6 删除数据

总结

在本章中,我解释了如何使用 Entity Framework Core 执行基本数据操作。在大多数情况下,这些操作很容易执行,并且很好地适应了 MVC 模型,尽管在使用 Entity Framework Core 时,必须考虑执行每个任务所需的查询,这是一个反复出现的主题。在下一章中,我将描述 Entity Framework Core 迁移功能,该功能用于准备用于存储应用程序数据的数据库。

;

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