Liam W
封面

深入理解 EF Core:EF Core 写入数据时发生了什么?

作者
王亮·发表于 3 年前

原文:https://bit.ly/2C67m1C
作者:Jon P Smith
翻译:王亮
声明:我翻译技术文章不是逐句翻译的,而是根据我自己的理解来表述的。其中可能会去除一些本人实在不知道如何组织但又不影响理解的句子。

这是深入理解 EF Core 系列的第二篇文章。第一篇是关于 EF Core 如何从数据库读取数据的;而这一篇是关于 EF Core 如何向数据库写入数据的。这是四种数据库操作 CRUD(新增、读取、更新和删除)中的 CUD 部分。

我假设你对 EF Core 已经有了一定的认识,但在深入学习之前,我们先来了解一下如何使用 EF Core,以确保我们已经掌握了一些基本知识。这是一个“深入研究”的课题,所以我准备大量的技术细节,希望我的描述方式你能理解。

本文是“深入理解 EF Core”系列中的第二篇。以下是本系列文章列表:

概要

∮. EF Core 可以通过新的或已存在的关联关系创建一个新的实体。为此,它必须以正确的顺序来组织实体类,以便能够建立各类之间的关联。这使得开发人员很容易写出具有复杂关联关系的类。

∮. 当你调用 EF Core 的 Add 命令来添加一个新条目时,会发生很多事情:

  • EF Core 查找添加的类和其他类的所有关联。对于每个关联的类,它也会判断是否需要在数据库中创建一个新行,或者仅仅链接到数据库中现有的行。
  • 它使用现有行的主键或伪主键为新添加的条目填充外键信息。

∮. EF Core 可以监测你从数据库读取的类的属性的变化。它通过已读入的类的隐藏副本来实现这一点。当你调用 SaveChanges 时,它会将每个读入的属性值与其原始值进行比较,并且会创建相应的数据更新命令。

∮. EF Core 的 Remove 方法将删除参数提供的实体类的主键所指向的数据行。如果被删除的类有外键关联,那么数据库会自动进行相关的操作(比如级联删除),但你可以更改删除的规则。

数据写入基础

提示:如果你已经对 EF Core 有一定的了解,那么你可以跳过这一部分,这只是一个简单的 EF Core 写入数据的例子。

在这一节的介绍中,我将描述一下本文用到的数据库结构,然后给出一个简单的数据库写入示例。下面是类/表的基本关系图:

这些表被映射到具有类似名称的类,例如 Book、BookAuthor、Author,这些类的属性名称与表的字段名称相同。由于篇幅有限,我不打算展开来讲这些类,但您可以在我的 GitHub 仓库[1]中查看这些类。

和读取数据一样,EF Core 将数据写入数据库也是五部分:

  1. 数据库服务器,如 SQL server, Sqlite, PostgreSQL…
  2. 映射到数据库的一个类或多个类—我将它们称为实体类
  3. 一个继承 EF Core 的 DbContext 的类,该类包含 EF Core 的配置
  4. 一个创建数据库的方法
  5. 最后,向数据库写入数据的命令

下面的单元测试代码来自我的 GitHub 创库[2],展示了一个简单的示例,它从现有数据库中读取 4 个 Book 实体及其关联的 BookAuthor 和 Authors 实体。

[Fact]
public void TestWriteTestDataSqliteInMemoryOk()
{
    //SETUP
    var options = SqliteInMemory.CreateOptions<EfCoreContext>();
    using (var context = new EfCoreContext(options))
    {
        context.Database.EnsureCreated();

        //ATTEMPT
        var book = new Book
        {
            Title = "Test",
            Reviews = new List<Review>()
        };
        book.Reviews.Add(new Review { NumStars = 5 });
        context.Add(book);
        context.SaveChanges();

        //VERIFY
        var bookWithReview = context.Books
            .Include(x => x.Reviews).Single()
        bookWithReview.Reviews.Count.ShouldEqual(1);
    }
}

