在領域驅動設計(DDD)的案例中,倉儲及其上下文都是開發人員學習和討論的重點。對這兩個內容的討論,大致包含兩個方面:第一個方面是有關倉儲及其上下文在整個應用程序架構中的位置;第二個方面,則是倉儲及其上下文的設計與具體技術實現。我將在本文中,結合Byteart Retail案例,對這兩個內容進行討論。
倉儲及其上下文在整個應用程序架構中的位置
倉儲是DDD中管理對象生命周期的一個重要組件。在面向對象的世界里,不僅僅是DDD,甚至是整個軟件設計和開發過程,都離不開對象生命周期的管理:對象的創建、持久化(Persistence)、反持久化(Materialize)以及銷毀,每種管理任務都對對象的狀態造成影響。在傳統的應用程序開發中,我們會使用類似DAO(Data Access Object)的類型來實現對象的持久化、反持久化操作,或者會使用Finder/Mediator來完成類似的任務。在DDD中,同樣存在着兩種與對象生命周期管理任務相關的組件,它們就是倉儲和工廠。與DAO、Finder/Mediator所不同的是,倉儲的實現更為限定在對整個聚合的操作上(事實上工廠也是如此),通過對聚合根的引用來完成整個聚合的持久化、反持久化操作;而DAO、Finder/Mediator則隨意性更強:它們的設計可以是面向DTO(Data Transfer Object)的,也可以是直接面向數據庫的。
剛剛提到,倉儲的實現需要限定在對整個聚合的操作上(工廠也是如此),因此,不管是從持久化機制讀取對象,還是將對象保存到持久化機制,都需要通過聚合根,以聚合為單位。根據DDD不難理解,聚合是領域模型的重要內容,而在整個應用程序的架構中,領域模型是屬於領域層的,於是,倉儲也是領域層的一個組成部分。
前不久,有網友向我詢問這樣的問題:如果說倉儲是領域層的一個組成部分,但是倉儲的實現往往需要涉及到很多技術層面上的東西,比如如果采用關系型數據庫作為對象持久化機制,那么倉儲的實現就需要封裝類似ORM的功能,這樣做豈不是使得領域層需要依賴這些技術的具體實現,從而使得兩者之間緊密耦合?
對於這個問題的回答,我想應該從兩個方面考慮。首先,領域層和領域模型是兩個概念,前者是應用程序架構中的一種分層,而后者則是應用程序的業務核心組件。領域模型定義在領域層中,領域層中還能包含諸如倉儲、工廠、服務等組件,一方面輔助領域模型完成完整的業務處理需求,另一方面為領域模型提供生命周期管理。因此,即使領域層耦合了其它基礎結構組件,它也能通過合理的模式應用,將這些實現細節從領域模型中剝離開來,以保證領域模型的純凈度;其次,即使可以解除領域模型與基礎結構組件的耦合關聯,我們也不應該使領域層也直接依賴這些組件,否則,我們得到的后果是,當基礎結構組件發生改變時,整個領域層組件將變得不再可用,我們不得不對領域層也進行重構,以適應新的接口需求。
綜上所述,一方面,倉儲的操作對象是領域模型中的聚合,無論是從DDD的實踐思路上,還是從倉儲與領域模型之間的關系上考慮,倉儲都應該屬於領域層,然而與領域模型不同,倉儲需要通過基礎結構組件的支持來提供服務,因此倉儲又將依賴於這些組件。這就使得開發人員在設計應用程序架構的時候,對於倉儲的部分具體應該如何設計產生了疑惑。
合理的做法是,將倉儲的接口定義和具體實現分開處理,倉儲接口定義在領域層,而倉儲的具體實現則划分到領域層之外(注意,這里可以理解為將倉儲的具體實現划分到基礎結構層,也可以理解為架構的一種外部插件的實現)。具體到.NET應用程序架構,倉儲接口定義在領域層的程序集中,倉儲的具體實現則同時引用領域層程序集和基礎結構組件程序集,以實現倉儲接口。這里或許又會引來一個新的問題:既然倉儲的具體實現引用了領域層的程序集,那領域層如何調用倉儲呢?再去引用倉儲的具體實現,豈不是造成了循環引用?我的答案是:領域層不需要,也不應該引用倉儲的具體實現,倉儲的具體實現應該以依賴注入(Dependency Injection)的方式,在應用層中獲得,並由應用層通過倉儲來完成領域對象管理和任務協調(比如:通過啟用分布式事務來保證倉儲和服務總線之間的事務性)。
下圖來自於微軟的DDD分層架構案例:Microsoft NLayerApp,從圖中的彩色高亮部分可以看到,倉儲接口和倉儲實現分別位於領域層和基礎結構層:
根據以上總結,我大致描繪了一下.NET解決方案中各層的程序集(Assembly)之間的引用關系,以供參考。
在Byteart Retail案例中,倉儲接口定義在ByteartRetail.Domain程序集中,而倉儲的實現部分則寫在了ByteartRetail.Domain.Repositories程序集中,以下是Visual Studio 2012中解決方案資源管理器下的項目結構,我用數字對四個主要部分做了標注:1、領域層的所有內容都定義在ByteartRetail.Domain程序集中;2、在該程序集的Repositories目錄(命名空間)下,定義了倉儲的接口(事實上還包含了倉儲上下文的接口定義);3、倉儲的具體實現部分寫在了ByteartRetail.Domain.Repositories程序集中,該程序集引用了ByteartRetail.Domain程序集;4、在ByteartRetail.Domain.Repositories程序集中提供了針對Entity Framework的倉儲實現。
接下來,再讓我們一起了解一下,Byteart Retail案例中,基於Entity Framework的倉儲實現。
倉儲及其上下文的設計與實現
倉儲的實現其實網上有很多相關的資料,有基於NHibernate的倉儲實現,也有基於Entity Framework Code First的,在我自己開發的面向領域驅動的應用程序開發框架Apworks中,就提供了基於三種技術的倉儲實現:NHibernate、Entity Framework Code First以及MongoDB。相對而言,網文中所提供的一些解決方案雖然簡單有效,但與實際項目應用之間還是有一定的差距。比如,對於EF Code First的實現,在很多文章中,都是直接在倉儲的泛型基類中封裝了DbContext對象,這樣做可以完成一般性的事務處理需求,但需要注意的是,由於DbContext對象被封裝在泛型類中,因此,這種事務性只能應用在對某個特定聚合的倉儲操作上,例如:Repository<Customer>可以保證所有針對Customer聚合的倉儲操作都在同一個事務處理范圍內,而Repository<SalesOrder>則可以保證所有針對SalesOrder聚合的倉儲操作都在另一個事務處理范圍內。從DDD的應用層角度看,由於應用層服務負責任務協調,而多個任務很有可能需要在同一事務下完成,如果某個任務需要同時更新Customer及其相關的SalesOrder,那么,將DbContext限定在倉儲的泛型類中,顯然無法完成這樣的設計需求。
為了解決這個問題,Byteart Retail案例和Apworks框架都引入了倉儲上下文(Repository Context)的概念,Repository Context負責事務處理,每一個Repository的實現都會被關聯到一個Repository Context上,以便來自不同倉儲的操作能夠被限定在同一個事務中。具體地說,在這種設計下,應用層服務只需要通過服務定位器來獲得一個Repository Context的實例,就能夠保證后續的倉儲操作都是在該Repository Context所管理的同一個事務之中:由於服務定位器的使用,應用層服務在獲得Repository實例的同時,會通過服務定位器來解析獲得Repository Context,因此,只要在IoC容器中注冊Repository Context類型時,使用了合理的生命周期管理器(Lifetime Manager),就能確保所有Repository<T>類型中所使用的Repository Context是同一個實例,於是,當應用層服務完成任務處理之后,直接使用Repository Context的Commit方法,即可將事務一次提交。
從實現上看,倉儲上下文應用了Unit Of Work模式[PoEAA],鑒於主流ORM框架都具有對象狀態托管功能,因此,倉儲上下文的實現基本上也都是對ORM會話組件(比如NHibernate Session或者EF DbContext)的封裝。當然這樣的封裝會有一定的風險性,以NHibernate為例,由於Session對象並不是線程安全的,因此盡量不要跨線程使用Session;更進一步,由於倉儲上下文是對這些會話組件的封裝,所以,在使用倉儲上下文時也應該遵循一些最佳操作條款,比如盡量不要使用單件(Singleton)模式來創建和使用Repository Context,除非你對你的設計有着十足的把握。下面的UML類圖體現了在Byteart Retail中,Repository和Repository Context相關的類型定義以及這些類型之間的關系,到目前為止,我們的討論還處於抽象層面,並沒有引入與NHibernate或者Entity Framework相關的類型定義。(注意:圖中僅展示了所涉及的類型及其關系,為了簡化圖形,類型中方法和屬性的定義並不一定與Byteart Retail案例的源代碼完全一致,如有出入,以源代碼為准)
接下來,我將討論在Byteart Retail中基於Entity Framework Code First的倉儲設計和實現細節。在這部分討論中,我不會過多地涉及EF Code First的用法,需要了解如何在應用程序開發中使用EF Code First的讀者,請直接參考Byteart Retail的源程序代碼。更多地,我會把重點放在架構設計部分,讓讀者充分了解到選擇這樣一種架構的好處。
基於Entity Framework Code First的倉儲設計和實現
倉儲的實現是多樣化的,總體上講,還是根據項目本身的實際情況而定。比如基於NoSQL的倉儲實現所采用的技術,就與基於關系型數據庫的倉儲實現所采用的技術不同;即使是關系型數據庫,使用不同的ORM,也會造成倉儲實現上的差異,不難理解,基於NHibernate的倉儲實現和基於EF Code First的倉儲實現之間就有着一定的區別。不過無論如何,如果采用上文給出的倉儲及其上下文的設計能夠滿足項目需求的話,我們總是可以在這個框架的基礎上進行擴展,以實現面向特定技術的倉儲及其上下文組件。
在Byteart Retail中,我選用了EF Code First作為ORM,實現了倉儲(Repository)和倉儲上下文(Repository Context),先來看看Repository Context。從技術實現角度分析,基於EF Code First的Repository Context封裝了DbContext,這跟上文中的分析是一致的,從設計和框架應用的角度分析,基於EF Code First的Repository Context需要實現IRepositoryContext的接口,以便當服務定位器在解析並提供IRepositoryContext類型實例的時候,能夠返回我們的EF Repository Context。為了提供一定的擴展性,我在Byteart Retail的ByteartRetail.Domain.Repositories程序集中引入了一個新的接口:IEntityFrameworkRepositoryContext,在這個接口中,向外界公開了訪問DbContext的屬性:
/// <summary> /// 表示繼承於該接口的類型,是由Microsoft Entity Framework支持的一種倉儲上下文的實現。 /// </summary> public interface IEntityFrameworkRepositoryContext : IRepositoryContext { #region Properties /// <summary> /// 獲取當前倉儲上下文所使用的Entity Framework的<see cref="DbContext"/>實例。 /// </summary> DbContext Context { get; } #endregion }
由於Repository類本身引用了Repository Context,因此,對於EF Repository而言,它能夠很方便地通過這個DbContext屬性來實現基於EF的倉儲操作(CRUD相關的操作)。至於IEntityFrameworkRepositoryContext接口的具體實現,我就不多探討了,讀者朋友請直接參考ByteartRetail.Domain.Repositories.EntityFramework命名空間下的EntityFrameworkRepositoryContext類的源代碼。
接下來是基於EF Code First的倉儲設計。倉儲設計相對簡單,不需要引入新的接口,只需要繼承上文所設計的Repository抽象類即可,當然,為了能夠在倉儲中使用EF的DbContext,在EF Repository的構造函數中,需要將注入的IRepositoryContext實例轉換為IEntityFrameworkRepositoryContext實例,例如:
public class EntityFrameworkRepository<TAggregateRoot> : Repository<TAggregateRoot> where TAggregateRoot : class, IAggregateRoot { private readonly IEntityFrameworkRepositoryContext efContext; public EntityFrameworkRepository(IRepositoryContext context) : base(context) { if (context is IEntityFrameworkRepositoryContext) this.efContext = context as IEntityFrameworkRepositoryContext; } // 暫時忽略其它方法和屬性 }
在引入了基於Entity Framework Code First的倉儲實現以后,與倉儲相關的類型及其關系可以用下圖表示(同樣,省略了不少方法和屬性的定義):
現在再讓我們對倉儲部分的實踐和應用中的幾個問題進行更進一步的思考。
設計更為專注的倉儲接口
這個標題聽起來似乎不太好理解。在上面的設計中,倉儲類型都是以泛型的方式定義的,於是,無論在向IoC容器注冊的時候,還是在使用的時候,都需要以泛型的方式進行定義和調用,這樣雖然沒什么不好,但始終會讓代碼看起來別扭。或許,在我們的設計中再加上一種更為專注的倉儲接口會顯得更好一些。例如,對於User的倉儲,我們可以定義這樣的接口:
public interface IUserRepository : IRepository<User> { #region Methods /// <summary> /// 根據指定的用戶名,獲取用戶實體。 /// </summary> /// <param name="userName">需要獲取的用戶的用戶名。</param> /// <returns>用戶實體。</returns> User GetUserByName(string userName); /// <summary> /// 根據指定的電子郵件地址,獲取用戶實體。 /// </summary> /// <param name="email">需要獲取的用戶的電子郵件地址。</param> /// <returns>用戶實體。</returns> User GetUserByEmail(string email); #endregion }
在這個接口中,我們可以看到兩個可讀性更好的方法:GetUserByName和GetUserByEmail,從方法名就能很快得知其含義,當然,這些方法本身也是使用某些規約(Specification)來調用已有的倉儲方法來獲取結果,不過增加了代碼的可讀性,而且在IoC注冊倉儲實例的時候,也可以直接使用這些接口,這對於倉儲部分的縱向擴展是有好處的。這我將在下面介紹這部分內容。
IUserRepository接口的實現比較簡單,如下:
public class UserRepository : EntityFrameworkRepository<User>, IUserRepository { public UserRepository(IRepositoryContext context) : base(context) { } public User GetUserByName(string userName) { return Find(new UserNameEqualsSpecification(userName)); } public User GetUserByEmail(string email) { return Find(new UserEmailEqualsSpecification(email)); } }
倉儲的橫向擴展
在Byteart Retail中,我采用的是基於Entity Framework Code First的倉儲實現,假如我們希望Byteart Retail能夠使用基於NHibernate或者MongoDB等其它技術的倉儲,應該怎么辦呢?
其實很簡單,只要定義兩個分別繼承於RepositoryContext和Repository抽象類的類型,並在這兩個類型中使用這些技術來完成倉儲及其上下文的操作,最后在ByteartRetail.Services項目的web.config中配置使用新的倉儲實現即可。這並不是一個很難的問題,關鍵是要能夠管理好倉儲所使用的技術資源。
倉儲的縱向擴展
以上的倉儲及其上下文的設計,作為一種框架而言,無法涵蓋所有的對象持久化/反持久化操作需求。比如以前有很多朋友問過我,假如我希望倉儲能夠根據多個字段進行排序,然后以分頁的方式給出某頁中的對象集合,應該怎么辦?不錯,目前的這個設計無法滿足這樣的需求,因為倉儲的接口中沒有一個方法能夠接受多個排序字段的參數,但是,我們可以借用上面“更為專注的接口”對這個設計進行擴展。
首先,另外定義一個接口,比如:ICustomUserRepository,使得這個接口繼承於IUserRepository接口,然后在這個接口中定義支持多字段排序、分頁獲取對象的方法;然后,另外定義一個類,比如:CustomUserRepository類,使其繼承於UserRepository類,並實現ICustomUserRepository接口,如此一來,就無需修改任何現有的倉儲代碼,即可完成新功能的添加。最后,我們會發現:我們新引入了一個接口和一個類(你可以將它們定義在另外一個單獨的Assembly中),同時我們還修改了ByteartRetail.Services項目的web.config,將IUserRepository接口的注冊替換為了ICustomUserRepository(或者也可以增加了ICustomUserRepository的注冊),於是,整個倉儲框架無需修改,更無需二次編譯。恭喜你,你已經可以將這個倉儲框架作為一個通用組件發布了!
你或許還有疑問,這樣一來,豈不是我還需要修改倉儲的調用部分?這就要看你的整個應用程序的設計是否能夠滿足這樣的修改了。對於類似Byteart Retail這樣的面向DDD分層架構的應用程序來說,倉儲的調用部分都位於應用(Application)層,而Byteart Retail的應用層也是面向接口定義的,因此,使用面向對象的手段來替換應用層的實現並非難事。
總結
本文詳細介紹了倉儲及其上下文在整個應用程序架構中的位置,並結合Byteart Retail案例講解了基於EF Code First的倉儲設計和實現方式。在本文的結尾部分,對這樣的倉儲設計進行了更深層次的分析和討論,尤其是在倉儲擴展的相關問題上。希望本文能夠解答讀者朋友心中大多數與領域驅動設計“倉儲”相關的疑問。也歡迎大家積極參與討論,提出寶貴意見。