天天看點

倉儲模式是否依然适用于EF Core?

作者:Jon P Smith

翻譯:精緻碼農-王亮

正文:

我在 2014 年寫了第一篇關于倉儲模式的文章,它仍然是一篇很受歡迎的文章。而這一篇文章是那篇文章的更新版,基于這幾年 EF Core 新的釋出和對 EF Core 資料庫通路模式的進一步研究。

https://www.thereformedprogrammer.net/is-the-repository-pattern-useful-with-entity-framework/
   https://www.thereformedprogrammer.net/is-the-repository-pattern-useful-with-entity-framework-part-2/
      

1概要

答案是“否”,倉儲/工作單元模式(簡稱 Rep/UoW)對 EF Core 沒有用。EF Core 已經實作了 Rep/UoW 模式,是以在 EF Core 上再加一個 Rep/UoW 模式是沒有用的。

更好的解決方案是直接使用 EF Core,它允許你使用 EF Core 的所有功能來建構高性能的資料庫通路。

2本文目的

這篇文章關注的是:

  • 人們對 EF 的 Rep/UoW 模式有什麼看法。
  • 在 EF 中使用 Rep/UoW 模式的優點和缺點。
  • 用 EF Core 代碼取代 Rep/UoW 模式的三種方法。
  • 如何使你的 EF Core 資料庫通路代碼易于發現和重構。
  • 關于 EF Core 單元測試的讨論。

我将假設你熟悉 C# 和 EF 6.x 或 EF Core 庫。文中我特别談到了 EF Core,但大部分内容也與 EF6.x 有關。

3背景

在 2013 年,我參與了一個專門用于醫療保健模組化的大型網絡應用程式的開發工作。我使用了 ASP.NET MVC4 和 EF 5,後者當時剛剛問世,支援處理地理資料的 SQL Spatial 類型。當時流行的資料庫通路模式是 Rep/UoW 模式--參見微軟在 2013 年寫的關于使用 EF Core 和 Rep/UoW 模式通路資料庫的文章。

Implementing the Repository and Unit of Work Patterns in an ASP.NET MVC Application
https://docs.microsoft.com/en-us/aspnet/mvc/overview/older-versions/getting-started-with-ef-5-using-mvc-4/implementing-the-repository-and-unit-of-work-patterns-in-an-asp-net-mvc-application
      

我使用 Rep/UoW 建構了我的應用程式,但在開發過程中發現這确實是一個痛點。我不得不不斷地“調整”版本庫的代碼來修複一些小問題,而每次“調整”都會破壞一些其他的東西。正是這一點讓我研究如何更好地實作我的資料庫通路代碼。

說到這裡,我在 2017 年底與一家新成立的公司簽約,幫助他們解決 EF6.x 應用程式的性能問題。性能問題的主要部分被證明是由于懶加載,這是需要的,因為應用程式使用了 Rep/UoW 模式。

事實證明,一個參與啟動項目的程式員曾經使用過 Rep/UoW 模式,在與該公司的創始人交談時,他說他發現應用程式的 Rep/UoW 部分是相當不透明的,很難操作。

4人們如何看倉儲模式

在研究 Spatial Modeller™ 設計的過程中,我發現了一些部落格文章,為抛棄倉儲模式提供了有力的證據。這類文章中最有說服力、考慮最周全的是“Repositories On Top UnitOfWork Are Not a Good Idea”。Rob Conery 的主要觀點是,Rep/UoW 隻是重複了 Entity Framework (EF) DbContext 給你的東西,是以為什麼要把一個完美的架構隐藏在一個沒有任何價值的外表下呢。Rob 稱之為“過度抽象的愚蠢行為”。

另一篇部落格是“Why Entity Framework renders the Repository pattern obsolete”。在這篇文章中,Isaac Abraham 補充說,倉儲模式并沒有使測試變得更容易,這是它本應該做的一件事。這一點在 EF Core 中更加實際,你将在後面看到。

