基于事件驱动的领域模型实现框架 - 分析框架如何解决各种典型业务逻辑场景

简介:
  1. 任何一个领域对象是“活”的,它不仅有属性(对象的状态),而且有方法(对象的行为)。为什么说是“活”的呢?因为领域对象的行为都不是被另外的领域对象调用的,而是自己去响应一些“事件” ,然后执行其自身的某个行为的。在我看来,如果一个领域对象的方法是被其他的领域对象调用的,那这个对象就是“死”的,因为它没有主动地去参与到某个活动中去。这里需要强调的一点是,领域对象只会更新它自己的状态,而不会更新其他领域对象的状态。
  2. 所有的领域对象之间都是平等的,任何两个领域对象之间不会有任何引用的关系(如,依赖、关联、聚合、组合);但是它们之间会存在数据上的关系,如一个对象会保留另外一个对象的唯一标识。
  3. 领域对象之间的交互和通信全部通过事件来完成,事件可以将所有的领域对象串联起来使它们能相互协作。除此之外,领域对象和外界的各种交互也通过事件完成。按照Eric Evans的理论,为了确保领域对象之间的概念完整性,需要有聚合及聚合根的概念,聚合根聚合了很多子的实体或值对象,或者还会关联其他的聚合根。另外,每个聚合需要有一个仓储(Repository)来负责聚合的持久化和重建的职责。其实,我觉得要确保领域对象之间的概念完整性,除了通过聚合的方式之外,还可以通过事件来确保。其实,用聚合来确保概念完整性是事物之间直接作用的反映;而用事件来确保概念完整性则是事物之间间接作用的反映。在用事件的方式下,仓储也不再需要了,因为领域模型和外界的交互也是通过事件来完成的。 

虽然在前面那篇文章中提供了两个Demo用来展示框架的功能,但我想大家直接看Demo源代码还是比较累,并且不能直观的看到框架能做什么以及如何使用。因此,本篇文章打算举几个典型的例子来分析如何使用我的框架来解决各种典型的应用场景。

应用场景1:银行转账

银行转账的核心流程大家应该都很熟悉了,主要有这么几步:

  1. 源账户扣除转账金额,当然首先需要先判断当前账户余额是否足够,如果不够,则无法转账。
  2. 目标账户增加转账金额;
  3. 为源账户生成一笔转账记录;
  4. 为目标账户生成一笔转账记录;

下面看看如何通过事件来实现上面的应用场景:

首先定义一个转账的事件:

复制代码
1       public   class  TransferEvent : DomainEvent
2      {
3           public  Guid FromBankAccountId {  get set ; }
4           public  Guid ToBankAccountId {  get set ; }
5           public   double  MoneyAmount {  get set ; }
6           public  DateTime TransferDate {  get set ; }
7      }
复制代码

该事件定义了:源账户、目标账户、转账金额、转账时间四个信息;

然后看看银行帐号类的设计:

