ASP.NET Core Web API下事件驅動型架構的實現(四):CQRS架構中聚合與聚合根的實現


在前面兩篇文章中,我詳細介紹了基本事件系統的實現,包括事件派發和訂閱、通過事件處理器執行上下文來解決對象生命周期問題,以及一個基於RabbitMQ的事件總線的實現。接下來對於事件驅動型架構的討論,就需要結合一個實際的架構案例來進行分析。在領域驅動設計的討論范疇,CQRS架構本身就是事件驅動的,因此,我打算首先介紹一下CQRS架構下相關部分的實現,然后再繼續討論事件驅動型架構實現的具體問題。

當然,CQRS架構本身的實現也是根據實際情況的不同,需要具體問題具體分析的,不僅如此,CQRS架構的實現也是非常復雜的,絕不是一套文章一套案例能夠解釋清楚並涵蓋全部的。所以,我不會把大部分篇幅放在CQRS架構實現的細節上,而是會着重介紹與我們的主題相關的內容,並對無關的內容進行弱化。或許,在這個系列文章結束的時候,我們會得到一個完整的、能夠運行的CQRS架構系統,不過,這套系統極有可能僅供技術研討和學習使用,無法直接用於生產環境。

基於這樣的前提,我們今天首先看一下CQRS架構中聚合與聚合根的實現,或許你會覺得目前討論的內容與你本打算關心的事件驅動架構沒什么關系,而事實是,CQRS架構中聚合與聚合根的實現是完全面向事件驅動的,而這部分內容也會為我們之后的討論做下鋪墊。不僅如此,我還會在本文討論一些基於.NET/C#的軟件架構設計的思考與實踐(請注意文章中我添加了Note字樣並且字體加粗的句子),因此,我還是會推薦你繼續讀完這篇文章。

CQRS架構知識回顧

早在2010年,我針對CQRS架構總結過一篇文章,題目是:《EntityFramework之領域驅動設計實踐【擴展閱讀】:CQRS體系結構模式》,當然,這篇文章跟Entity Framework本沒啥關系,只是延續了領域驅動設計這一話題進行的擴展討論罷了。這篇文章介紹了CQRS架構模式所產生的背景、結構,以及相關的一些概念,比如:最近非常流行的詞語:“事件溯源”、解決事件溯源性能問題的“快照”、用於存取事件數據的“事件存儲(Event Store)”,還有重新認識了什么叫做“對象的狀態”,等等。此外,在后續的博文中,我也經常對CQRS架構中的實現細節做些探討,有興趣的讀者可以翻看我過去的博客文章。總體上講,CQRS架構基本符合下圖所描述的結構:

image

看上去是不是特別復雜?沒錯,特別復雜,而且每個部分都可以使用不同的工具、框架,以不同的形式進行實現。整個架構甚至可以是語言、平台異構的,還可以跟外部系統進行整合,實現大數據分析、呈現等等,玩法可謂之五花八門,這些統統都不在我們的討論范圍之內。我們今天打算討論的,就是上圖右上部分“領域模型”框框里的主題:CQRS架構中的聚合與聚合根。

說到聚合與聚合根,了解過領域驅動設計(DDD)的讀者肯定對這兩個概念非常熟悉。通常情況下,具有相同生命周期,組合起來能夠共同表述一種領域概念的一組模型對象,就可以組成一個聚合。在每個聚合中,銜接各個領域模型對象,並向外提供統一訪問聚合的對象,就是聚合根。聚合中的所有對象,離開聚合根,就不能完整地表述一個領域概念。比如:收貨地址無法離開客戶,訂單詳情無法離開訂單,庫存無法離開貨品等等。所以從定義上來看,一個聚合大概就是這樣:

  • 聚合中的對象可以是實體,也可以是值對象
  • 聚合中所有對象具有相同的生命周期
  • 外界通過聚合根訪問整個聚合,聚合根通過導航屬性(Navigation Properties)進而訪問聚合中的其它實體和值對象
  • 通過以上兩點,可以得出:工廠和倉儲必須針對聚合根進行操作
  • 聚合根是一個實體
  • 聚合中的對象是有狀態的,通常會通過C#的屬性(Properties)將狀態曝露給外界