那麼,他們是對的嗎?

5我對 Rep/UoW 模式的看法

讓我試着以盡可能公平的方式回顧一下 Rep/UoW 模式的優點和缺點。以下是我的觀點。

Rep/UoW 模式的優點

  1. 隔離你的資料庫通路代碼。倉庫模式的最大優點是你知道你所有的資料庫通路代碼在哪裡。另外,你通常會把你的倉儲庫分成幾個部分,如目錄庫、訂單處理庫等,這使得你很容易找到有錯誤或需要性能調整的特定查詢的代碼。這無疑是一個很大的優點。
  2. 聚合(Aggregation)。領域驅動設計(DDD)是一種設計系統的方法,它建議你有一個根實體,其他相關的實體被歸入它。我在《Entity Framework Core in Action》一書中使用的例子是一個帶有 Review 實體集合的 Book 實體。這些 Review 隻有在與 Book 相聯系時才有意義,是以 DDD 說你應該隻通過 Book 實體來改變 Review。Rep/UoW 模式通過提供一種方法在 Book Repository 中将評論添加/删除中來實作這一點。
  3. 隐藏複雜的 T-SQL 指令。有時你需要繞過 EF Core,使用 T-SQL。這種類型的通路應該從高層隐藏起來,但又容易找到,以幫助維護或重構。我應該指出,Rob Conery 的文章 Command/Query Objects 也可以處理這個問題。
  4. 易于模拟/測試。很容易模拟一個單獨的資源庫,這使得通路資料庫的單元測試代碼更容易。這在若幹年前是真的,但現在這有其他的方法來解決這個問題,我将在後面介紹。

你會注意到,我沒有提出“用另一個資料庫通路庫替換 EF Core”。這是 Rep/UoW 背後的想法之一,但我認為這是一個誤解,因為 a)很難替換一個資料庫通路庫,b)你真的會在你的應用程式中交換這樣一個關鍵庫嗎?

Rep/UoW 模式的缺點

前三個項目都是圍繞着性能。我并不是說你不能寫一個高效的 Rep/UoW,但它是一項艱難的工作,而且我看到許多實作都有内在的性能問題(包括微軟舊的 Rep/UoW 實作)。以下是我在 Rep/UoW 模式中發現的缺點清單。

  1. 性能--處理實體關系。一個資源庫通常會傳回一種類型的 ​

    ​IEnumerable/IQueryable​

    ​ 結果,例如在微軟的例子中的一個 Student 實體類。假設你想從 Student 的關系中顯示資訊,比如他們的位址?在這種情況下,倉儲庫中最簡單的方法是使用懶加載來讀取學生的位址實體,我看到人們經常這樣做。問題是懶加載會導緻每一個關系都要單獨往返于資料庫,這比把所有的資料庫通路合并成一次資料庫往返要慢。(另一種方法是有多個不同傳回類型的查詢方法,但這将使你的資源庫變得非常大和麻煩--見第 4 點)。
  2. 資料不符合要求的格式。因為倉儲元件通常是根據資料庫建立的,傳回的資料可能不是服務或使用者需要的确切格式。你也許可以調整倉儲庫的輸出,但這是你必須要寫的第二個階段。我認為更好的做法是在靠近前端的地方形成你的查詢,并包括你需要的資料的任何調整。
  3. 性能--更新:許多 Rep/UoW 的實作試圖隐藏 EF Core,但這樣做并沒有利用它的所有功能。例如,Rep/UoW 會使用 EF Core 的 Update 方法更新一個實體,該方法會儲存實體中的每個屬性。而使用 EF Core 内置的變化跟蹤功能,它将隻更新已經改變的屬性。
  4. 太通用了。Rep/UoW 的誘惑力來自于你可以寫一個通用的倉儲庫(Repository),然後用它來建立所有的子倉儲庫,例如目錄庫、訂單處理庫等。這應該可以最大限度地減少你需要寫的代碼,但我的經驗是,一個通用的倉儲庫在開始時是有效的,但随着事情變得越來越複雜,你最終不得不為每個單獨的倉儲庫添加越來越多的代碼。“The more reusable the code is, the less usable it is.” --Neil Ford

