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

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

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

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

本文將為你詳細描繪 EF Core 從資料庫中讀取資料的“幕後”檢視。我將揭開兩種資料庫讀取方式的面紗:一個是普通的查詢,另一個是使用 AsNoTracking 方法的非跟蹤查詢。我還將通過一個實驗來演示我是如何解決我的一個客戶遇到的效能問題。

我假設你對 EF Core 已經有了一定的認識,但在深入學習之前,我們先來了解一下如何使用 EF Core,以確保我們已經掌握了一些基本知識。這是一個“深入研究”的課題,所以我準備大量的技術細節,希望我的描述方式你能理解。

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

  • 當 EF Core 從資料庫讀取資料時發生了什麼?(本文)
  • 當 EF Core 寫入資料到資料庫時發生了什麼?(敬請期待)

概要

  • EF Core 有兩種方法從資料庫中讀取資料(也稱為查詢):普通 LINQ 查詢和包含 AsNoTracking 方法的非跟蹤 LINQ 查詢。
  • 這兩種方法查詢的返回類(被稱為實體類),它連線的其它的實體類(即所謂的導航屬性)也被同時載入,但這兩種法如何連線及連線的內容是不一樣的。
  • 普通查詢接受的是 DbContext 執行讀取時所有資料的副本——此時的實體類稱為被跟蹤。這允許載入的實體類參與資料庫的更新操作。
  • 普通查詢還會有一些其它的複雜底層實現,稱為關係修補(fixup),用於描述讀入的實體類和其他被跟蹤實體之間的連線關係。
  • AsNoTracked 非跟蹤查詢沒有副本,所以它沒有被跟蹤——這意味著它比普通查詢更快。這也意味著它不會用於資料庫的寫操作。
  • 最後,我將展示 EF Core 普通查詢中一個鮮為人知的特性,以此作為示例,說明通過導航屬性連線實體類的關係是多麼智慧。

EF Core 如何讀取資料庫資料

提示:如果你已經對 EF Core 有一定的認識,那麼你可以跳過這一節,這部分只是一個如何讀取資料庫的例子。

為了能讓你更好地理解,我先描述一個資料庫結構,然後再給出一個簡單的資料庫讀取示例。下面是一些基本表的結構和它們之間的關係。

這些表被對映到具有類似名稱的類,例如 Book、BookAuthor、Author,這些類的屬性名稱與表的欄位名稱相同。由於篇幅有限,我不打算展開來講這些類,但您可以在我的 GitHub 倉庫[1]中檢視這些類。

EF Core 讀取資料庫需要下面五部分:

  1. 資料庫伺服器,如 SQL server, Sqlite, PostgreSQL 等。
  2. 具有資料的資料庫。
  3. 對映到資料表的類(稱為實體類)。
  4. 一個繼承 DbContext 的類,該類包含 EF Core 的配置。
  5. 最後,從資料庫讀取資料的命令。

下面的單元測試程式碼來自我的 GitHub 創庫[2],展示了一個簡單的示例,它從現有資料庫中讀取 4 個 Book 實體及其關聯的 BookAuthor 和 Authors 實體。

倉庫地址:https://bit.ly/2Yza7QQ

[Fact]
public void TestBookCountAuthorsOk()
{
    //SETUP
    var options = SqliteInMemory.CreateOptions<EfCoreContext>();
    //code to set up the database with four books, two with the same Author
    using (var context = new EfCoreContext(options))
    {
        //ATTEMPT
        var books = context.Books
            .Include(r => r.AuthorsLink)
            .ThenInclude(r => r.Author)
            .ToList();

        //VERIFY
        books.Count.ShouldEqual(4);
        books.SelectMany(x => x.AuthorsLink.Select(y => y.Author))
            .Distinct().Count().ShouldEqual(3);
    }
}

現在,如果我們將單元測試程式碼對應到上面的 5 部分,結果是這樣的:

  1. 資料庫伺服器——第 5 行:我選擇了一個 Sqlite 資料庫伺服器,在本例中是 SqliteInMemory.CreateOptions 方法,它使用我的一個 NuGet 包 EfCore.TestSupport 建立了一個記憶體資料庫(記憶體中的資料庫對於單元測試非常有用,因為你可以為這個測試建立一個新的空資料庫)。
  2. 具有資料的資料庫——第 6 行:我將在下一篇文章介紹資料是如何寫入資料庫的,現在假設有一個資料庫包含 4 本書資訊,其中兩本書的作者是同一個人。
  3. 實體類——程式碼裡這裡沒有展示,但是你可以在這裡檢視這些類[1]。其中有一個 Books 實體類,通過一個名為 BookAuhor 的實體類多對多關聯 Authors 實體類。
  4. 一個繼承 DbContext 的類——第 7 行:EfCoreContext 類繼承了 DbContext 類並配置了從類到資料庫的對映關係(你可以在我的 GitHub 倉庫[3] 中檢視該類)。
  5. 從資料庫讀取資料的命令——第 10 到 13 行,這是一個查詢:
    • 第 10 行 — context 為 EfCoreContext 的例項,通過它訪問你的資料庫,.Books 表示您希望訪問 Books 表。
    • 第 11 行 — Include 被稱為貪婪載入,它告訴 EF Core 當它載入 Books 時,也應該載入關聯到的所有 BookAuthor 實體類。
    • 第 12 行 — ThenInclude 是繼續貪婪載入,它告訴 EF Core 當它載入一個 BookAuthor 時,它也應該載入關聯到該 BookAuthor 的 Author 實體類。

