倉儲模式是否依然適用於ef core?

倉儲模式是否依然適用於ef core?

昨天『.net 大牛之路』群里的小夥伴們談論了使用 ef core 實現倉儲模式的話題,我想起以前看過一篇一名國外大佬寫的文章,覺得非常有參考價值。

最后更新 2022/5/4 下午4:04
liamwang 精致码农
预计阅读 21 分钟
分类
EF Core
标签
.NET C# EF Core ORM

昨天『.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 資料庫訪問模式的進一步研究。

  1. original: analysing whether repository pattern useful with entity framework (2014 年 5 月).

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

  2. 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/

  3. 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

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 模式的優點和缺點。以下是我的觀點。

5.1 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)你真的會在你的應用程式中交換這樣一個關鍵庫嗎?

5.2 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 方法时,这些命令会被执行。在我的简单例子中,我没有使用数据库,但如果我们用应用程序的 DbContextDbSet<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>

  1. 新增、更新和刪除資料庫訪問方法

查询对象处理了 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,另一种是还没有。但如果评论还没有被加载,它比原来的情况要快,因为它使用了“通过外键创建关系”的方法。

因为访问方法的代码在实体类里面,如果需要的话可以更复杂,因为它将是你需要写的唯一版本的代码。在方式一中,你可以在不同的地方重复相同的代码,只要你需要更新 BookReview 集合。

註:我寫了一篇名為“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 风格的实体类一起工作。

  1. 哪一個方式更好

方式一(直接使用 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 風格的實體類,以及從中受益的架構,也許還會有一個新的庫:)。

編碼愉快!

Keep Exploring

延伸阅读

更多文章