1. 程式人生 > >如何運用領域驅動設計 - 儲存庫

如何運用領域驅動設計 - 儲存庫

目錄

  • 概述
  • 直接看東西
  • 被廣泛使用的倉儲
    • 倉儲是反模式嗎
  • 什麼是儲存庫
  • 如何運用儲存庫
    • 儲存庫是為聚合提供操作
    • 儲存庫對外提供哪些方法
    • 不要使用過多特性干擾您的領域物件
    • 不要為了顯示而使用儲存庫
    • 工作單元
  • 持久化中的困難
  • 總結

概述

在上一篇文章中,我們已經瞭解過領域驅動設計中一個很核心的物件-聚合。在現實場景中,我們往往需要將聚合持久化到某個地方,或者是從某個地方創建出聚合。此時就會使得領域物件與我們的基礎架構產生緊密的耦合,那麼我們應該怎麼隔絕這一層耦合關係,使它們自身的職責界限更加清晰呢?是的,這就要用到我們今天要講的內容 - 儲存庫。在很多地方,我們喜歡叫它為倉儲,特別是在現有的AspNetCore應用中,大量的應用都在引入Repository這種東西。那麼究竟什麼是儲存庫呢?我們現在的使用方式是正確的嗎?它在領域驅動設計中又扮演著怎樣的角色呢?本文將從不同的角度來帶大家重新認識一下“儲存庫”這個概念,並且給出相應的程式碼片段(本教程的程式碼片段都使用的是C#,後期的實戰專案也是基於 DotNet Core 平臺)。

直接看東西

“少囉嗦,直接看東西”。是的,在本次的文章中,居然!居然!居然! 附帶了Github的程式碼。本次程式碼其實是演示工作單元的實現,但是它確實又結合了儲存庫的一些內容,所以就在這裡提供給大家參考。

GitHub 地址,點選直達喲

這是一個工作單元的超簡易版本,您可以在github中看到它的描述和簡介,這裡我就不再重複了。下一次的文章會對工作單元的實現進行解析和優化,可能它就不屬於 《如何運用領域驅動設計》 系列的正傳系列了(算個番外吧 ( ̄▽ ̄)")。所以為了您不錯過這一部分,可以點選部落格園右上角的關注,有了動態之後就能夠第一時間收到啦!

哦,對了!在Github程式碼中,您可能會看到一個叫做MiCake(米蛋糕)的東西,它是我們一步一步實現的DDD元件,它會讓您的 aspnet core 應用更輕鬆的融合DDD的思想,並且它包含了我們該系列博文中所提到的所有戰略元件,以及它們之間的約束和處理。

被廣泛使用的倉儲

是的,說儲存庫模式您可能還不能一下想到這是個什麼東西,但是一說到倉儲,您可能就會有一種豁然開朗的感覺:“哦!就是這個東西呀!”。回顧一下,您現有的AspNet Core專案,是否已經引入了一個叫做Repository的物件,並且它為您提供了與資料基礎架構互動的方法。

彷彿從某一天開始,以往我們使用的BLL,DLL這種東西就逐漸開始消失了,替換它們的是一個叫做Repository的東西。特別是從傳統的AspNet演化為AspNetCore的階段,大量的應用都開始使用倉儲了,即使您在使用類似於EF這樣的ORM框架。

倉儲是反模式嗎

關於儲存厙模式存在非常多的誤解和混淆,許多人認為它是多餘的儀式以及不必要的抽象,它隱藏了底層持久化框架的能力。特別是當您正在使用類似於Entity FrameWork Core這樣的ORM框架的時候,您是否發現明明EFCore直接就可以實現的東西,為什麼我又在它的基礎上套了一層,而且這一層中我並沒有執行任何邏輯,只是簡單的呼叫DbContext(EF中的資料上下文)這種東西。那為什麼我不能直接呼叫DbContext呢?是的,這樣的疑問相信不止很多同學都遇到了。所以在微軟EF Core 3.x的官方教程中,提到了這樣的一句話:

該內容位於 ASP.NET Core 官方教程 - 資料訪問 - 高階教程 中。

那麼我們真的不需要儲存庫這種東西嗎?答案是否定的,至少在實踐領域驅動設計的應用中。還記得在上一篇文章 如何運用領域驅動設計 - 聚合 中,我們不止一次的提到了倉儲這個概念,因為它是為聚合而服務的,而隨著領域的深入,使得領域模型越來越複雜的時候,儲存庫將慢慢變成模型的擴充套件,它將描述您每一個用例檢索聚合的意圖。

思考一下,您現有的應用中是否包含了一個全能的ORM框架(比如EF),那您引入倉儲的原因是什麼呢?

什麼是儲存庫

好吧,這次的開篇太長了,終於回到了正題:什麼是儲存庫? 原著《領域驅動設計:軟體核心複雜性應對之道》 中對儲存庫的有關解釋:

為每種需要全域性訪問的物件型別建立一個物件,這個物件就相當於該型別的所有物件在記憶體中的一個集合的“替身”。通過一個眾所周知的介面來提供訪問。提供新增和刪除物件的方法,用這些方法來封裝在資料儲存中實際插入或刪除資料的操作。提供根據具體標準來挑選物件的方法,並返回屬性值滿足查詢標準的物件或物件集合(所返回的物件是完全例項化的),從而將實際的儲存和查詢技術封裝起來。只為那些確實需要直接訪問的Aggregate提供Repository。讓客戶始終聚焦於型,而將所有物件儲存和訪問操作交給Repository來完成。

國際慣例,讓我們來看看這一段話大致講了什麼。Repository提供了一個增刪改查的操作,它抽象了資料訪問的部分。是的,這個理解是很正確的,因為這是儲存庫很重要的特性。所以有很多同學就開始瘋狂的使用儲存庫了,在專案中大量的引入Repository,而巢狀於ORM之上。

但是!!!!! 我們忽略了上面的其它幾點:“確實需要直接訪問的Aggregate提供Repository” ,“提供根據具體標準來挑選物件” 。 注意,這很重要,下文將一一為大家解釋。

如何運用儲存庫

儲存庫是為聚合提供操作

這一點是非常關鍵的,儲存庫是為聚合而服務的。有關於聚合的部分,可以檢視上一篇文章 如何運用領域驅動設計 - 聚合。為什麼呢它一定要為聚合服務? 它不能為實體服務嗎? 因為聚合是一個整體,在上一文中我們已經說過了,當凝練出一個聚合根的時候,就證明外界只能通過聚合根來訪問聚合內的實體,所以我們沒有理由在任何一個地方需要穿透聚合根去訪問實體,這是錯誤並且沒有意義的。那麼很自然的就可以衍生出:我們什麼時候需要使用儲存庫單獨來提取實體呢?好像確實沒有。不過有的同學會說了,我在做**報表的時候,我就確實需要只訪問某個實體呀?那麼請思考兩個點:1、該實體是否需要提升為聚合根。 2、如果是廣泛查詢的報表,可能並不需要通過倉儲來獲取物件,需要專門的查詢框架來完成。

因此,我們建立出來的倉儲的介面可能是這個樣子的:

public interface IRepository<TAggregateRoot>
    where TAggregateRoot : class, IAggregateRoot
{
}

此處使用了C#的介面泛型約束,將倉儲的服務者約束為了一個聚合根。該程式碼在上文介紹的 MiCake 中您也可以看到。

儲存庫對外提供哪些方法

到目前為止,我們已經知道一個儲存庫至少應該包含根據ID來對聚合的增刪改查方法,可能有一些時候我們只需要查,不需要刪。但是就一個通用的儲存庫來說,它能具有這些方法是毫無疑問的。所以我們的倉儲介面可以增加一些通用方法:

public interface IRepository<TAggregateRoot>  
        where TAggregateRoot : class, IAggregateRoot
{
    TAggregateRoot Find(TKey Id);

    void Add(TAggregateRoot aggregateRoot);

    void Update(TAggregateRoot aggregateRoot);

    void Delete(TAggregateRoot aggregateRoot);
}

儲存庫是一個明確的約定

雖然儲存庫提供了基礎的提取方法,但是在許多場景下,我們可能更需要根據某種條件來從資料庫中讀取對應的模型並將其轉換為領域聚合物件。比如在之前的一篇文章 如何運用領域驅動設計 - 領域服務 中就有一個地方出現了使用儲存庫的情況:我們需要根據當前的位置來查詢附近的飯店:

var nearbyRestaurants = restaurantRepository.GetNearbyRestaurant(currentAddress);

採用了類似於這樣的寫法。該儲存庫對外提供了一個GetNearbyRestaurant的方法出來,外界的應用服務就可以通過該方法來獲取對應的結果。

這是一個很好的方法簽名,我們通過傳入一個當前位置就能夠獲取到附近的飯店。通過閱讀儲存庫提供出來的方法就能理解領域中的檢索意圖,從側面也反應了領域的某些用例。

但是,現在有部分的同學熱愛另外一種寫法:通過Lambda作為方法引數,傳遞給下層的ORM框架來進行查詢。該方法簽名類似於這樣:

IQueryable<TEntity> FindMatch(params Expression<Func<TEntity, object>>[] propertySelectors);

這樣做的好處是所有的儲存庫都可以複用這個介面,以後所有的查詢都可以通過使用該方來來完成,而不需要再去單獨寫各種Find方法。通過返回一個IQueryable物件,甚至可以將業務查詢邏輯直接放到應用層,這樣想怎麼操作就怎麼操作。

請注意!!!這非常的危險!!!! 您可能會問了:“我平時所接觸的框架或者倉儲不都是這樣寫的嗎?可以實現我任何的業務查詢,爽歪歪。” 但是這樣寫正在逐漸喪失儲存庫原有的作用。回到開篇提到的一個問題:假如使用了EF這樣的ORM框架,為什麼還需要巢狀一層倉儲呢? 而現在,您可能正在這樣做,開放且靈活的約定,再加上延遲的IQueryable物件,讓倉儲層完全喪失了原有的作用,它反而成了負擔,為什麼不直接使用DbContext物件呢? 為了倉儲而使用倉儲,為了看上去像DDD而DDD,那不是自己騙自己嗎?

所以請儘量避免在您的儲存庫中去寫這種靈活而沒有任何明確檢索意圖的方法介面,它可能確實會使您減少程式碼書寫量,但隨著專案的複雜和領域物件的逐漸增多,它會使您的應用層越來越迷惑。所以儲存庫中所提供的應該是具有明確約定的方法。

這裡我摘抄了 領域驅動設計模式、原理與實踐 中的一段話,我覺得它的描述非常好:

儲存庫不是一個物件。它是一個程式邊界以及一個明確的約定,在其上命名方法時它需要的工作量與領域模型中的物件所需的工作量一樣多。你的儲存庫約定應該是特定的以及能夠揭示意圖並對領域專傢俱有意義。

具有領域意圖的東西我們都應該領域層,而類似於資料庫的訪問實現這類基礎架構應該放在基礎設施層。所以可以看出我們抽象出來的倉儲介面是應該放在領域層的,而倉儲的實現可以放在基礎設施層 。這個問題有很多小夥伴可能迷惑了很久,我上次看到一位同學將倉儲介面放在了應用層,因為它認為和領域無關,認為倉儲只是一個提供增刪改查的東西。而這也是因為忽略了倉儲也是領域行為的一部分的結果。

審計追蹤

在前面講值物件的文章中,有一位園友問了我一個問題,有一點是:類似於CreateDate,CreateUser這種審計資訊,我們許多時候都會依附在領域物件身上,那麼是不是應該通過領域服務來做處理呢?

其實不然,它們雖然對我們有參考意義,其實並沒有在捕獲領域需求時捕獲出來。往往這類審計資訊都是我們按照以往的開發經驗所提煉出來的,所以它們對領域物件的影響很小。那麼我們又很需要去操作它們,比如持久化一個聚合根的時候,為它附帶上建立時間,這樣便於我們去追蹤它的一些記錄。而此時,就可以依賴我們的儲存庫來完成了,當聚合根在領域服務或者領域用例中已經完成了操作時,將它傳遞給儲存庫持久化之前就可以讓儲存庫為它加上審計資訊。

彙總

儲存庫有時還可以擁有對集合彙總的功能,比如上面我們提到了飯店的一個倉儲,可能我們在系統中想得到我係統中到底有多少個飯店,或者在某個區域有多少個飯店。這種彙總的功能您也可以交給儲存庫來完成,這也完美的符合“儲存庫”中“庫”的含義。但還是請注意,這些彙總的方法依然得擁有一個明確的約定格式,不要因為是彙總就將儲存庫寫的開放而過於靈活。

有時候您可能需要形成一個報表,該報表它包含了各個領域物件的彙總情況。在此時,該彙總的職責可能並不屬於儲存庫了,它需要您使用另外的方式來完成,該內容可以看下面的小節。

不要使用過多特性干擾您的領域物件

在持久化的過程中,現在的主流方式我們都會依賴於類似於EF Core這樣的ORM框架來完成。當我們需要將領域物件轉換為資料庫的資料物件(可以理解為表吧)時,可能有時候就需要表明什麼是主鍵,什麼具有約束等情況。如果您正在使用EF Core,對於 Data annotations 您可能再熟悉不過了,它提供了通過特性來標記的寫法完成對映關係:

public class CustomerWithoutNullableReferenceTypes
{
    public int Id { get; set; }
    [Required]            // Data annotations needed to configure as required
    public string FirstName { get; set; }
    [Required]
    public string LastName { get; set; }     // Data annotations needed to configure as required
    public string MiddleName { get; set; }   // Optional by convention
}

該程式碼摘自 EF Core 教程 - 必需和可選屬性

這種寫法很誘人,因為只需要簡單的在屬性上增加一個特性就完成了配置。但是!!!這些特性對領域物件其實是沒有必要的,它可能還會干擾您的閱讀。因為我們在構建領域物件的時候不應該考慮資料持久層面的問題,而構建出來的領域物件也應該保持乾淨。

在EFCore中,為我們提供了Fluent API的方式來配置模型,該方式可以很好的讓領域物件保持乾淨。假如您沒有使用EFCore,另外的ORM框架也一定會為您提供類似於這樣的配置方法。

不要為了顯示而使用儲存庫

很多場景我們可能需要提供一個豐富的介面,或者一個完整的報表。比如在一個介面上顯示了某個聚合中的一個實體的資訊,又或者在報表中提供了各個實體和值物件的彙總和特定資訊。在這個情況下,倉儲可能就顯得有點隆重了,我必須要通過A、B、C……倉儲獲取所有聚合A,B,C,然後再來處理彙總資訊。要麼就是將儲存庫的規則打破,直接查詢利用EF Core查詢出IQueryable集合物件,然後一頓輸出猛如虎來達到效果。

記住不要為了使用DDD而讓您的開發變得複雜而不順手,在這個時候我們甚至可以不使用儲存庫,我們可以利用另外的框架來直接查詢資料庫,也或者是使用ADO.NET運用原生Sql來達到查詢的效果。還有一種方法是將查詢單獨劃分為應用系統的一個分支,將修改(命令)單獨劃分為另外一個分支來操作領域物件。這是DDD的另外一種模式,可能您已經聽過它的英文簡寫了:CQRS。該模式的內容會在後期的文章中為大家介紹,MiCake後期也會增加對CQRS的支援。

工作單元

在持久化的過程中,我們必須保證一個聚合的所有的部分一同保持成功,或者一個用例的多個聚合同時儲存成功(在分散式中可能只能追求最終一致性)。所以我們必須得保證儲存庫是有事務的,而事務的管理是由工作單元來提供的。這也是為什麼儲存庫每次都和工作單元這一概念一同出現。下面引用了微軟AspNet中的一張圖,方便您理解工作單元(UnitOfWork):

該圖片選取自 微軟 AspNet 教程 - 實現儲存庫和工作單元模式

本章附帶了關於工作單元和倉儲介面的演示程式碼,關於工作單元的部分會在下篇文章為大家介紹。

持久化中的困難

關於持久化的問題已經是一個老生常談的話題了,在一篇關於值物件的博文中就已經說明了這個問題。如何將領域物件如何通過ORM來持久化到資料庫?在回答這個問題之前,我們得先理解一下什麼是領域模型和資料模型:領域模型是問題域的抽象,富含行為和語言;資料模式是一種包含指定時間領域模型狀態的儲存結構,ORM可以將特定的物件(C#的類)對映到資料模型。資料模型和領域模型無關,儲存庫的作用就是保持這兩個模型的獨立並且不讓它們變得模糊不清。

也就是說我們在設計領域模型時應該僅僅關心領域中的物件,千萬不要讓框架(比如ORM)來驅動你的設計。關於這一點給了我一點靈感:既然我們只關心領域物件,那在持久化的時候能不能單獨建立一個持久化物件專門供ORM去對映到資料庫,而倉儲負責了聚合建立和儲存的過程,在這個過程中讓倉儲自動去完成領域物件到持久化物件的轉換就行了。關於這個實現方法,準備在下下一起番外系列中為大家介紹,可能MiCake也會預設支援該方法來完成領域物件的持久化任務。當然,因為是番外的系列,所以為了您不錯過這一部分,可以點選部落格園右上角的關注。( 好吧,我又把上面的話不要臉的又複製了一遍 (ง •_•)ง)

總結

本次我們介紹了有關領域驅動設計中“儲存庫”的內容,我們知道了什麼是儲存庫,以及如何去使用一個儲存庫。由於儲存庫屬於一個很基礎的概念,所以在該章節中我們沒有使用旅行記賬的案例來為大家介紹。而更多的是希望大家能夠理解使用儲存庫的場景和規範,畢竟現在儲存庫模式是很常用的一個模式,如果只知其然而不知其所以然的去使用儲存庫模式,不僅體驗不到它的益處,反而會讓程式碼變得越來越複雜。

最後,提前祝大家元旦快樂。 (o゚v゚)ノ