现在,如果我们将单元测试代码对应到上面的 5 部分,结果是这样的:

  1. 数据库服务器——第 5 行:我选择了一个 Sqlite 数据库服务器,在本例中是 SqliteInMemory.CreateOptions 方法,它使用我的一个 NuGet 包 EfCore.TestSupport 创建了一个内存数据库(内存中的数据库对于单元测试非常有用,因为你可以为这个测试建立一个新的空数据库)。
  2. 实体类——和上一篇结构差不多,但是多了一个与 Book 关联的 Review 实体类。
  3. 一个继承 DbContext 的类——第 6 行:EfCoreContext 类继承了 DbContext 类并配置了从类到数据库的映射关系(你可以在我的 GitHub 仓库[3] 中查看该类)。
  4. 一个创建数据库的方法——第 8 行:第一次执行时,这句代码会创建一个新的数据库,包括创建正确的表、键、索引等。EnsureCreated 方法用于单元测试,但对于真实的应用程序,你最好手动执行 EF Core 的 Migration 命令。
  5. 向数据库写入数据的命令——第 17 到 18 行:
    • 第 17 行:Add 方法告诉 EF Core 需要将一个 Book 实体及其关系(在本例中,只是一个 Review 实体)写入数据库。
    • 第 18 行:SaveChange 方法将在数据库中的 Books 和 Reviews 表中创建新行。

在 //VERIFY 注释之后的最后几行用来检查数据是否已经被写入数据库。

在本例中,你向数据库添加了新的记录(SQL 的 INSERT INTO 命令)。EF Core 也可以处理更新和删除数据库的数据,下一节介绍这个新增示例,然后介绍其他新增、更新和删除的示例。

写入数据时数据库端发生了什么

我将从创建一个新的 Book 实体类和新的 Review 实体类开始。这两个类的关系比较简单。使用上面单元测试的例子,主要代码如下:

var book = new Book
{
    Title = "Test",
    Reviews = new List<Review>()
};
book.Reviews.Add(new Review { NumStars = 1 });
context.Add(book);
context.SaveChanges();

为了将这两个实体添加到数据库,EF Core 需要这样做:

  1. 确定它应该以什么顺序创建这些新行——在本例中,它必须在 Books 表中创建一行,这样它就拥有 Books 的主键。
  2. 将主键复制到与其关联的外键——在本例中,它将 Books 中的主键 BookId 复制到 Review 的外键。
  3. 复制数据库中新创建的数据,以便实体类正确表示数据库的数据——在这种情况下,它必须复制 BookId 并更新 BookId 属性,包括 Book 和 Review 实体类以及 Review 实体类的 ReviewId。

下面我们看看上面代码生成的 SQL 语句:

-- 第一次访问数据库
SET NOCOUNT ON;
-- 向数据库的 Books 表生成一条新数据.
-- 数据库生成 Books 的主键值
INSERT INTO [Books] ([Description], [Title], ...)
VALUES (@p0, @p1, @p2, @p3, @p4, @p5, @p6);

-- 返回主键值,检查并确认数据行是否已添加
SELECT [BookId] FROM [Books]
WHERE @@ROWCOUNT = 1 AND [BookId] = scope_identity();

-- 第二次访问数据库
SET NOCOUNT ON;
-- 向数据库的 Review 表生成一条新数据.
-- 数据库生成 Review 的主键值
INSERT INTO [Review] ([BookId], [Comment], ...)
VALUES (@p7, @p8, @p9, @p10);

-- 返回主键值,检查并确认数据行是否已添加
SELECT [ReviewId] FROM [Review]
WHERE @@ROWCOUNT = 1 AND [ReviewId] = scope_identity();

重要的一点是,EF Core 是按正确的顺序处理实体类的,这样它就可以填充外键。这是简单的例子,但我遇到一个客户项目的例子是,我不得不建立一个非常复杂的数据组成的 15 个不同的实体类,一些实体类是新增,一些是更新和删除,EF Core 通过一个 SaveChanges 将把所有工作有序地完成了库。因此,EF Core 使开发者可以很容易地将复杂的数据写入数据库。