复制代码
 1       public   class  BankAccount : DomainObject < Guid >
 2      {
 3           #region  Constructors
 4 
 5           public  BankAccount(Guid customerId) :  base (Guid.NewGuid())
 6          {
 7               this .CustomerId  =  customerId;
 8          }
 9 
10           #endregion
11 
12           #region  Public Properties
13 
14           public  Guid CustomerId {  get private   set ; }
15          [TrackingProperty]
16           public   double  MoneyAmount {  get set ; }
17 
18           #endregion
19 
20           #region  Event Handlers
21 
22           private   void  TransferTo(TransferEvent evnt)
23          {
24              WithdrawMoney(evnt.MoneyAmount);
25 
26              CreateTransferHistory(evnt.FromBankAccountId,
27                                    evnt.FromBankAccountId,
28                                    evnt.ToBankAccountId,
29                                    evnt.MoneyAmount,
30                                    evnt.TransferDate);
31          }
32           private   void  TransferFrom(TransferEvent evnt)
33          {
34              DepositMoney(evnt.MoneyAmount);
35 
36              CreateTransferHistory(evnt.ToBankAccountId,
37                                    evnt.FromBankAccountId,
38                                    evnt.ToBankAccountId,
39                                    evnt.MoneyAmount,
40                                    evnt.TransferDate);
41          }
42 
43           #endregion
44 
45           #region  Private Methods
46 
47           private   void  WithdrawMoney( double  moneyAmount)
48          {
49               if  ( this .MoneyAmount  <  moneyAmount)
50              {
51                   throw   new  InvalidOperationException( " 账户余额不足。 " );
52              }
53               this .MoneyAmount  -=  moneyAmount;
54          }
55           private   void  DepositMoney( double  moneyAmount)
56          {
57               this .MoneyAmount  +=  moneyAmount;
58          }
59           private   void  CreateTransferHistory(Guid currentBankAccount,
60                                             Guid fromBankAccountId,
61                                             Guid toBankAccountId,
62                                              double  moneyAmount,
63                                             DateTime transferDate)
64          {
65              TransferHistory transferHistory  =
66                   new  TransferHistory(
67                      fromBankAccountId,
68                      toBankAccountId,
69                      moneyAmount,
70                      transferDate);
71 
72              Repository.Add(transferHistory);
73 
74              EventProcesser.ProcessEvent(
75                   new  AddAccountTransferHistoryEvent
76                  {
77                      BankAccountId  =  currentBankAccount,
78                      TransferHistory  =  transferHistory
79                  });
80          }
81 
82           #endregion
83      }
复制代码

BankAccount是一个领域对象,TransferTo和TransferFrom是两个事件的响应函数。目前为止,我们只需要知道:1)TransferTo方法会自动被源帐号对象调用,2)TransferFrom方法会自动被目标帐号对象调用。

最后,如何来通知领域模型进行转账操作呢?很简单,只要触发TransferEvent事件即可:

复制代码
1      EventProcesser.ProcessEvent(
2           new  TransferEvent {
3              FromBankAccountId  =  bankAccount1.Id,
4              ToBankAccountId  =  bankAccount2.Id,
5              MoneyAmount  =   1000 ,
6              TransferDate  =  DateTime.Now
7          }
8      );
复制代码

上面的代码通知中央事件处理器处理一个转账的事件。 

好了,理想情况下,如果只要上面的这样三段代码就能完成转账的业务场景了,那就太好了。但是那时不可能的,因为还有一个很重要的信息没有告诉框架,那就是框架还不知道源账号和目标账号的唯一标识,我们需要告诉框架源账号的唯一标识是从事件的那个属性中获取,目标帐号的唯一标识是从事件的那个属性中获取。如下的代码体现了这点:

复制代码
 1      RegisterObjectEventMappingItem < TransferEvent, BankAccount > (
 2           new  GetDomainObjectIdEventHandlerInfo < TransferEvent >
 3          {
 4              GetDomainObjectId  =  evnt  =>  evnt.FromBankAccountId,
 5              EventHandlerName  =   " TransferTo "
 6          },
 7           new  GetDomainObjectIdEventHandlerInfo < TransferEvent >
 8          {
 9              GetDomainObjectId  =  evnt  =>  evnt.ToBankAccountId,
10              EventHandlerName  =   " TransferFrom "
11          }
12      );
复制代码

上面的代码表示,一个BankAccount对象会响应TransferEvent事件,并且会有两个方法会响应;“TransferTo”方法表示源账号对TransferEvent事件的响应,“TransferFrom”方法表示目标帐号对TransferEvent事件的响应;另外,通过传递给框架一个“GetDomainObjectId”委托函数来告诉框架,当前响应者的唯一标识。通过上面的四段代码,我们就能实现转账的应用场景了。可以看出,转账的逻辑都在BankAccount对象中,而RegisterObjectEventMappingItem方法则是用来告诉框架BankAccount对象的唯一标识是从TransferEvent事件中的那个属性中获取的。另外一般情况下,我们不需要指定事件响应函数的名字,但由于这里一个对象对同一个事件有两个响应函数,则需要额外指定一个名字来告诉框架对应关系。

应用场景2:论坛中帖子的回复对帖子的影响

大家都知道,一个论坛的注册用户可以发表帖子,发表帖子的回复,或者是删除自己发表的某个回复。假设有如下的场景:帖子有一个属性表示它有多少个回复,当该帖子新增一个回复时,该属性值加1;当该帖子删除一个回复时,该属性值减1。