總結一下不好的地方--Rep/UoW 隐藏了 EF Core,這意味着你不能使用 EF Core 的功能來編寫簡單但高效的資料庫通路代碼。

6如何保留 Rep/UoW 的優點使用 EF Core

在前面的優點部分中,我列出了隔離、聚合、隐藏和單元測試,Rep/UoW 做得很好。在這一節中,我将談論一些不同的軟體模式和實踐,當你直接使用 EF Core 時,這些模式和實踐與良好的架構設計相結合,提供同樣的隔離、聚合等功能。

我将解釋每一個優點的實作,然後把它們放到一個分層的軟體架構中。

1. 查詢對象:一種隔離和隐藏資料庫讀取的方法

資料庫通路可分為四種類型。新增、讀取、更新和删除--被稱為 CRUD。對我來說,讀的部分,在 EF Core 中被稱為查詢,往往是最難建立和性能調整的。許多應用程式都依賴于良好的、快速的查詢,例如,要購買的産品清單,要做的事情清單,等等。人們想出的方案是查詢對象。

我第一次接觸到它們是在 2013 年 Rob Conery 的文章中(前面提到),他提到了指令/查詢對象。另外,Jimmy Bogard 在 2012 年發表了一篇名為“Favor query objects over repositories”的文章。使用 .NET 的 IQueryable 類型和擴充方法,我們可以在 Rob 和 Jimmy 的例子中改進查詢對象模式。

下面的清單給出了一個查詢對象的簡單例子,它可以選擇一個整數清單的排序方式。

public static class MyLinqExtension
{
public static IQueryable<int> MyOrder
        (this IQueryable<int> queryable, bool ascending)
    {
return ascending
            ? queryable.OrderBy(num => num)
            : queryable.OrderByDescending(num => num);
    }
}
      

下面這是這個 ​

​MyOrder​

​ 查詢對象使用示例:

var numsQ = new[] { 1, 5, 4, 2, 3 }.AsQueryable();

var result = numsQ
    .MyOrder(true)
    .Where(x => x > 3)
    .ToArray();
      

​MyOrder​

​​ 查詢對象的工作原理是,​

​IQueryable​

​​ 類型持有一個指令清單,當我應用 ​

​ToArray​

​​ 方法時,這些指令會被執行。在我的簡單例子中,我沒有使用資料庫,但如果我們用應用程式的 ​

​DbContext​

​​ 的 ​

​DbSet<T>​

​​ 屬性替換 ​

​numsQ​

​​ 變量,那麼 ​

​IQueryable<T>​

​ 類型中的指令将被轉換為資料庫指令。

因為 ​

​IQueryable<T>​

​​ 類型直到最後才被執行,是以你可以将多個查詢對象連鎖起來。讓我從我的書《Entity Framework Core in Action》中給你一個更複雜的資料庫查詢的例子。在下面的代碼中,使用了四個查詢對象鍊在一起,對一些圖書的資料進行選擇、排序、過濾和分頁。你可以在實時網站 ​

​efcoreinaction.com​

​ 看到這些。

public IQueryable<BookListDto> SortFilterPage
    (SortFilterPageOptions options)
{
var booksQuery = _context.Books
        .AsNoTracking()
        .MapBookToDto()
        .OrderBooksBy(options.OrderByOptions)
        .FilterBooksBy(options.FilterBy,
                       options.FilterValue);

    options.SetupRestOfDto(booksQuery);

return booksQuery.Page(options.PageNum-1,
                           options.PageSize);
}
      

