前面一篇文章介绍了我设计的基于“事件”驱动的领域模型的基础框架的设计起因和设计思路。基于这个框架,我们领域模型中的所有领域对象有如下几个特点:
- 任何一个领域对象是“活”的,它不仅有属性(对象的状态),而且有方法(对象的行为)。为什么说是“活”的呢?因为领域对象的行为都不是被另外的领域对象调用的,而是自己去响应一些“事件” ,然后执行其自身的某个行为的。在我看来,如果一个领域对象的方法是被其他的领域对象调用的,那这个对象就是“死”的,因为它没有主动地去参与到某个活动中去。这里需要强调的一点是,领域对象只会更新它自己的状态,而不会更新其他领域对象的状态。
- 所有的领域对象之间都是平等的,任何两个领域对象之间不会有任何引用的关系(如,依赖、关联、聚合、组合);但是它们之间会存在数据上的关系,如一个对象会保留另外一个对象的唯一标识。
- 领域对象之间的交互和通信全部通过事件来完成,事件可以将所有的领域对象串联起来使它们能相互协作。除此之外,领域对象和外界的各种交互也通过事件完成。按照Eric Evans的理论,为了确保领域对象之间的概念完整性,需要有聚合及聚合根的概念,聚合根聚合了很多子的实体或值对象,或者还会关联其他的聚合根。另外,每个聚合需要有一个仓储(Repository)来负责聚合的持久化和重建的职责。其实,我觉得要确保领域对象之间的概念完整性,除了通过聚合的方式之外,还可以通过事件来确保。其实,用聚合来确保概念完整性是事物之间直接作用的反映;而用事件来确保概念完整性则是事物之间间接作用的反映。在用事件的方式下,仓储也不再需要了,因为领域模型和外界的交互也是通过事件来完成的。
虽然在前面那篇文章中提供了两个Demo用来展示框架的功能,但我想大家直接看Demo源代码还是比较累,并且不能直观的看到框架能做什么以及如何使用。因此,本篇文章打算举几个典型的例子来分析如何使用我的框架来解决各种典型的应用场景。
应用场景1:银行转账
银行转账的核心流程大家应该都很熟悉了,主要有这么几步:
- 源账户扣除转账金额,当然首先需要先判断当前账户余额是否足够,如果不够,则无法转账。
- 目标账户增加转账金额;
-
为源账户生成一笔转账记录;
-
为目标账户生成一笔转账记录;
下面看看如何通过事件来实现上面的应用场景:
首先定义一个转账的事件:
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 }
该事件定义了:源账户、目标账户、转账金额、转账时间四个信息;
然后看看银行帐号类的设计:
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事件即可:
2 new TransferEvent {
3 FromBankAccountId = bankAccount1.Id,
4 ToBankAccountId = bankAccount2.Id,
5 MoneyAmount = 1000,
6 TransferDate = DateTime.Now
7 }
8 );
上面的代码通知中央事件处理器处理一个转账的事件。
好了,理想情况下,如果只要上面的这样三段代码就能完成转账的业务场景了,那就太好了。但是那时不可能的,因为还有一个很重要的信息没有告诉框架,那就是框架还不知道源账号和目标账号的唯一标识,我们需要告诉框架源账号的唯一标识是从事件的那个属性中获取,目标帐号的唯一标识是从事件的那个属性中获取。如下的代码体现了这点:
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。
首先看一下帖子类:
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>这两个事件就表示新增了一个帖子的回复的事件和移除了一个帖子的回复的事件。
另外,我们可以通过下面的代码来添加一个回复,或移除一个回复。
2 Repository.Remove(reply1); //移除回复的代码
大家从上面的代码中看到了Repository,也就是仓储。其实这个类不是真正的仓储,因为它的内部实现也仅仅是做了“发布事件”的事情。换句话说,我这里的Repository只是帮我们做了发布一些通用典型事件的操作。可以看一下这两个方法的实现:
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 }
当然,和前面的例子一样,我们还必须告诉框架,从事件的那个部分去获取事先响应者的唯一标识,我们可以通过下面的简单明了的代码来告诉框架这个信息:
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理论中,也正是通过聚合及仓储的设计来确保各个领域对象之间的概念完整性的。
但是,我上面提到过,没有了聚合,没有了仓储,我们还可以通过事件来确保领域对象的完整性。下面举个例子来说明如何实现这个目标:
大家都知道一个论坛中帖子与帖子回复的关系应该是和书本与书本评论的关系是同一种关系。也就是说,当我们在删除一个帖子时,还需要级联删除帖子的回复。
帖子类的实现上面已经写了,这里我们看一下帖子回复类的实现:
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 }
可以看到回复类有一个事件响应函数,该函数表示当其所属的帖子删除时,需要把自己也一起删除。也就是说,当我们在执行如下代码时,上面代码中的响应函数就会自动被执行。
当然,框架还不可能也做不到这么智能的地步。我们必须告诉框架哪些回复回去响应DomainObjectRemovedEvent<Topic>事件。如下代码所示:
evnt => Repository.Find<Reply, FindTopicRepliesEvent>(evt => evt.TopicId = evnt.DomainObject.Id));
上面的代码表示帖子回复类会去响应DomainObjectRemovedEvent<Topic>事件,也就是帖子被删除的事件,并且通过一个委托来告诉框架有哪些回复会响应该事件。
总结:
从上面的几个例子,我们可以清楚的看到领域对象之间没有相互引用,完全通过事件来实现相互协作。比如父对象通知子对象,子对象通知父对象,一个事件通知一个或多个同类型或不同类型的对象,等等。要实现任何一个业务场景,我们需要做的事情一般是:
1)通知中央事件处理器处理某个事件(如果该事件是框架没有提供特定的业务事件,则需要自己定义,如TransferEvent);
2)领域对象响应该事件,通过定义私有的响应函数来实现响应;
3)在领域模型内部,告诉框架事件及响应者之间的映射关系,并告诉框架有哪个或哪些对象会去响应,它们的唯一标识是如何从事件中获取的;
通过这三个步骤,我们就可以轻松简单的实现各种领域对象之间的协作了。而且需要强调的是,通过这样的方式,可以充分体现出领域对象是“活”的这个概念。因为所有的领域对象的事件响应函数都是私有的,也就是领域对象自己的行为别的领域对象无法去调用,而都是由一个“中央事件处理器”去统一调用。这样的效果就是,任何一个领域对象都会“主动”去响应某个事件,这样就从分体现出了“活”对象的概念了。在我看来,这才是真正的面向对象编程,因为所有的对象都是主动参与到某个业务场景中去的。
最后,关于使用这种方式来组织业务逻辑的好处和坏处,我目前还没有仔细研究过,我还没有利用该框架做过一个真实的项目。但我想有一点是可以肯定的,那就是这应该是另外一种全新的组织业务逻辑的方法,并且它的最大特点是高度可扩展性,因为是基于事件消息机制的,把领域对象之间的耦合度降到了最低,但同时我想在可维护性方面可能会有一些缺点。