首先看一下帖子类:

复制代码
 1       public   class  Topic : DomainObject < Guid >
 2      {
 3           #region  Constructors
 4 
 5           public  Topic(Guid createdBy, DateTime createDate,  int  totalReplyCount) :  base (Guid.NewGuid())
 6          {
 7               this .CreatedBy  =  createdBy;
 8               this .CreateDate  =  createDate;
 9               this .TotalReplyCount  =  totalReplyCount;
10          }
11 
12           #endregion
13 
14           #region  Public Properties
15 
16           public  Guid CreatedBy {  get private   set ; }          // 作者
17           public  DateTime CreateDate {  get private   set ; }     // 创建日期
18          [TrackingProperty]
19           public   string  Subject {  get set ; }                  // 标题
20          [TrackingProperty]
21           public   string  Body {  get set ; }                     // 消息内容
22          [TrackingProperty]
23           public   int  TotalMarks {  get set ; }                  // 点数
24          [TrackingProperty]
25           public   int  TotalReplyCount {  get set ; }             // 当前主题下的消息总数
26 
27           #endregion
28 
29           #region  Event Handlers
30 
31           private   void  Handle(DomainObjectAddedEvent < Reply >  evnt)
32          {
33               this .TotalReplyCount  +=   1 ;
34          }
35           private   void  Handle(DomainObjectRemovedEvent < Reply >  evnt)
36          {
37               this .TotalReplyCount  -=   1 ;
38          }
39 
40           #endregion
41      }
复制代码

Topic类表示一个帖子,大家可以看到它响应两个事件DomainObjectAddedEvent<Reply>和DomainObjectRemovedEvent<Reply>,其中Reply类表示帖子的回复。

DomainObjectAddedEvent<TDomainObject>和DomainObjectRemovedEvent<TDomainObject>这两个事件是由框架定义的泛型事件,用来表示某个领域对象被创建了或被移除了。所以,DomainObjectAddedEvent<Reply>和DomainObjectRemovedEvent<Reply>这两个事件就表示新增了一个帖子的回复的事件和移除了一个帖子的回复的事件。

另外,我们可以通过下面的代码来添加一个回复,或移除一个回复。

1  var reply1  =  Repository.Add( new  Reply(topic.Id) { Body  =   " A new topic reply1. "  });  // 添加回复的代码
2  Repository.Remove(reply1); //移除回复的代码  

大家从上面的代码中看到了Repository,也就是仓储。其实这个类不是真正的仓储,因为它的内部实现也仅仅是做了“发布事件”的事情。换句话说,我这里的Repository只是帮我们做了发布一些通用典型事件的操作。可以看一下这两个方法的实现:

复制代码
 1         public static TDomainObject Add<TDomainObject>(TDomainObject domainObject) where TDomainObject : class
 2         {
 3             EventProcesser.ProcessEvent(new PreAddDomainObjectEvent<TDomainObject> { DomainObject = domainObject });
 4             EventProcesser.ProcessEvent(new AddDomainObjectEvent<TDomainObject> { DomainObject = domainObject });
 5             EventProcesser.ProcessEvent(new DomainObjectAddedEvent<TDomainObject> { DomainObject = domainObject });
 6             return domainObject;
 7         }
 8         public static void Remove<TDomainObject>(TDomainObject domainObject) where TDomainObject : class
 9         {
10             EventProcesser.ProcessEvent(new PreRemoveDomainObjectEvent<TDomainObject> { DomainObject = domainObject });
11             EventProcesser.ProcessEvent(new RemoveDomainObjectEvent<TDomainObject> { DomainObject = domainObject });
12             EventProcesser.ProcessEvent(new DomainObjectRemovedEvent<TDomainObject> { DomainObject = domainObject });
13         }
复制代码

当然,和前面的例子一样,我们还必须告诉框架,从事件的那个部分去获取事先响应者的唯一标识,我们可以通过下面的简单明了的代码来告诉框架这个信息:

1         RegisterObjectEventMappingItem<DomainObjectAddedEvent<Reply>, Topic>(evnt => evnt.DomainObject.TopicId);
2         RegisterObjectEventMappingItem<DomainObjectRemovedEvent<Reply>, Topic>(evnt => evnt.DomainObject.TopicId);