好吧,對這些概念比較熟悉的讀者來說,我在此算是多啰嗦了幾句。接下來,讓我們結合CQRS架構中命令處理器對領域模型的更改過程來看看,除了以上這些常規特征之外,聚合與聚合根還有哪些特殊之處。當命令處理器接到操作命令時,便開始對領域模型進行更改,步驟如下:

  1. 首先,命令處理器通過倉儲,獲取具有指定ID值的聚合(聚合的ID值就是聚合根的ID值)
  2. 然后,倉儲訪問事件存儲數據庫,根據需要獲取的聚合根的類型,以及ID值,獲取所有關聯的領域事件
  3. 其次,倉儲構造聚合對象實例,並依照一定的順序,逐一將領域事件重新應用在新構建的聚合上
  4. 每當有一個領域事件被應用在聚合上時,聚合本身的內聯事件處理器會捕獲這個領域事件,並根據領域事件中的數據,設置聚合中對象的狀態
  5. 當所有的領域事件全部應用在聚合上時,聚合的狀態就是曾經被保存時的狀態
  6. 然后,倉儲將已經恢復了狀態的聚合返回給命令處理器,命令處理器調用聚合上的方法,對聚合進行更改
  7. 在調用方法的時候,方法本身會產生一個領域事件,這個領域事件會立刻被聚合本身的內聯事件處理器捕獲,並進行處理。在處理的過程中,會更新聚合中對象的狀態,同時,這個領域事件還會被緩存在聚合中
  8. 命令處理器在完成對聚合的更改之后,便會調用倉儲,將更改后的模型保存下來
  9. 接着,倉儲從聚合中獲得所有緩存的未曾保存的領域事件,並將所有這些領域事件逐個保存到事件存儲數據庫。在成功完成保存之后,會清空聚合中的事件緩存
  10. 最后,倉儲將所有的這些領域事件逐個地派發到事件消息總線

接下來在事件消息總線和事件處理器中將會發生的事情,我們今后還會討論,這里就不多說了。從這個過程,我們不難得出:

  • CQRS的聚合中,更改對象狀態必須通過領域事件,也就是說,不能向外界曝露直接訪問對象狀態的接口,更通俗地說,表示對象狀態的屬性(Property)不能有設置器(Setter)
  • CQRS聚合的聚合根中,會有一套內聯的領域事件處理機制,用來捕獲並處理聚合中產生的領域事件
  • CQRS聚合的聚合根會有一個保存未提交領域事件的本地緩存,對該緩存的訪問應該是線程安全的
  • CQRS的聚合需要能夠向倉儲提供必要的接口,比如清除事件緩存的方法等
  • 此外,CQRS聚合是有版本號的,版本號通常是一個64位整型,表述歷史上發生在聚合上的領域事件一共有多少個。當然,這個值在我們目前的討論中並非能夠真正用得上,但是,在倉儲重建聚合需要依賴快照時,這個版本號就非常重要了。我會在后續文章中介紹

聽起來是不是非常復雜?確實如此。那我們就先從領域事件入手,逐步實現CQRS中的聚合與聚合根。

領域事件

領域事件,顧名思義,就是從領域模型中產生的事件消息。概念上很簡單,比如,客戶登錄網站,就會由客戶登錄實體產生一個事件派發出去,例如CustomerLoggedOnEvent,表示客戶登錄這件事已經發生了。雖然在DDD的實踐中,領域事件更多地在CQRS架構中被討論,其實即便是非事件驅動型架構,也可以通過領域模型來發布消息,達到系統解耦的目的。

延續之前的設計,我們的領域事件繼承了IEvent接口,並增加了三個屬性/方法,此外,為了編程方便,我們實現了領域事件的抽象類,UML類圖如下:

image

圖中的綠色部分就是在之前我們的事件模型上新加的接口和類,用以表述領域事件的概念。其中:

  • aggregateRootId:發生該領域事件的聚合的聚合根的ID值
  • aggregateRootType:發生該領域事件的聚合的聚合根的類型
  • sequence:該領域事件的序列號

