Byteart Retail案例:倉儲及其上下文

post200發表於2021-09-09

在領域驅動設計(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聚合的倉儲操作都在同一個事務處理範圍內,而Repository則可以保證所有針對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型別中所使用的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的屬性:

1
2
3
4
5
6
7
8
9
10
11
12
///
/// 表示繼承於該介面的型別,是由Microsoft Entity Framework支援的一種倉儲上下文的實現。
///
public interface IEntityFrameworkRepositoryContext : IRepositoryContext
{
    #region Properties
    ///
    /// 獲取當前倉儲上下文所使用的Entity Framework的例項。
    ///
    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例項,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class EntityFrameworkRepository : Repository
    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的倉儲,我們可以定義這樣的介面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public interface IUserRepository : IRepository
{
    #region Methods
    ///
    /// 根據指定的使用者名稱,獲取使用者實體。
    ///
    /// 需要獲取的使用者的使用者名稱。
    /// 使用者實體。
    User GetUserByName(string userName);
    ///
    /// 根據指定的電子郵件地址,獲取使用者實體。
    ///
    /// 需要獲取的使用者的電子郵件地址。
    /// 使用者實體。
    User GetUserByEmail(string email);
    #endregion
}

在這個介面中,我們可以看到兩個可讀性更好的方法:GetUserByName和GetUserByEmail,從方法名就能很快得知其含義,當然,這些方法本身也是使用某些規約(Specification)來呼叫已有的倉儲方法來獲取結果,不過增加了程式碼的可讀性,而且在IoC註冊倉儲例項的時候,也可以直接使用這些介面,這對於倉儲部分的縱向擴充套件是有好處的。這我將在下面介紹這部分內容。

IUserRepository介面的實現比較簡單,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class UserRepository : EntityFrameworkRepository, 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的倉儲設計和實現方式。在本文的結尾部分,對這樣的倉儲設計進行了更深層次的分析和討論,尤其是在倉儲擴充套件的相關問題上。希望本文能夠解答讀者朋友心中大多數與領域驅動設計“倉儲”相關的疑問。也歡迎大家積極參與討論,提出寶貴意見。

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/2249/viewspace-2800071/,如需轉載,請註明出處,否則將追究法律責任。

相關文章