我之所以提到这一点,是因为我看到过在 EF Core 代码中,开发人员多次调用 SaveChanges 方法来从第一个新增命令中获得主键,并把它设置为相关实体的外键。例如:

var book = new Book
{
    Title = "Test"
};
context.Add(book);
context.SaveChanges();
var review = new Review { BookId = book.BookId, NumStars = 1 }
context.Add(review);
context.SaveChanges();

虽然这代码效果是一样的,但它有一个缺陷——如果第二 SaveChanges 失败,那么就会发生部分数据更新到数据库的情况。在某种情况下,这可能不是个问题,但对于像我客户那种需要保证数据一致的情况,就非常糟糕了。

因此,从中得到的收获是,您不需要将主键复制到外键中,因为你可以设置导航属性,EF Core 将为您挑选出外键。因此,如果你认为需要调用两次 SaveChanges,那么通常意味着你没有设置正确的导航属性来处理这种情况。

写数据时 DbContext 做了什么

在上一节中,你看到了 EF Core 在数据库端做了什么,现在你要看看在 EF Core 中发生了什么。大多数情况,你不需要知道,但有时候知道这些是非常重要的。例如,你只能在 SaveChanges 之前捕获数据的状态。而对于自增主键,你只有在 SaveChanges 被调用之后才能拿到主键的值。

与上一个示例相比,这个示例稍微复杂一些。在这个示例中,我想向你展示 EF Core 通过从数据库中读取的已有实体类的实例来处理另一个实体类的新实例。下面的代码创建了一个新的 Book,但 Author 已经在数据库中了。代码注明了阶段 1、阶段 2 和阶段 3,然后我用图表描述每个阶段发生的事情。

// 阶段 1
var author = context.Authors.First();
var bookAuthor = new BookAuthor { Author = author };
var book = new Book
{
    Title = "Test Book",
    AuthorsLink = new List<BookAuthor> { bookAuthor }
};

// 阶段 2
context.Add(book);

// 阶段 3
context.SaveChanges();

接下来的三个图向你展示了实体类及其跟踪数据在每个阶段内发生的事情。每个图显示了其阶段结束时的以下数据:

  • 流程的每个阶段中每个实例的状态。
  • Book 和 BookAuthor 类是棕色的,表示它们是类的新实例,需要添加到数据库中,而 Author 实体类是蓝色的,表示从数据库中读取的实例。
  • 主键和外键旁边的括号是其当前的值。如果一个键是 (0),那么它还没有被设值。
  • 箭头连线连接的是从导航属性到其相应实体类。
  • 每个阶段之间的变化通过粗体文本或箭头连线的粗线显示。

下图显示了阶段 1 完成后的情况。用于设置一个新的 Book 实体类(左)和一个新的 BookAuthor 实体类(中),后者将 Book 连接接到一个现有的 Author 实体类(右)。

阶段 1 这是调用任何 EF Core 方法之前的起点。

下一个图显示了执行 context.Add(book) 之后的情况。更改部分以粗体显示。

你可能会惊讶于执行 Add 方法时所发生的事情。它将作为参数提供的实体的状态设置为 Added(在本例中为 Book 实体)。然后通过导航属性或外键值查看与该实体连接的所有实体。对于每个被连接的实体,它会执行以下操作(注意:我不知道它们执行的确切顺序)。

  • 如果实体未被跟踪(即其当前状态为 Detached),则将其状态设置为 Added——在本例中,它是 BookAuthor 实体。
  • 它用主键的值填充正确的外键的值。如果连接的主键还不可用,它将为跟踪的主键和外键数据的 CurrentValue 属性设置一个惟一的负数。你可以在上图中看到这一点。
  • 它填充当前未设值的导航属性——如上图中所示。

最后一个阶段,即阶段 3,是调用 SaveChanges 方法时发生的情况,如图所示。