我们可以看出,这里的代码要币上例的代码简单很多,原因是Topic类对同一个事件的响应函数只有一个。这里,我们仅仅只是提供了一个委托用来告诉框架Topic的唯一标识如何获取,这样就足够了。其实在大多数情况下,一个类对某个事件的响应函数只有一个,也就是说,只要确定了领域对象类型和事件类型,我们就可以找到对应的响应函数了。

应用场景3:论坛中帖子被删除后帖子回复的级联删除

本篇文章一开始简单讨论了聚合和仓储的概念。首先聚合有业务逻辑,而仓储是用来持久化整个聚合的,那么仓储也肯定知道它所管理的聚合的业务逻辑。也就是说,仓储在持久化聚合时,肯定知道了该聚合内的哪些对象需要被一起持久化,哪些则不用。比如下面的例子:

Book.Author

Book.Comments

假设有一本书,用Book表示;它是一个聚合根,一本书有一些评论,用Comments表示书本的所有评论,书本评论离开书本没有意义,类似于Order和OrderItem之间的关系,所以Book聚合了一些Comments;另外,一本书有一个作者,用Author表示。一般情况下,Author也是一个聚合根,因为它是独立于书本而存在的。当我们删除一本书时,书本的作者肯定不能被删除,最多删除他们之间的关系。好了,有了上面这些前提条件后,假设有一个BookRepository,它负责持久化Book。则BookRepository的RemoveBook方法看起来应该是这样:

复制代码
bookRepository.RemoveBook(book)

{

    //delete book it self;

    //delete book comments;

    //remove the relationship between book and author; 

}
复制代码

我们可以充分看到上面的方法之所以知道当一本书需要被删除时需要做哪些事情,是因为BookRepository完全知道整个聚合(这里就是Book)的所有和聚合相关的业务逻辑。事实上,在Eric Evans的DDD理论中,也正是通过聚合及仓储的设计来确保各个领域对象之间的概念完整性的。

但是,我上面提到过,没有了聚合,没有了仓储,我们还可以通过事件来确保领域对象的完整性。下面举个例子来说明如何实现这个目标:

大家都知道一个论坛中帖子与帖子回复的关系应该是和书本与书本评论的关系是同一种关系。也就是说,当我们在删除一个帖子时,还需要级联删除帖子的回复。

帖子类的实现上面已经写了,这里我们看一下帖子回复类的实现:

复制代码
 1       public   class  Reply : DomainObject < Guid >
 2      {
 3           #region  Constructors
 4 
 5           public  Reply(Guid topicId) :  base (Guid.NewGuid())
 6          {
 7               this .TopicId  =  topicId;
 8          }
 9 
10           #endregion
11 
12           #region  Public Properties
13 
14           public  Guid TopicId {  get private   set ; }      // 主题ID
15          [TrackingProperty]
16           public   string  Body {  get set ; }               // 消息内容
17 
18           #endregion
19 
20           #region  Event Handlers
21 
22           private   void  Handle(DomainObjectRemovedEvent < Topic >  evnt)
23          {
24              Repository.Remove( this );
25          }
26 
27           #endregion
28      }
复制代码

可以看到回复类有一个事件响应函数,该函数表示当其所属的帖子删除时,需要把自己也一起删除。也就是说,当我们在执行如下代码时,上面代码中的响应函数就会自动被执行。

1  Repository.Remove(topic);

当然,框架还不可能也做不到这么智能的地步。我们必须告诉框架哪些回复回去响应DomainObjectRemovedEvent<Topic>事件。如下代码所示:

    RegisterObjectEventMappingItem < DomainObjectRemovedEvent < Topic > , Reply > (
        evnt 
=>  Repository.Find < Reply, FindTopicRepliesEvent > (evt  =>  evt.TopicId  =  evnt.DomainObject.Id));

上面的代码表示帖子回复类会去响应DomainObjectRemovedEvent<Topic>事件,也就是帖子被删除的事件,并且通过一个委托来告诉框架有哪些回复会响应该事件。

总结: 

