昨天『.NET 大牛之路』群裡的小夥伴們談論了使用 EF Core 實現倉儲模式的話題,我想起以前看過一篇一名國外大佬寫的文章,覺得非常有參考價值,今天進行了翻譯,供大家賞鑑。
原文:https://www.thereformedprogrammer.net/is-the-repository-pattern-useful-with-entity-framework-core/
作者:Jon P Smith
翻譯:精致碼農-王亮
說明:原文首次發布於 2018 年 2 月,最後更新於 2020 年 7 月。
正文:
我在 2014 年寫了第一篇關於倉儲模式的文章,它仍然是一篇很受歡迎的文章。而這一篇文章是那篇文章的更新版,基於這幾年 EF Core 新的發布和對 EF Core 資料庫訪問模式的進一步研究。
Original: Analysing whether Repository pattern useful with Entity Framework (2014 年 5 月).
https://www.thereformedprogrammer.net/is-the-repository-pattern-useful-with-entity-framework/
First solution: Four months on – my solution to replacing the Repository pattern (2014 年 10 月).
https://www.thereformedprogrammer.net/is-the-repository-pattern-useful-with-entity-framework-part-2/
THIS ARTICLE: Is the repository pattern useful with Entity Framework Core?
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
我使用 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 模式的優點和缺點。以下是我的觀點。
5.1 Rep/UoW 模式的優點
隔離你的資料庫訪問程式碼。倉庫模式的最大優點是你知道你所有的資料庫訪問程式碼在哪裡。另外,你通常會把你的倉儲庫分成幾個部分,如目錄庫、訂單處理庫等,這使得你很容易找到有錯誤或需要效能調整的特定查詢的程式碼。這無疑是一個很大的優點。
聚合(Aggregation)。領域驅動設計(DDD)是一種設計系統的方法,它建議你有一個根實體,其他相關的實體被歸入它。我在《Entity Framework Core in Action》一書中使用的例子是一個帶有 Review 實體集合的 Book 實體。這些 Review 只有在與 Book 相聯繫時才有意義,所以 DDD 說你應該只透過 Book 實體來改變 Review。Rep/UoW 模式透過提供一種方法在 Book Repository 中將評論添加/刪除中來實現這一點。
隱藏複雜的 T-SQL 命令。有時你需要繞過 EF Core,使用 T-SQL。這種類型的訪問應該從高層隱藏起來,但又容易找到,以幫助維護或重構。我應該指出,Rob Conery 的帖子 Command/Query Objects 也可以處理這個問題。
易於模擬/測試。很容易模擬一個單獨的資源庫,這使得訪問資料庫的單元測試程式碼更容易。這在若干年前是真的,但現在這有其他的方法來解決這個問題,我將在後面介紹。
你會注意到,我沒有提出「用另一個資料庫訪問庫替換 EF Core」。這是 Rep/UoW 背後的想法之一,但我認為這是一個誤解,因為 a)很難替換一個資料庫訪問庫,b)你真的會在你的應用程式中交換這樣一個關鍵庫嗎?
5.2 Rep/UoW 模式的缺點
前三的項目都是圍繞著效能。我並不是說你不能寫一個高效的 Rep/UoW,但它是一項艱難的工作,而且我看到許多實現都有內在的效能問題(包括微軟舊的 Rep/UoW 實現)。以下是我在 Rep/UoW 模式中發現的缺點清單。
效能--處理實體關係。一個資源庫通常會返回一種類型的
IEnumerable/IQueryable結果,例如在微軟的例子中的一個 Student 實體類。假設你想從 Student 的關係中顯示資訊,比如他們的地址?在這種情況下,倉儲庫中最簡單的方法是使用懶加載來讀取學生的地址實體,我看到人們經常這樣做。問題是懶加載會導致每一個關係都要單獨往返於資料庫,這比把所有的資料庫訪問合併成一次資料庫往返要慢。(另一種方法是有多個不同返回類型的查詢方法,但這將使你的資源庫變得非常大和麻煩--見第 4 點)。資料不符合要求的格式。因為倉儲組件通常是根據資料庫建立的,返回的資料可能不是服務或用戶需要的确切格式。你也許可以調整倉儲庫的輸出,但這是你必須要寫的第二個階段。我認為更好的做法是在靠近前端的地方形成你的查詢,並包括你需要資料的任何調整。
效能--更新:許多 Rep/UoW 的實現試圖隱藏 EF Core,但這樣做並沒有利用它的所有功能。例如,Rep/UoW 會使用 EF Core 的 Update 方法更新一個實體,該方法會保存實體中的每個屬性。而使用 EF Core 內置的變化跟蹤功能,它將只更新已經改變的屬性。
太通用了。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 時,這些模式和實踐與良好的架構設計相結合,提供同樣的隔離、聚合等功能。
我將解釋每一個優點的實現,然後把它們放到一個分層的軟體架構中。
- 查詢物件:一種隔離和隱藏資料庫讀取的方法
資料庫訪問可分為四種類型。新增、讀取、更新和刪除--被稱為 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>。
- 新增、更新和刪除資料庫訪問方法
查詢物件處理了 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
這些庫並沒有真正實現倉儲模式,而是在實體類和前端需要實際資料之間充當適配器模式。我曾使用過原始的 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 風格的實體類包含訪問方法,以允許根實體及其聚合體被修改。

註:前面提到的庫
GenericServices(EF6.x)和EfCore.GenericServices(EF Core)實際上是一個提供ServiceLayer功能的庫,即在DataLayer和你的 Web/行動/桌面應用程式之間充當適配器模式和命令模式。
從這個圖中我想說的是,透過使用不同的組件,一個簡單的命名標準(見圖中粗體字 Book)和資料夾,你可以建立一個應用程式,其中你的資料庫程式碼是獨立的,很容易找到。隨著你的應用程式的增長,這可能是至關重要的。
8 EF 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 自訂庫。它們使得編寫程式碼更容易,而且通常表現良好。但如果它們很慢,就很容易定位並對單個資料庫訪問進行效能調整。
在我為 Manning 出版社寫的書中,有一章是對一個「賣」書的 ASP.NET Core 應用程式進行效能調整。這個過程使用了查詢物件和 DDD 實體方法,並表明它可以產生效能很好的資料庫訪問(見我的文章「 Entity Framework Core performance tuning – a worked example」)。
我自己的工作是在使用查詢物件進行讀取,並使用 DDD 風格的實體類及其 CUD 和業務邏輯的訪問方法。我確實需要在一個適當的應用中使用這些東西,才能真正知道它們是否有效。請關注我的部落格,了解更多關於 DDD 風格的實體類,以及從中受益的架構,也許還會有一個新的庫:)。
編碼愉快!