在“写数据时数据库端发生了什么”一节中,数据库更改的任何列都被复制回实体类中,以便实体与数据库匹配。在本例中,数据更新到数据库时会把主键值更新到 Book 的 BookId 和 BookAuthor 的 BookId。
而且,此次数据库写入完成后,涉及的所有实体的状态都会被更新为 Unchanged。

对于上面这样一个很长的解释,很多时候你不需要知道这些细节,你只管它“工作了”就行。但是,当某些东西不能正常工作或者想做一些复杂的事情时,比如记录实体类的更改,那么了解这个就非常有用。

更新数据到数据库时发生了什么

上面的示例是关于向数据库添加新记录的,但是没有进行更新。在这一节中,我将展示当你更新数据库中已有的记录时会发生什么。这里使用我上一篇文章“EF Core 读取数据时发生了什么?”中讲到的查询例子。

这个更新很简单,只有三行,但是它在代码中有三个阶段:读取、更新和保存。

var books = context.Books.ToList();
books.First().PublishedOn = new DateTime(2020, 1, 1);
context.SaveChanges();

下图展示了这三个阶段:

如你所见,你使用的查询类型很重要——普通查询加载数据并把返回的实体保存一份“跟踪快照”,返回的实体类被称为“被跟踪的”。如果实体没有没跟踪,则无法更新它。

注意:上一节中的 Author 实体类也是被“跟踪”的。在这个例子中,Author 的跟踪状态告诉 EF Core Author 已经在数据库中,因此不会再次创建。

因此,如果你更改了加载的跟踪实体类中的任何属性,那么当你调用 SaveChanges 时,它会将所有跟踪的实体类与它们的跟踪快照进行比较。对于每个类,它遍历映射到数据库字段的所有属性。这个过程称为更改跟踪,它将检测被跟踪实体中的每一个更改,包括 Title、PubishedOn 等非关系属性。

在这个简单的示例中,只有 4 个 Book 实体,但在实际应用程序中,您可能已经加载了许多相互连接的实体类。在这一点上,比较阶段可能需要一段时间。因此,你应该尝试只加载需要更改的实体类。

注意:EF Core 有一个名为 Update 的命令,它用于更新每个属性/列的特定情况。EF Core 会自动跟踪更改,默认只更新已更改的属性/列。

每次更新都将创建一个 SQL UPDATE 命令,所有这些更新都将在一个 SQL 事务中执行。使用 SQL 事务意味着所有更新都作为一个整体,如果其中任何一部分失败,那么事务中的任何数据库更改都会失效。

从数据库删除数据时发生了什么

CRUD 的最后一部分是 DELETE,这在某些情况很简单,你只需要调用 context.Remove()。在另一些情况它很复杂,例如,当你删除另一个实体类依赖的实体类时会发生什么?

删除映射到数据库的实体类的方法是 Remove。举个例子,我加载一个特定的 Book,然后删除它。

var book = context.Books
    .Single(p => p.Title == "Quantum Networking");
context.Remove(book);
context.SaveChanges();

它的阶段如下:

  1. 加载要删除的 Book 实体类。这会获取它的所有属性数据,但对于删除,您实际上只需要实体类的主键。
  2. 调用 Remove 方法其实是将 Book 的状态标记为 Deleted。这些信息会有序地存储在跟踪快照中。
  3. 最后,SaveChanges 创建一个 SQL DELETE 命令,该命令与任何其他数据库更改一起发送到数据库,并且在一个 SQL 事务中。

这看起来很简单,但这里发生了一些重要的事情,从代码看并不明显。原来书名为“Quantum Networking”的书有其他一些实体类关联到到它——在某个特定的测试用例中,书名为“Quantum Networking”的书关联到以下实体类:

  • 两个 Review
  • 一个 PriceOffer
  • 一个 BookAuthor

现在,Review、PriceOffer 和 BookAuthor 实体类只与这本书相关——我们使用术语叫依赖于 Book 实体类。因此,如果这本书被删除了,那么这些 Review、PriceOffer 和所关联的 BookAuthor 数据行也应该被删除。如果不删除,那么数据库的关联关系就是不正确的,SQL 数据库将抛出异常。那么,为什么做这个删除工作?

