深入理解 EF Core:EF Core 寫入資料時發生了什麼?

精緻碼農發表於2020-06-19

閱讀本文大概需要 14 分鐘。

原文: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

相關文章