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