这里所发生的都是因为设置了级联删除,级联删除规则设置了 Books 表和三个依赖表之间的数据库关系。
下面是 EF Core 为创建 Review 表而生成的 SQL 命令的一个示例:

CREATE TABLE [Review] (
    [ReviewId] int NOT NULL IDENTITY,
    [VoterName] nvarchar(max) NULL,
    [NumStars] int NOT NULL,
    [Comment] nvarchar(max) NULL,
    [BookId] int NOT NULL,
    CONSTRAINT [PK_Review] PRIMARY KEY ([ReviewId]),
    CONSTRAINT [FK_Review_Books_BookId] FOREIGN KEY ([BookId])
         REFERENCES [Books] ([BookId]) ON DELETE CASCADE
);

CONSTRAINT 语句部分定义了约束规则,该约束表示 Review 通过 BookId 列链接到 Books 表中的一行。在该约束的最后,你将看到关于 DELETE 级联的规则。它告诉数据库,如果它链接的书被删除了,那么这个 Review 也应该被删除。这意味着书的删除是允许的,因为所有相关的行也被删除了。

这是非常有用的,但有时候想要更改删除规则怎么办?比如我决定不允许删除客户订单中存在的书。为了做到这一点,我在 DbContext 中添加了一些 EF Core 配置来改变删除规则,如下:

public class EfCoreContext : DbContext
{
    private readonly Guid _userId;

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

    public DbSet<Book> Books { get; set; }
    //… 其它 DbSet<T>

    protected override void OnModelCreating(ModelBuilder modelBuilder
    {
        //… 其它代码

        modelBuilder.Entity<LineItem>()
            .HasOne(p => p.ChosenBook)
            .WithMany()
            .OnDelete(DeleteBehavior.Restrict);
    }
}

一旦该配置应用到数据库,就不会生成 SQL 语句的 DELETE CASCADE。这意味着,如果你试图删除客户订单中的一本书,那么数据库将返回一个错误,EF Core 将把这个错误变成一个异常。

这使你对正在发生的事情有一个更深的了解,但是还有相当多的内容我没有介绍(但我在我的书中介绍了)。这里有一些关于删除我还没有提到的事情:

  • 实体类之间可以有 required 关系(依赖关系)和 optional 关系,EF Core 为每种类型使用不同的规则。
  • EF Core 可以通过设置 DeleteBehavior 来设置级联删除规则,当实体类存在循环关联关系时,可以用它避免一些错误——一些数据库在发现循环删除时会抛出错误。
  • 你可以在调用 Remove 方法时提供一个新的只有主键有值的类来删除实体类。这在处理只返回主键的场景非常有用。

总结

本文我介绍了 CRUD 中的新增、更新和删除部分,前一篇文章介绍了读取部分。

正如您所看到的,使用 EF Core 在数据库中创建记录很容易,但内部很复杂。你通常不需要知道 EF Core 或数据库中发生了什么,但了解一些细节可以让你更好地利用 EF Core 的优势。

更新也很简单——只需在你读入的实体类中更改一个或多个属性,当你调用 SaveChanges 时,EF Core 会找到已更改的数据,并构建 SQL 命令更新数据库。这适用于非关系属性(如图 Book 的 Title 属性)和导航属性(你可以在他们的关系)。

最后,我们看了一个删除案例。同样很容易使用,但很多处理也是在背后执行的。​ 另外,敬请关注我的下一篇文章,我将讨论所谓的“软删除”。如果你设置了一个标志,EF Core 就不会再看到这个实体类了,它仍然在数据库中,但它是隐藏的。

希望本文对你有用,也希望你关注本系列的更多文章。

祝你编程愉快!

[1]. https://bit.ly/2MXK3ZY
[2]. https://bit.ly/2Yza7QQ
[3]. https://bit.ly/2Y0UORO