所有這一切查詢出來是一個結果集,其中有普通屬性,像 Books 的 Title 屬性;有關聯實體類的導航屬性,像 Books 的 AuthorsLink 屬性。

這個示例稱為查詢或讀取,也是四種資料庫訪問型別之一,即 CRUD(新增、讀取、更新和刪除)。我將在下一篇文章中介紹新增和更新。

EF Core 如何表示讀取的資料

當你查詢資料庫時,EF Core 會將資料庫返回的資料轉換為實體類並填充導航屬性的值。在本節中,我們將研究兩種型別的查詢步驟——普通查詢(即沒有 AsNoTracking 方法,也稱為讀寫查詢)和新增了 AsNoTracking 方法的非跟蹤查詢(稱為只讀查詢)。

我們先來看一下最初 LINQ 語句是如何轉換成資料庫相應的查詢命令然後返回資料的。對於我們將要看到的兩種型別的查詢來說,這是很常見的操作。關於查詢的第一部分,請參見下圖。

有一些非常複雜的程式碼將你的 LINQ 轉換為資料庫查詢命令,但這些內部細節我們不必關心。如果你的 LINQ 不能被翻譯,你會從 EF Core 得到一個異常訊息,其中包含類似“不能被翻譯”的描述詞語。此外,當資料返回時,像 Value Converters[4] 這樣的特性可能會調整資料。

本節展示了查詢的第一部分,其中 LINQ 被轉換為資料庫命令並返回所有正確的值。現在我們來看查詢的第二部分,在這裡 EF Core 獲取返回值並將它們轉換為實體類的例項,並填充導航屬性。我們將分別看看兩種型別的查詢。

1. 普通查詢(讀寫查詢)

普通查詢讀取資料的方式可以修改資料並更新到資料庫,這就是我將其稱為讀寫查詢的原因。它不會自動更新資料(請參閱下一篇文章,瞭解如何寫入資料庫)。如果你要更新資料,你的查詢必須是讀寫查詢。

我在介紹中給出的示例執行的是一個普通讀寫查詢,讀取帶有 AuthorsLink 例項的示例。下面是該示例的查詢部分的程式碼:

var books = context.Books
    .Include(r => r.AuthorsLink)
    .ThenInclude(r => r.Author)
    .ToList();

然後 EF Core 通過三個步驟將這些值轉換並填充含有導航屬性的實體類。下圖顯示了這三個步驟以及生成的實體類及其導航屬性的實體類。

讓我們來分析一下這三個步驟:

  1. 建立類並填充資料。它接受資料庫返回的值,並填充非導航(稱為標量)屬性、欄位等。在 Book 實體類中,是 BookId(主鍵)、Title 等屬性——參見上圖左下角淺藍色矩形。
  2. 修補關聯關係。首先是填入主鍵和外來鍵的資訊,它們定義如何相互關聯資料。然後,EF Core 使用這些鍵設定實體類之間的導航屬性(如圖中藍色粗線所示)。這個關係的修補所需的資訊不僅是查詢讀入的實體類,它還會檢視 DbContext 中跟蹤的每個實體,並填充導航屬性。這是一個強大的功能,但你的被跟蹤實體越多,所需消耗時間也越多——這就是為什麼需要 AsNoTracking 來實現更快的查詢。
  3. 建立跟蹤快照。跟蹤快照是返回給使用者的實體類的一個副本,加上它所隱藏的與每個實體類的關聯關係——若一個實體處於被跟蹤狀態,這意味著它將會發生修改並會寫入到資料庫中。

2. 非跟蹤查詢(只讀查詢)

非跟蹤查詢,即使用 AsNoTracking 方法的查詢,是一個只讀查詢。這意味著,當 SaveChanges 方法被呼叫時,你讀取的任何內容都不會被寫入資料庫。非跟蹤查詢的查詢效率更高,在下一節中,我將介紹非跟蹤查詢以及與普通查詢的其他區別。

在前文的示例之後,我修改了查詢程式碼,新增了下面的 AsNoTracking 方法(請看第 2 行):

var books = context.Books
    .AsNoTracking()
    .Include(r => r.AuthorsLink)
    .ThenInclude(r => r.Author)
    .ToList();

這裡的 LINQ 查詢只有上面的普通查詢的前兩個步驟(沒有第三個步驟)。下圖顯示了 AsNoTracking 查詢的步驟。