好了,如果說我們將發生在某聚合上的領域事件保存到關系型數據庫,那么,當需要獲得該聚合的所有領域事件時,只需要下面一句SQL就行了:

SELECT * FROM [Events] WHERE [AggregateRootId]=aggregateRootId AND [AggregateRootType]=aggregateRootType ORDER BY [Sequence] ASC

這就是最簡單的事件存儲數據庫的實現了。不過,我們暫時不介紹這些內容。

事實上,與標准的事件(IEvent接口)相比,除了上面三個主要的屬性之外,領域事件還可以包含更多的屬性和方法,這就要看具體的需求和設計了。不過目前為止,我們定義這三個屬性已經夠用了,不要把問題搞得太復雜。

有了領域事件的基本模型,我們開始設計CQRS下的聚合。

聚合的設計與實現

由於外界訪問聚合都是通過聚合根來實現的,因此,針對聚合的操作都會被委托給聚合根來處理。比如,當用戶地址發生變化時,服務層會調用Customer.ChangeAddress方法,這個方法就會產生一個領域事件,並通過內聯的事件處理機制更改聚合中Address值對象中的狀態。於是,從技術角度,聚合的設計也就是聚合根的實現。

接口與類之間的關系

首先需要設計的是與聚合相關的概念所表述的接口、類及其之間的關系。結合領域驅動設計中的概念,我們得到下面的設計:

image

其中,實體(IEntity)、聚合根(IAggregateRoot)都是大家耳熟能詳的領域驅動設計的概念。由於實體都是通過Id進行唯一標識,所以,IEntity會有一個id的屬性,為了簡單起見,我們使用Guid作為它的類型。聚合根(IAggregateRoot)繼承於IEntity接口,有趣的是,在我們目前的場景中,IAggregateRoot並不包含任何成員,它僅僅是一個空接口,在整個框架代碼中,它僅作為泛型的類型約束。Note:這種做法其實也是非常常見的一種框架設計模式。具有事件溯源能力的聚合根(IAggregateRootWithEventSourcing)又繼承於IAggregateRoot接口,並且有如下三個成員:

  • uncommittedEvents:用於緩存發生在當前聚合中的領域事件
  • version:表示當前聚合的版本號
  • Replay:將指定的一系列領域事件“應用”到當前的聚合上,也就是所謂的事件回放

此外,你還發現我們還有兩個神奇的接口:IPurgable和IPersistedVersionSetter。這兩個接口的職責是:

  • IPurgable表示,實現了該接口的類型具有某種清空操作,比如清空某個隊列,或者將對象狀態恢復到初始狀態。讓IAggregateRootWithEventSourcing繼承於該接口是因為,當倉儲完成了聚合中領域事件的保存和派發之后,需要清空聚合中緩存的事件,以保證在今后,發生在同一時間點的同樣的事件不會被再次保存和派發
  • IPersistedVersionSetter接口允許調用者對聚合的“保存版本號”進行設置。這個版本號表示了在事件存儲中,屬於當前聚合的所有事件的個數。試想,如果一個聚合的“保存版本號”為4(即在事件存儲中有4個事件是屬於該聚合的),那么,如果再有2個事件發生在這個聚合中,於是,該聚合的版本就是4+2=6.

Note:為什么不將這兩個接口中的方法直接放在IAggregateRootWithEventSourcing中呢?是因為單一職責原則。聚合本身不應該存在所謂之“清空緩存”或者“設置保存版本號”這樣的概念,這樣的概念對於技術人員來說比較容易理解,可是如果將這些技術細節加入領域模型中,就會污染領域模型,造成領域專家無法理解領域模型,這是違背面向對象分析與設計的單一職責原則的,也違背了領域驅動設計的原則。那么,即使把這些方法通過額外的接口獨立出去,實現了IAggregateRootWithEventSourcing接口的類型,不還是要實現這兩個接口中的方法嗎?這樣,聚合的訪問者不還是可以訪問這兩個額外的方法嗎?的確如此,這些接口是需要被實現的,但是我們可以使用C#中接口的顯式實現,這樣的話,如果不將IAggregateRootWithEventSourcing強制轉換成IPurgable或者IPersistedVersionSetter的話,是無法直接通過聚合根對象本身來訪問這些方法的,這起到了非常好的保護作用。接口的顯式實現在軟件系統的框架設計中也是常用手段。