从上面的几个例子,我们可以清楚的看到领域对象之间没有相互引用,完全通过事件来实现相互协作。比如父对象通知子对象,子对象通知父对象,一个事件通知一个或多个同类型或不同类型的对象,等等。要实现任何一个业务场景,我们需要做的事情一般是:

1)通知中央事件处理器处理某个事件(如果该事件是框架没有提供特定的业务事件,则需要自己定义,如TransferEvent);

2)领域对象响应该事件,通过定义私有的响应函数来实现响应;

3)在领域模型内部,告诉框架事件及响应者之间的映射关系,并告诉框架有哪个或哪些对象会去响应,它们的唯一标识是如何从事件中获取的;

通过这三个步骤,我们就可以轻松简单的实现各种领域对象之间的协作了。而且需要强调的是,通过这样的方式,可以充分体现出领域对象是“活”的这个概念。因为所有的领域对象的事件响应函数都是私有的,也就是领域对象自己的行为别的领域对象无法去调用,而都是由一个“中央事件处理器”去统一调用。这样的效果就是,任何一个领域对象都会“主动”去响应某个事件,这样就从分体现出了“活”对象的概念了。在我看来,这才是真正的面向对象编程,因为所有的对象都是主动参与到某个业务场景中去的。

最后,关于使用这种方式来组织业务逻辑的好处和坏处,我目前还没有仔细研究过,我还没有利用该框架做过一个真实的项目。但我想有一点是可以肯定的,那就是这应该是另外一种全新的组织业务逻辑的方法,并且它的最大特点是高度可扩展性,因为是基于事件消息机制的,把领域对象之间的耦合度降到了最低,但同时我想在可维护性方面可能会有一些缺点。 


目录
相关文章
|
存储 架构师 算法
架构设计的本质:系统与子系统、模块与组件、框架与架构
在软件研发这个领域,程序员的终极目标都是想成为一名合格的架构师。然而梦想很美好,但现实却很曲折。
架构设计的本质:系统与子系统、模块与组件、框架与架构
|
存储 设计模式 缓存
复杂逻辑业务层治理探究
复杂逻辑业务层治理探究
300 1
|
存储 前端开发 Java
分层开发与响应式编程
分层开发与响应式编程
140 0
分层开发与响应式编程
|
存储 消息中间件 监控
复杂任务中,流程的解耦设计
在系统开发的过程中,必然存在耗时极高的动作,是基于请求响应模式无法解决的问题,通常会采用解耦的思维,并基于异步或者事件驱动的方式去调度整个流程的完整执行。
371 0
复杂任务中,流程的解耦设计
|
缓存 运维 架构师
架构之道:分离业务逻辑和技术细节
架构之道:分离业务逻辑和技术细节
438 1
架构之道:分离业务逻辑和技术细节
|
Cloud Native 测试技术 领域建模
领域模型为核心的架构设计 初篇
领域模型为核心的架构设计 初篇
278 0
领域模型为核心的架构设计 初篇
|
SQL
封装与职责分离的开发思维
封装与职责分离的开发思维
93 0
|
设计模式 算法 安全
洞察设计模式的底层逻辑
设计模式是开发同学经常聊到的话题,也经常被用到实际的开发项目中,熟练的人可以做到信手拈来,不熟悉的人陷入苦思冥想中。笔者认为,不仅仅要掌握设计模式的用法,更要洞察设计模式的底层逻辑,只有那样,才能做到遇到实际的问题可以使用合适的设计模式去解决。
洞察设计模式的底层逻辑
|
缓存 运维 架构师
应用架构之道:分离业务逻辑和技术细节
“让上帝的归上帝,凯撒的归凯撒。”
21052 0
应用架构之道:分离业务逻辑和技术细节
|
SQL 存储 分布式计算
从方法到思维:什么是应用逻辑架构的正确姿势?(上)
本文分享阿里资深技术专家六铢的架构方法论,这套方法论中包含了详细的架构推导逻辑,希望能够帮助大家在工作中从各个粒度、各个层次来做好架构工作。较长,同学们可先收藏再看。
1680 1
从方法到思维:什么是应用逻辑架构的正确姿势?(上)

热门文章

最新文章