深刻理解 EF Core:EF Core 寫入數據時發生了什麼?

閱讀本文大概須要 14 分鐘。sql

原文:https://bit.ly/2C67m1C
做者:Jon P Smith
翻譯:王亮
聲明:我翻譯技術文章不是逐句翻譯的,而是根據我本身的理解來表述的。其中可能會去除一些本人實在不知道如何組織但又不影響理解的句子。數據庫

這是深刻理解 EF Core 系列的第二篇文章。第一篇是關於 EF Core 如何從數據庫讀取數據的;而這一篇是關於 EF Core 如何向數據庫寫入數據的。這是四種數據庫操做 CRUD(新增、讀取、更新和刪除)中的 CUD 部分。編程

我假設你對 EF Core 已經有了必定的認識,但在深刻學習以前,咱們先來了解一下如何使用 EF Core,以確保咱們已經掌握了一些基本知識。這是一個「深刻研究」的課題,因此我準備大量的技術細節,但願個人描述方式你能理解。服務器

本文是「深刻理解 EF Core」系列中的第二篇。如下是本系列文章列表:ide

概要

∮. EF Core 能夠經過新的或已存在的關聯關係建立一個新的實體。爲此,它必須以正確的順序來組織實體類,以便可以創建各種之間的關聯。這使得開發人員很容易寫出具備複雜關聯關係的類。單元測試

∮. 當你調用 EF Core 的 Add 命令來添加一個新條目時,會發生不少事情:學習

  • EF Core 查找添加的類和其餘類的全部關聯。對於每一個關聯的類,它也會判斷是否須要在數據庫中建立一個新行,或者僅僅連接到數據庫中現有的行。
  • 它使用現有行的主鍵或僞主鍵爲新添加的條目填充外鍵信息。

∮. EF Core 能夠監測你從數據庫讀取的類的屬性的變化。它經過已讀入的類的隱藏副原本實現這一點。當你調用 SaveChanges 時,它會將每一個讀入的屬性值與其原始值進行比較,而且會建立相應的數據更新命令。測試

∮. EF Core 的 Remove 方法將刪除參數提供的實體類的主鍵所指向的數據行。若是被刪除的類有外鍵關聯,那麼數據庫會自動進行相關的操做(好比級聯刪除),但你能夠更改刪除的規則。ui

數據寫入基礎

提示:若是你已經對 EF Core 有必定的瞭解,那麼你能夠跳過這一部分,這只是一個簡單的 EF Core 寫入數據的例子。spa

在這一節的介紹中,我將描述一下本文用到的數據庫結構,而後給出一個簡單的數據庫寫入示例。下面是類/表的基本關係圖:

這些表被映射到具備相似名稱的類,例如 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 已經在數據庫中了。代碼註明了階段 一、階段 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

相關文章
相關標籤/搜索