抽象類AggregateRootWithEventSourcing的實現

在上面的類圖中,IAggregateRootWithEventSourcing最終由AggregateRootWithEventSourcing抽象類實現。不要抱怨類的名字太長,它有助於我們理解這一類型在我們的領域模型中的角色和功能。下面的代碼列出了該抽象類的主要部分的實現:

public abstract class AggregateRootWithEventSourcing : IAggregateRootWithEventSourcing
{
    private readonly Lazy<Dictionary<string, MethodInfo>> registeredHandlers;
    private readonly Queue<IDomainEvent> uncommittedEvents = new Queue<IDomainEvent>();
    private Guid id;
    private long persistedVersion = 0;
    private object sync = new object();

    protected AggregateRootWithEventSourcing()
        : this(Guid.NewGuid())
        { }

    protected AggregateRootWithEventSourcing(Guid id)
    {
        registeredHandlers = new Lazy<Dictionary<string, MethodInfo>>(() =>
        {
            var registry = new Dictionary<string, MethodInfo>();
            var methodInfoList = from mi in this.GetType().GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
                                 let returnType = mi.ReturnType
                                 let parameters = mi.GetParameters()
                                 where mi.IsDefined(typeof(HandlesInlineAttribute), false) &&
                                 returnType == typeof(void) &&
                                 parameters.Length == 1 &&
                                 typeof(IDomainEvent).IsAssignableFrom(parameters[0].ParameterType)
                                 select new { EventName = parameters[0].ParameterType.FullName, MethodInfo = mi };

            foreach (var methodInfo in methodInfoList)
            {
                registry.Add(methodInfo.EventName, methodInfo.MethodInfo);
            }

            return registry;
        });

        Raise(new AggregateCreatedEvent(id));
    }

    public Guid Id => id;

    long IPersistedVersionSetter.PersistedVersion { set => Interlocked.Exchange(ref this.persistedVersion, value); }

    public IEnumerable<IDomainEvent> UncommittedEvents => uncommittedEvents;

    public long Version => this.uncommittedEvents.Count + this.persistedVersion;

    void IPurgable.Purge()
    {
        lock (sync)
        {
            uncommittedEvents.Clear();
        }
    }

    public void Replay(IEnumerable<IDomainEvent> events)
    {
        ((IPurgable)this).Purge();
        events.OrderBy(e => e.Timestamp)
            .ToList()
            .ForEach(e =>
            {
                HandleEvent(e);
                Interlocked.Increment(ref this.persistedVersion);
            });
    }

    [HandlesInline]
    protected void OnAggregateCreated(AggregateCreatedEvent @event)
    {
        this.id = @event.NewId;
    }
    
    protected void Raise<TDomainEvent>(TDomainEvent domainEvent)
        where TDomainEvent : IDomainEvent
    {
        lock (sync)
        {
            // 首先處理事件數據。
            this.HandleEvent(domainEvent);
            // 然后設置事件的元數據,包括當前事件所對應的聚合根類型以及
            // 聚合的ID值。
            domainEvent.AggregateRootId = this.id;
            domainEvent.AggregateRootType = this.GetType().AssemblyQualifiedName;
            domainEvent.Sequence = this.Version + 1;
            // 最后將事件緩存在“未提交事件”列表中。
            this.uncommittedEvents.Enqueue(domainEvent);
        }
    }

    private void HandleEvent<TDomainEvent>(TDomainEvent domainEvent)
        where TDomainEvent : IDomainEvent
    {
        var key = domainEvent.GetType().FullName;
        if (registeredHandlers.Value.ContainsKey(key))
        {
            registeredHandlers.Value[key].Invoke(this, new object[] { domainEvent });
        }
    }
}