查詢對象提供了比 Rep/UoW 模式更好的隔離性,因為你可以把複雜的查詢分割成一系列的查詢對象,并把它們連在一起。這使得它更容易編寫和了解、重構和測試。另外,如果你有一個需要原始 SQL 的查詢,你可以使用 EF Core 的 FromSql 方法,它也可以傳回 ​

​IQueryable<T>​

​。

2. 新增、更新和删除資料庫通路方法

查詢對象處理了 CRUD 的讀取部分,但是新增、更新和删除部分呢,也就是你向資料庫寫入的部分?我将向你展示運作 CUD 操作的兩種方法:直接使用 EF Core 指令,以及使用實體類中的 DDD 方法。讓我們來看看非常簡單的更新例子:在我的圖書應用中添加一個評論(見​

​efcoreinaction.com​

​)。

注:如果你想嘗試添加評論,有一個與我的書配套的 GitHub repo(​

​github.com/JonPSmith/EfCoreInAction​

​),選擇分支 Chapter05(每章都有一個分支)并在本地運作該應用程式。你會看到每本書旁邊都有一個管理按鈕,有幾個 CUD 指令。

方式一:直接使用 EF Core 指令

最明顯的方法是使用 EF Core 方法來完成資料庫的更新。下面是一個方法,它将為一本書添加一個新的評論,其中包括使用者提供的評論資訊。注意:​

​ReviewDto​

​ 是一個持有使用者填寫完評論資訊後傳回的資訊類。

public Book AddReviewToBook(ReviewDto dto)
{
var book = _context.Books
        .Include(r => r.Reviews)
        .Single(k => k.BookId == dto.BookId);
var newReview = new Review(dto.numStars, dto.comment, dto.voterName);
    book.Reviews.Add(newReview);
    _context.SaveChanges();
return book;
}
      
注:​

​AddReviewToBook​

​​ 方法是在一個叫做 ​

​AddReviewService​

​​ 的類中,這個類在我的 ​

​ServiceLayer​

​​ 中。這個類被注冊為一個服務,并且有一個構造函數,它接收應用程式的 ​

​DbContext​

​​,這個 ​

​DbContext​

​​ 是通過 DI 注入的。注入的值被存儲在私有字段 ​

​_context​

​​ 中,​

​AddReviewToBook​

​ 方法可以使用它來通路資料庫。

這将把新的評論添加到資料庫中,這很有效,但還有另一種方法可以使用更多的 DDD 方法來建構。

方式二:DDD 風格的實體類

EF Core 為我們提供了一個新的地方來編寫你的更新代碼--實體類内部。EF Core 有一個叫做後援字段(backing fields)的功能,它使建構 DDD 實體成為可能。後援字段允許你控制對任何關系結構的通路。這在 EF6.x 中其實是不可能的。

DDD 談到了聚合(前面提到過),所有的聚合隻能通過根實體中的方法來改變,我把它稱為通路方法。在 DDD 術語中,評論是圖書實體的聚合,是以我們應該通過圖書實體類中的一個名為 ​

​AddReview​

​​ 的通路方法來添加一個評論。這樣一來,上面的代碼就變成了 ​

​Book​

​ 實體中的一個方法,在這裡:

public Book AddReviewToBook(ReviewDto dto)
{
var book = _context.Find<Book>(dto.BookId);
    book.AddReview(dto.numStars, dto.comment,
         dto.voterName, _context);
    _context.SaveChanges();
return book;
}
      

​Book​

​​ 實體類中的 ​

​AddReview​

​ 通路方法看起來像這樣:

public class Book
{
private HashSet<Review> _reviews;
public IEnumerable<Review> Reviews => _reviews?.ToList();
//...other properties left out

//...constructors left out

public void AddReview(int numStars, string comment,
string voterName, DbContext context = null)
    {
if (_reviews != null)
        {
            _reviews.Add(new Review(numStars, comment, voterName));
        }
else if (context == null)
        {
throw new ArgumentNullException(nameof(context),
"You must provide a context if the Reviews collection isn't valid.");
        }
else if (context.Entry(this).IsKeySet)
        {
            context.Add(new Review(numStars, comment, voterName, BookId));
        }
else
        {
throw new InvalidOperationException("Could not add a new review.");
        }
    }
//...
}
      

這個方法更複雜,因為它可以處理兩種不同的情況:一種是已經加載了 ​

​Review​

​,另一種是還沒有。但如果評論還沒有被加載,它比原來的情況要快,因為它使用了“通過外鍵建立關系”的方法。

因為通路方法的代碼在實體類裡面,如果需要的話可以更複雜,因為它将是你需要寫的唯一版本的代碼。在方式一中,你可以在不同的地方重複相同的代碼,隻要你需要更新 ​

​Book​

​​ 的 ​

​Review​

​ 集合。

注:我寫了一篇名為“Creating Domain-Driven Design entity classes with Entity Framework Core”的文章,全部都是關于 DDD 風格的實體類。這篇文章對這個話題有更詳細的介紹。我還更新了關于如何用 EF Core 編寫業務邏輯的文章,以使用同樣的 DDD 風格的實體類。

為什麼實體類中的方法不調用 ​

​SaveChanges​

​​?在方式一中,一個方法包含了所有的部分:a)加載實體,b)更新實體,c)調用 SaveChanges 來更新資料庫。我可以這樣做,因為我知道它是由一個網絡請求調用的,而這就是我想做的全部。對于 DDD 實體方法,你不能在實體方法中調用 ​

​SaveChanges​

​​,因為你不能确定操作已經完成。例如,如果你從備份中加載一本書,你可能想建立這本書,添加作者,添加任何評論,然後調用 ​

​SaveChanges​

​,這樣所有的東西都在一起被送出到資料庫。

方式三:GenericServices 庫

還有第三種方式。我注意到在我建構的 ASP.NET 應用程式中使用 CRUD 指令時有一個标準模式,早在 2014 年我就建立了一個名為 ​

​GenericServices​

​​ 的庫,它可以與 EF6.x 一起使用。2018 年我為 EF Core 建立了一個更全面的版本,名為 ​

​EfCore.GenericServices​

​​,見這篇關于 ​

​EfCore.GenericServices​

​ 的文章:

GenericServices: A library to provide CRUD front-end services from a EF Core database
https://www.thereformedprogrammer.net/genericservices-a-library-to-provide-crud-front-end-services-from-a-ef-core-database/
      

這些庫并沒有真正實作倉儲模式,而是在實體類和前端需要實際資料之間充當擴充卡模式。我曾使用過原始的 EF6.x,​

​GenericServices​

​​ 為我節省了幾個月的枯燥的前端代碼編寫。新的 ​

​EfCore.GenericServices​

​ 甚至更好,因為它可以與标準風格的實體類和 DDD 風格的實體類一起工作。

哪一個方式更好

方式一(直接使用 EF Core 代碼)要寫的代碼最少,但有可能出現重複,因為應用程式的不同部分可能要對一個實體應用 CUD 指令。例如,當使用者通過改變事物時,你可能會通過 ​

​ServiceLayer​

​​ 進行更新,但外部 API 可能不會通過 ​

​ServiceLayer​

​,是以你必須重複 CUD 代碼。

方式二(DDD 風格的實體類)将關鍵的更新部分放在實體類内,是以代碼對任何能得到實體執行個體的人都是可用的。事實上,由于 DDD 風格的實體類“鎖定”了對屬性和集合的通路,每個人都必須使用 ​

​Book​

​​ 實體的 ​

​AddReview​

​​ 通路方法,如果他們想更新 ​

​Review​

​ 集合的話。出于許多原因,這是我想在未來的應用中使用的方法(見我的文章中關于利弊的讨論)。其(輕微的)缺點是它需要一個單獨的加載/儲存部分,這意味着更多的代碼。