步驟如下:

  1. 建立類並填充資料。它接受資料庫返回的值,並填充非導航(稱為標量)屬性、欄位等。在 Book 實體類中,是 BookId(主鍵)、Title 等屬性——參見上圖左下角淺藍色矩形。
  2. 修補關聯關係。首先是填入主鍵和外來鍵的資訊,它們定義如何相互關聯資料。然後,EF Core 使用這些鍵設定實體類之間的導航屬性(如圖中藍色粗線所示)。這個關係的修補所需的資訊不僅是查詢讀入的實體類,它還會檢視 DbContext 中跟蹤的每個實體,並填充導航屬性。這是一個強大的功能,但你的被跟蹤實體越多,所需消耗時間也越多——這就是為什麼需要 AsNoTracking 來實現更快的查詢。

普通查詢和非跟蹤查詢的區別

現在讓我們比較這兩種查詢比較明顯的區別。

  1. 非跟蹤查詢查詢的效能更好。使用非跟蹤查詢查詢的主要原因是效能。非跟蹤查詢查詢表現為:

    • 稍微快一點,使用的記憶體稍微少一點,因為它不需要建立跟蹤快照。
    • 避免沒有必要的跟蹤快照可以提高 SaveChanges 的效能,因為它不必檢查跟蹤快照以查詢更改。
    • 稍微快一點,因為修補關聯關係時沒有所謂的身份解析。這就是為什麼你會得到兩個具有相同資料的 Author 例項。
  2. 非跟蹤查詢修補關聯關係時只連結查詢中的實體。在普通查詢中,我已經說過修補關聯關係時連線的是查詢中的實體和當前跟蹤的實體,但是非跟蹤查詢只修補查詢中的實體關係。

  3. 非跟蹤查詢並不總是代表資料庫關係。這兩種型別查詢之間的關係修補的另一個區別是,非跟蹤查詢關係修補更快,它不需要標識的解析。這可以為資料庫中的同一行生成多個例項——見上圖右下角藍色的 Author 實體和註釋。如果只是向使用者顯示資料,那麼這種差異並不重要,但是如果具有業務邏輯,那麼多個例項不能正確反映資料的結構,就可能會有問題。

對層級資料有用的關係修補特性

關聯關係修補的步驟是非常智慧的,特別是在普通查詢中。下面我想向你展示我是如何利用關係修補的特性來解決一個客戶專案中的效能問題的。

我曾在一家公司工作,那裡的許多資料處理都是層次化結構的,即資料具有一系列深度不確定的關聯關係。問題是我必須先解析整個層次結構,然後才能呈現這些資料。我最初是通過貪婪的方式載入前兩個層級,然後顯式地載入更深的層級來實現這一點的。它可以工作,但是效能非常慢,並且資料庫因大量單資料庫訪問而超載。

這不得不讓我思考解決辦法,如果普通查詢的關係修補那麼智慧的話,它能幫助我提高查詢的效能嗎?它可以!讓我給你舉一個公司員工的例子。下圖顯示了我們想要載入的公司的層次結構。

你可以接龍式地使用 .Include(x => x.WorksForMe).ThenInclude(x => x.WorksForMe)… 等等來載入所需的層級資訊,但結果是一個 .Include(x => x.WorksForMe) 就夠了。因為 EF Core 的關係修補為你做了剩下的事情,這一點很驚奇,但也很有用。

例如,如果我想查詢角色為 Development 的所有員工(每個員工都有一個名為 WhatTheyDo 的屬性和名為 Role 的屬性,該 Role 包含他們工作的部門),我可以這樣編寫程式碼:

var devDept = context.Employees
    .Include(x => x.WorksFromMe)
    .Where(x => x.WhatTheyDo.HasFlag(Roles.Development))
    .ToList();

這將建立一個查詢,用於載入角色為 Development 的所有員工,並且在員工實體類上修補與 WorksFoMe 導航屬性(集合)和 Manager 導航屬性(單個)的關係。通過只執行一個查詢,既提高了查詢花費的時間,又減少了資料庫伺服器上的負載。

總結

你已經看到了兩種型別的查詢,我稱之為 a)普通的讀寫查詢,和 b) 非跟蹤的只讀查詢。對於每一種查詢型別,我都向你展示了 EF Core “幕後”是如何讀取資料並展示的。他們工作方式的不同也表現出他們的優勢和劣勢。

非跟蹤查詢是隻讀查詢的解決方案,因為它比普通讀寫查詢更快。但是您應該記住關係修補的機制,它可以在資料庫只有一個關係的情況下建立類的多個例項。

普通的讀寫查詢是查詢跟蹤實體的解決方案,這意味著你可以在建立、更新和刪除資料時使用它們。普通的讀寫查詢確實會佔用更多的時間和記憶體資源,但是有一些有用的特性,比如自動連結到其他被跟蹤的實體類例項。

我希望這篇文章對您有用。祝你程式設計快樂!

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

相關文章