上面的代碼不算復雜,它根據上面的分析和描述,實現了IAggregateRootWithEventSourcing接口,篇幅原因,就不多做解釋了,不過有幾點還是可以鑒賞一下的:

  1. 使用Lazy類型來保證領域事件處理器的容器在整個聚合生命周期中只初始化一次
  2. 通過lock語句和Interlocked.Exchange來保證類型的線程安全和數值的原子操作
  3. 聚合根被構造的時候,會找到當前類型中所有標記了HandlesInlineAttribute特性,並具有一定特征的函數,將它們作為領域事件的內聯處理器,注冊到容器中
  4. 每當聚合中的某個業務操作(方法)需要更改聚合中的狀態時,就調用Raise方法來產生領域事件,由對應的內聯處理器捕獲領域事件,並在處理器方法中設置聚合的狀態
  5. Replay方法會遍歷所有給點的領域事件,調用HandleEvent方法,實現事件回放

現在,我們已經實現了CQRS架構下的聚合與聚合根,雖然實際上這個結構有可能比我們的實現更為復雜,但是目前的這個設計已經能夠滿足我們進一步研究討論的需求了。下面,我們再更進一步,看看CQRS中倉儲應該如何實現。

倉儲實現初探

為什么說是“初探”?因為我們目前打算實現的倉儲暫時不包含事件派發的邏輯,這部分內容我會在后續文章中講解。首先看看,倉儲的接口是什么樣的。在CQRS架構中,倉儲只具備兩種操作:

  1. 保存聚合
  2. 根據聚合ID(也就是聚合根的ID)值,獲取聚合對象

你或許會問,那根據某個條件查詢滿足該條件的所有聚合對象呢?注意,這是CQRS架構中查詢部分的職責,不屬於我們的討論范圍。

通常,倉儲的接口定義如下:

public interface IRepository
{
    Task SaveAsync<TAggregateRoot>(TAggregateRoot aggregateRoot)
        where TAggregateRoot : class, IAggregateRootWithEventSourcing;

    Task<TAggregateRoot> GetByIdAsync<TAggregateRoot>(Guid id)
        where TAggregateRoot : class, IAggregateRootWithEventSourcing;
}
 
        

與之前領域事件的設計類似,我們為倉儲定義一個抽象類,所有倉儲的實現都應該基於這個抽象類:

public abstract class Repository : IRepository
{
    protected Repository()
    { }

    public async Task<TAggregateRoot> GetByIdAsync<TAggregateRoot>(Guid id)
        where TAggregateRoot : class, IAggregateRootWithEventSourcing
    {
        var domainEvents = await LoadDomainEventsAsync(typeof(TAggregateRoot), id);
        var aggregateRoot = ActivateAggregateRoot<TAggregateRoot>();
        aggregateRoot.Replay(domainEvents);
        return aggregateRoot;
    }

    public async Task SaveAsync<TAggregateRoot>(TAggregateRoot aggregateRoot)
        where TAggregateRoot : class, IAggregateRootWithEventSourcing
    {
        var domainEvents = aggregateRoot.UncommittedEvents;
        await this.PersistDomainEventsAsync(domainEvents);
        aggregateRoot.PersistedVersion = aggregateRoot.Version;
        aggregateRoot.Purge();
    }

    protected abstract Task<IEnumerable<IDomainEvent>> LoadDomainEventsAsync(Type aggregateRootType, Guid id);

    protected abstract Task PersistDomainEventsAsync(IEnumerable<IDomainEvent> domainEvents);

    private TAggregateRoot ActivateAggregateRoot<TAggregateRoot>()
                        where TAggregateRoot : class, IAggregateRootWithEventSourcing
    {
        var constructors = from ctor in typeof(TAggregateRoot).GetTypeInfo().GetConstructors()
                           let parameters = ctor.GetParameters()
                           where parameters.Length == 0 ||
                           (parameters.Length == 1 && parameters[0].ParameterType == typeof(Guid))
                           select new { ConstructorInfo = ctor, ParameterCount = parameters.Length };

        if (constructors.Count() > 0)
        {
            TAggregateRoot aggregateRoot;
            var constructorDefinition = constructors.First();
            if (constructorDefinition.ParameterCount == 0)
            {
                aggregateRoot = (TAggregateRoot)constructorDefinition.ConstructorInfo.Invoke(null);
            }
            else
            {
                aggregateRoot = (TAggregateRoot)constructorDefinition.ConstructorInfo.Invoke(new object[] { Guid.NewGuid() });
            }

            // 將AggregateRoot下的所有事件清除。事實上,在AggregateRoot的構造函數中,已經產生了AggregateCreatedEvent。
            aggregateRoot.Purge();
            return aggregateRoot;
        }

        return null;
    }
}