方式三(GenericServices 庫)是我的首選方法,尤其是現在我已經建立了 ​

​EfCore.GenericServices​

​​ 版本,可以處理 DDD 風格的實體類。正如你在關于 ​

​EfCore.GenericServices​

​ 的文章中所看到的,這個庫極大地減少了你在 Web/移動/桌面應用程式中需要編寫的代碼。當然,你仍然需要在你的業務邏輯中通路資料庫,但這是另一回事。

7組織你的 CRUD 代碼

Rep/UoW 模式的一個好處是,它将你所有的資料通路代碼放在一個地方。當換成直接使用 EF Core 時,你可以把你的資料通路代碼放在任何地方,但這使得你或其他團隊成員很難找到它。是以,我建議對你的代碼放在哪裡有一個明确的計劃,并堅持下去。

下圖展示了一個分層或六邊形的架構,隻展示了三個程式集(我省去了業務邏輯,在六邊形的架構中,你會有更多的程式集)。顯示的三個程式集是:

  • ASP.NET Core。這是表現層,提供 HTML 頁面或一個網絡 API。這沒有資料庫通路代碼,但依賴于 ​

    ​ServiceLayer​

    ​ 和 ​

    ​BusinessLayer​

    ​ 中的各種方法。
  • 服務層。它包含資料庫通路代碼,包括查詢對象和新增、更新和删除方法。服務層使用擴充卡模式和指令模式來連接配接資料層和 ASP.NET Core(表現)層。
  • 資料層。它包含了應用程式的 ​

    ​DbContext​

    ​ 和實體類。然後,DDD 風格的實體類包含通路方法,以允許根實體及其聚合體被修改。
倉儲模式是否依然适用于EF Core?
注:前面提到的庫 ​

​GenericServices​

​​(EF6.x)和 ​

​EfCore.GenericServices​

​​(EF Core)實際上是一個提供 ​

​ServiceLayer​

​​ 功能的庫,即在 ​

​DataLayer​

​ 和你的 Web/移動/桌面應用程式之間充當擴充卡模式和指令模式。

從這個圖中我想說的是,通過使用不同的程式集,一個簡單的命名标準(見圖中黑體字 ​

​Book​

​)和檔案夾,你可以建立一個應用程式,其中你的資料庫代碼是獨立的,很容易找到。随着你的應用程式的增長,這可能是至關重要的。

8EF Core 方法單元測試

最後要看的部分是對使用 EF Core 的應用程式進行單元測試。倉儲模式的優點之一是你可以在測試時用一個模拟來代替它。是以,直接使用 EF Core 就失去了模拟的選擇(技術上你可以模拟 EF Core,但很難做得好)。

值得慶幸的是,現在的 EF Core 已經有了進步,你可以用記憶體資料庫來模拟資料庫了。記憶體資料庫的建立速度更快,而且有一個預設的起始點(即,空),是以針對它編寫測試要容易得多。參見我的文章“Using in-memory databases for unit testing EF Core applications” 詳細了解如何做到這一點,另外還有一個名為 ​

​EfCore.TestSupport​

​ 的 NuGet 包,它提供了一些方法,使編寫 EF Core 單元測試更加快速。

9結論

我上一個使用 Rep/UoW 模式的項目要追溯到 2013 年,從那以後我再也沒有使用過 Rep/UoW 模式。我嘗試過一些方法,一個基于 EF6.x 名為 ​

​GenericServices​

​​ 的自定義庫,以及現在一個更标準的基于 EF Core 實作查詢對象和 DDD 風格的實體類方法的 ​

​EfCore.GenericServices​

​ 自定義庫。它們使得編寫代碼更容易,而且通常表現良好。但如果它們很慢,就很容易定位并對單個資料庫通路進行性能調整。

繼續閱讀