代碼也是非常簡單、容易理解的:GetByIdAsync方法根據給定的聚合根類型以及ID值,從后台存儲中讀取所有屬於該聚合的領域事件,並在聚合上進行回放,以便將聚合恢復到存儲前的狀態;SaveAsync方法則從聚合根上獲得所有未被提交的領域事件,將這些事件保存到后台存儲,然后設置聚合的“已保存版本”,最后清空未提交事件的緩存。剩下的就是如何實現LoadDomainEventsAsync以及PersistDomainEventsAsync兩個方法了。而這兩個方法,原本就應該是事件存儲對象的職責范圍了。

Note:你也許會問:如果某個聚合從開始到現在,已經發生了大量的領域事件了,那么這樣一條條地將事件回放到聚合上,豈不是性能非常低下?沒錯,這個問題我們可以通過快照來解決。在后續文章中我會介紹。你還會問:日積月累,事件存儲系統中的事件數量豈不是會越來越多嗎?需要刪除嗎?答案是:不刪!不過可以對數據進行歸檔,或者依賴一些第三方框架來處理這個問題,但是,從領域驅動設計的角度,領域事件代表着整個領域模型系統中發生過的所有事情,事情既然已經發生,就無法再被抹去,因此,刪除事件存儲系統中的事件是不合理的。那數據量越來越大怎么辦?答案是:或許,存儲硬件設備要比業務數據更便宜。

倉儲的實現我們暫且探索到這一步,目前我們只需要有一個正確的聚合保存、讀取(通過領域事件重塑)的邏輯就可以了,並不需要關心事件本身是如何被讀取被保存的。接下來,我們在.NET Core的測試項目中,借助Moq框架,通過Mock一個假想的倉儲,來驗證整個系統從聚合、聚合根的實現到倉儲設計的正確性。

使用Moq框架,通過單元測試驗證聚合、聚合根以及倉儲設計的正確性

Moq是一個很好的Mock框架,簡單輕量,而且支持.NET Core,在單元測試的項目中使用Moq是一種很好的實踐。Moq上手非常簡單,只需要在單元測試項目上添加Moq的NuGet依賴包就可以開始着手編寫測試用例了。為了測試我們的聚合根以及倉儲對聚合根保存、讀取的設計,首先我們定義一個簡單的聚合:

public class Book : AggregateRootWithEventSourcing
{
    public void ChangeTitle(string newTitle)
    {
        this.Raise(new BookTitleChangedEvent(newTitle));
    }
    
    public string Title { get; private set; }

    [HandlesInline]
    private void OnTitleChanged(BookTitleChangedEvent @event)
    {
        this.Title = @event.NewTitle;
    }

    public override string ToString()
    {
        return Title;
    }
}

Book類是一個聚合根,它繼承AggregateRootWithEventSourcing抽象類,同時它有一個屬性,Title,表示書的名稱,而ChangeTitle方法(業務方法)會直接產生一個BookTitleChangedEvent領域事件,之后,OnTitleChanged成員函數會負責將領域事件中的NewTitle的值設置到Book聚合根的Title狀態上,完成書本標題的更新。與之相關的BookTitleChangedEvent的定義如下:

public class BookTitleChangedEvent : DomainEvent
{
    public BookTitleChangedEvent(string newTitle)
    {
        this.NewTitle = newTitle;
    }

    public string NewTitle { get; set; }

    public override string ToString()
    {
        return $"{Sequence} - {NewTitle}";
    }
}

首先,下面兩個測試用例用於測試Book聚合本身產生領域事件的過程是否正確,如果正確,那么當Book本身本構造時,會產生一個AggregateCreatedEvent,如果更改書本的標題,則又會產生一個BookTitleChangedEvent,所以,第一個測試中,book的版本應該為1,而第二個則為2:

[Fact]
public void CreateBookTest()
{
    // Arrange & Act
    var book = new Book();
    // Assert
    Assert.NotEqual(Guid.Empty, book.Id);
    Assert.Equal(1, book.Version);
}

[Fact]
public void ChangeBookTitleEventTest()
{
    // Arrange
    var book = new Book();
    // Act
    book.ChangeTitle("Hit Refresh");
    // Assert
    Assert.Equal("Hit Refresh", book.Title);
    Assert.Equal(2, book.UncommittedEvents.Count());
    Assert.Equal(2, book.Version);
}

接下來,測試倉儲保存Book聚合的正確性,因為我們沒有實現一個有效的倉儲實例,因此,這里借助Moq幫我們動態生成。在下面的代碼中,讓Moq對倉儲抽象類的PersisDomainEventsAsync受保護成員進行動態生成,指定當它被任何IEnumerable<IDomainEvent>作為參數調用時,都將這些事件保存到一個本地的List中,於是,最后只需要檢查List中的領域事件是否符合我們的要求就可以了。代碼如下:

[Fact]
public async Task PersistBookTest()
{
    // Arrange
    var domainEventsList = new List<IDomainEvent>();
    var mockRepository = new Mock<Repository>();

    mockRepository.Protected().Setup<Task>("PersistDomainEventsAsync",
            ItExpr.IsAny<IEnumerable<IDomainEvent>>())
        .Callback<IEnumerable<IDomainEvent>>(evnts => domainEventsList.AddRange(evnts))
        .Returns(Task.CompletedTask);

    var book = new Book();
    // Act
    book.ChangeTitle("Hit Refresh");
    await mockRepository.Object.SaveAsync(book);

    // Assert
    Assert.Equal(2, domainEventsList.Count);
    Assert.Empty(book.UncommittedEvents);
    Assert.Equal(2, book.Version);
}

同理,我們還可以測試倉儲讀取聚合並恢復聚合狀態的正確性,同樣還是使用Moq對倉儲的LoadDomainEventsAsync進行Mock:

[Fact]
public async Task RetrieveBookTest()
{
    // Arrange
    var fakeId = Guid.NewGuid();
    var domainEventsList = new List<IDomainEvent>
        {
            new AggregateCreatedEvent(fakeId),
            new BookTitleChangedEvent("Hit Refresh")
        };
    var mockRepository = new Mock<Repository>();
    mockRepository.Protected().Setup<Task<IEnumerable<IDomainEvent>>>("LoadDomainEventsAsync",
            ItExpr.IsAny<Type>(),
            ItExpr.IsAny<Guid>())
        .Returns(Task.FromResult(domainEventsList.AsEnumerable()));

    // Act
    var book = await mockRepository.Object.GetByIdAsync<Book>(fakeId);

    // Assert
    Assert.Equal(fakeId, book.Id);
    Assert.Equal("Hit Refresh", book.Title);
    Assert.Equal(2, book.Version);
    Assert.Empty(book.UncommittedEvents);
}

好了,其它的幾個測試用例就不多做介紹了,使用Visual Studio運行一下測試然后查看結果就可以了:

image

總結

本文又是一篇長篇幅的文章,好吧,要介紹的東西太多,而且這些內容又不能單獨割開成多個主題,所以也就很難控制篇幅了。文章主要介紹了基於CQRS架構的聚合以及聚合根的設計與實現,同時引出了倉儲的部分實現,這些內容也是為今后進一步討論事件驅動型架構做准備。本文介紹的內容對於一個真實的CQRS系統實現來說還是有一定差距的,但總體結構也大致如此。文中還提及了快照的概念,這部分內容我今后在介紹事件存儲的實現部分還會詳細討論,下一章打算擴展一下倉儲本身,了解一下倉儲對領域事件的派發,以及事件處理器對領域事件的處理。

源代碼的使用

本系列文章的源代碼在https://github.com/daxnet/edasample這個Github Repo里,通過不同的release tag來區分針對不同章節的源代碼。本文的源代碼請參考chapter_4這個tag,如下:

image


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM