DDD 回归具体的业务场景,Domain Model 再再重新设计

首先,把最真挚的情感送与梅西,加油!

写在前面

  阅读目录:

  上一篇《设计窘境:来自 Repository 的一丝线索,Domain Model 再重新设计》。

  讲本篇内容之前,先回顾上一篇所讨论的内容,主要是 Repository(仓储)的职责问题,属于领域?还是应用层?其实到头来也没有准确的结论,但是最终比较偏向于仓储定义在领域,实现在基础层,调用在应用层。你可能有些疑问,为什么要讨论仓储的职责问题?看过上一篇的内容你可能会有些答案,这也就是上一篇博文标题,为什么是“设计窘境”的原因。

  本篇博文标题定义为:”拨乱反正“,就像战乱纷争的年代,清剿叛军,回归大统,所表达的意思就是,排除一切干扰因素,回归正确的业务场景,然后进行干净的领域模型设计。领域驱动设计这个实践系列已经写了大概六七篇博文了,从领域模型到底如何设计?到领域模型重新设计,到领域模型再重新设计,到现在的领域模型再再重新设计。。。对,被你看出来了,领域驱动设计中最重要的就是领域模型的设计,但是到现在为止,领域模型的设计一直没有完成,而是一次一次的被推翻重建,道路是曲折的,前途是光明的,但是这个过程真是太痛苦了,回顾现有的这个过程,就会发现,为什么领域模型设计这么难?原因就是领域模型中的职责分配问题,谁存在?谁不存在?谁属于谁?谁不属于谁?谁是谁的?谁是谁的谁的谁(好像是一段歌词,不好意思哈,打顺手了)?但这一切的前提都是建立在正确的业务场景之上,那我们这个业务场景究竟是什么?希望你能从本篇博文中找到些许答案。

  如果想了解前面大大小小的“坑”,请访问《[17]小菜学习编程-DDD》,如果不想了解(推荐),那就请从本篇博文开始了解吧。

重申业务场景

  其实这个项目(MessageManager)一开始设计的时候,定义为短消息系统,也就是类似博客园短消息系统,发送人给接收人发送一个短消息,接收人接收到这个短消息进行查看,发送人和接收人可以查看各自的收发件箱,大概也就是这样的一个业务场景。但是后来才发现真正的业务场景是,短消息系统中的“短”字应该去掉,也就变成了消息系统,不一定只适用于短消息发送,还可能发送邮件、发送短信等,但是这种发送不会像短消息那种需要进行持久化,他们只是一个发送的动作。

  有朋友可能看到这可能会有些疑问,发送邮件、发送短信这种信息发送,不应该属于应用层所干的事吗?其实这很容易造成误解,比如一个在线商城应用程序中,业务需求要求每提交一笔订单发送一封邮件给客户,我们一般会在应用层接收来自领域处理完订单的请求后,调用基础层提供的服务完成邮件发送,这种设计是没有什么问题的。需要明确的是现在的业务场景是消息系统,而不是在线商城,聚焦的是消息业务,那所有具体的消息都是业务,也就包含邮件发送和短信发送,因为他们是属于消息的一种。

  毫无疑问,我们现在的消息系统就必须要抽离出,所有消息的抽象业务逻辑,以适用于所有消息业务场景的具体应用,我觉得这才是《道德经》中“有之以为利,无之以为用”的真谛所在(以前关于这个观点的解读,现在感觉都是在瞎扯),消息领域模型所展现的就是“无”,体现出来的结果就是“有”。说具体点就是,所有消息应用包含什么东西,我觉得就是三个对象:发送人对象、接收人对象和消息对象,这三个对象组成一个完整的消息系统,不管短消息、邮件和短信都是如此,三者缺一不可,缺少任何一种就不是一个完整的消息系统,可能在不同的消息场景中会有些变化,比如短消息系统中,发送人是一个用户对象,但是在邮件和短信系统中,发送人只是一个邮箱和手机号标识,但是它也代表着发送人所表达的意义。

  除了抽离出消息系统所存在的对象,还要去了解整个消息业务场景中所表现的过程,在消息系统中最重要的一个用例就是消息发送,因为查看消息或者查看收发件箱只在短消息业务场景下,邮件和短信的查看可以通过电子邮箱和手机查看,在消息系统中只存在发消息这个业务,那发消息的流程是什么?首先必须有发件人(标识),填写一个消息,贴上收件人(标识),然后送给邮递员进行邮递,短消息、邮件和短信发送都是这个过程,那我们再来看看下之前用户实体的设计:

 1 /**
2 * author:xishuai
3 * address:https://www.github.com/yuezhongxin/MessageManager
4 **/
5
6 using System;
7 using System.Collections.Generic;
8
9 namespace MessageManager.Domain.Entity
10 {
11 public class User : IAggregateRoot
12 {
13 public User(string loginName, string displayName)
14 {
15 if (string.IsNullOrEmpty(loginName))
16 {
17 throw new ArgumentException("loginName can't be null");
18 }
19 if (string.IsNullOrEmpty(displayName))
20 {
21 throw new ArgumentException("displayName can't be null");
22 }
23 this.ID = Guid.NewGuid().ToString();
24 this.LoginName = loginName;
25 this.DisplayName = displayName;
26 this.SendMessages = new List<Message>();
27 this.ReceiveMessages = new List<Message>();
28 }
29
30 public string ID { get; set; }
31 public string LoginName { get; private set; }
32 public string DisplayName { get; private set; }
33 public virtual ICollection<Message> SendMessages { get; set; }
34 public virtual ICollection<Message> ReceiveMessages { get; set; }
35
36 public void SendMessage(User receiveUser, Message message)
37 {
38 this.SendMessages.Add(message);
39 receiveUser.ReceiveMessage(this, message);
40 }
41 private void ReceiveMessage(User sendUser, Message message)
42 {
43 this.ReceiveMessages.Add(message);
44 }
45 }
46 }

  在之前的设计中,我们把用户设计成一个实体,而且是一个独立于消息的聚合根,因为需要通过其他属性获取到用户,但是在现在的消息业务场景中,用户是不需要存储的,也就是说用户的获取或验证都是通过外部实现的,很显然现在的这种设计就有点不合理了。还有就是用户实体下的 SendMessages 和 ReceiveMessages 属性,用来表示此用户下的发件箱和收件箱,如果存在用户实体,这种设计也是有待商榷的,更何况用户实体并不存在。还有后面加的 SendMessage 和 ReceiveMessage 方法,表示用户发送消息和接收消息的一种行为,为什么要这样设计?主要原因还是来自上一篇的讨论:

  《领域驱动设计》账户转账示例流程分享(来自 hailants):

  1. 用户发起业务,界面层调用应用层的转账操作

  2. 应用层调用 a123 的转账操作,传入对方账户和转账金额

  3. 账户 a123 调用 a234 加钱操作

  4. a234 将操作添入事务单元,向账户 a123 返回确认

  5. 账户 a123 调用自身减钱操作,添入事务单元,向应用层返回确认

  6. 应用层提交事务,向界面层返回确认

  相应推理出发送消息业务流程:

  1. 用户发起发消息业务请求,界面调用应用层转账操作(传入参数为:标题,内容,发件人名称,收件人名称)

  2. 应用层首先创建一个发件人对象(通过仓储获得),然后再创建收件人对象和消息对象

  3. 发件人对象调用用户实体中的 SendMessage 操作(参数为收件人对象和消息对象)

  4. 在发件人对象中的 SendMessage 方法中,收件人对象调用用户实体中的 ReceiveMessage 操作(参数为发件人和消息对象)

  5. 在以上操作的完成后添加到事务操作(具体就是往消息仓储中添加消息领域对象)

  6. 应用层提交事务,向界面返回确认。

  其实这种设计某种意义上也没有什么问题,至少在短消息系统中,因为发消息本身就是用户的一种行为,但是如果仔细一想就会觉得有些别扭,首先账户转账业务流程和发消息业务流程虽然表面上相似,但是其聚合的对象并不相同,比如账户转账示例中,聚合的是账户,发消息业务场景中如果这样分析应该聚合的是用户,但是很显然并不是,聚合的是消息对象,如果聚合用户就会变成用户消息系统了,这就偏离了大方向,并不是我们所想看到的。还有就是现在的这种设计只适用于短消息业务场景,在邮件和短信业务场景中并不适用,为什么?因为邮件发送和短信发送并不存在用户的概念(这个用户概念并不是现实生活中的人,而是系统中的用户,这个观点很容易造成误解),有的只是一个标识(电子邮件或手机号),用来体现出发件人和收件人的概念,也就是说这种标识并不是一个对象(没有行为的对象),准确的来说应该不是一个实体,那是什么?在领域驱动设计中设计为值对象(为什么要设计成值对象,后面领域模型设计中进行说明),一个没有行为的对象中加入行为操作,本身逻辑就存在问题,所以这种设计是有问题的。

  回到发消息这个业务用例上,一个消息对象存在意义的前提是拥有标题、内容、发送人(标识)和接收人(标识),当然还存在一些选填元素,但是主要包含这四个元素,缺少任何一种,就不是一个完整的消息对象,也就是说不能用来发送。发送操作不仅仅是一个对象的行为,而应该是消息领域模型提供的一种服务,也就是领域服务,提供各种消息发送的服务,这一点很容易和基础层的消息发送服务搞混,区分他们只需要记住一点:基础层是技术上的实现,领域是业务上的抽象,因为这个业务场景是消息系统,那发消息就是一种业务用例,而并不是一个技术调用方法。

  说了这么多,总结一下所描述的消息业务场景:抽象所有消息业务逻辑(包含短消息、邮件和短信等),应用具体的业务场景(比如短消息)。发消息业务用例:发送人(系统用户)填写消息,包含标题、内容、发送人(标识)、接收人(标识),调用(应用层发送请求)服务(领域服务)发送消息,相当于邮递员投递信件,就是这样的一个过程,至少听起来这么简单,实现起来呢?我觉得那是另一方面的问题了,呵呵。

Domain Model 设计

  回顾之前领域模型的设计,你会发现完全是一套一套的,也就是说差别很大,造成这种设计的主要原因是领域模型中的边界和职责问题,这也是领域模型设计中最难的一点,如果边界确定和职责分配和上一版本有细微的差别,那设计出来的领域模型会和上一版本完全不一样,就比如用户的边界确定(是实体?还是值对象?),还有就是仓储的职责问题(领域还是应用层?),如果不确定这些因素,设计出来的领域模型就不是真正的领域模型。

  仓储的职责问题在上一篇中有过讨论,开头也给过总结,这边就不多说了,其实现在在设计领域模型的时候就要排除一切干扰因素,比如我现在在设计的时候就把表现层、应用层、仓储中的项目卸载掉了,这个解决方案中的项目就只剩基础层、领域模型和领域中的单元测试项目,这样在设计领域模型的时候才能保证其“纯净度”。

  在上面重申业务场景节点中,把发送人(标识)或接收人(标识)设计成值对象,为什么要这样设计?我们稍后解读,先说一下实体和值对象的区别,这两个对象的概念网上有很多资料进行参考,但最好还是看下《领域驱动设计》这本书的定义,作者关于实体的解读,重点强调了实体的唯一性,也就是说实体必须通过唯一标识进行区分,比如消息系统中的消息实体,虽然我和同一个人发送同样内容的消息,但是这两个消息就不能用同一个对象进行标识,而是两个具有同样消息内容的不同消息实体,换句话说,我们不仅需要知道消息是什么,而且还要知道消息是哪个。关于值对象的解读,作者主要强调:“值对象就是那些在设计中我们只关心它们是什么,而不关心它们谁是谁的对象。”这是和实体的最好区分,就是说对于值对象,我们只要知道他们是什么就行了,而并不需要他们是哪个,就比如消息系统中的消息状态值对象,包含两个内容:未读和已读,相对于消息实体而言,我们只需要知道消息状态是什么,它所表达的内容(我们并不关心,它从哪里来,到哪里去)。还有就是值对象是一般相对于实体而言的,就是说值对象一般附属在实体上,如果独立于实体,他们就不存在任何意义,就像消息状态值对象,它如果独立于消息实体,就没有什么意义了,因为消息状态只有相对于消息对象而言才有存在的意义。

  内容有点多,换个行。

  那为什么要把发送人(标识)或接收人(标识)设计成值对象?那我们分析一下消息系统中收发件人,首先需要明确一点的就是,我们设计的是消息系统,并非是用户消息系统,也就是说把用户中的行为剔除掉(SendMessage 和 ReceiveMessage),对象除掉行为之后就只有属性了,如果一个实体中只有属性,是不是所必要的呢?对于消息系统而言,用户是不被存储的,也就是说用户只是在消息系统中作为一个标识,所谓标识就是所表现出来的一个值。比如发邮件业务场景中,用户A(123@gmail.com)给用户B(456@gmail.com)发送了一封邮件,对于消息系统,我需要确定用户A和用户B吗?显然不需要,因为我只要知道 123@gmail.com 这个邮箱给 456@gmail.com 这个邮箱发送了一封邮件就行了,至于是哪个用户发的,在消息系统中并不需要考虑。在短消息业务场景中也是类似,因为短消息系统中的用户概念来自于其他系统,那其他系统对于用户而言肯定有一个唯一标识(比如主键值、用户名、显示名等等),对于消息系统而言,我只要知道这个标识就行了,至于这个标识所代表的是哪个用户,并不需要关心。

  把发送人(标识)或接收人(标识)设计成值对象,还有一个重要原因是,如果把发送人(标识)或接收人(标识)设计成实体,那他们可以独立于消息实体存在,但是我们所设计的是消息系统,并不是用户消息系统,用户来自于外部,如果在消息系统中单独存在就有点不伦不类了,还有就是如果用户设计成实体,这些用户实体对象是需要存储的,这就违背了我们的业务需求。如果把用户设计成值对象呢?就符合我们现在的消息业务场景了,因为在消息系统中,我们只需要知道用户是什么,而且用户独立于消息,对于消息系统而言将没有任何意义。

  概念理清楚,下面就是具体的设计了。

  IContact 抽象接口:

 1 /**
2 * author:xishuai
3 * address:https://www.github.com/yuezhongxin/MessageManager
4 **/
5
6 namespace MessageManager.Domain.ValueObject
7 {
8 public interface IContact
9 {
10 string Name { get; set; }
11 }
12 }

  Sender-发送人:

 

  Recipient-接收人:

 

  因为发送人和接收人都是联系人的一种,只不过所扮演的角色不同,所以我们可以把他们抽象出来,IContact 接口中只有一个 Name 属性,用来表示我们上面所讨论的标识,用 Name 也更符合现实生活中的名称(发送人和接收人)。

  消息领域模型中的对象确定后,下面就是发送消息服务了,因为消息业务场景中,不只包含短消息的发送,还有邮箱发送和短信发送等,所以我们需要把消息领域服务抽象出来。

  ISendMessageService 发送消息领域服务:

 1 /**
2 * author:xishuai
3 * address:https://www.github.com/yuezhongxin/MessageManager
4 **/
5
6 using MessageManager.Domain.Entity;
7 namespace MessageManager.Domain.DomainService
8 {
9 public interface ISendMessageService
10 {
11 bool SendMessage(Message message);
12 }
13 }

  SendShortMessageService 短消息领域发送服务实现:

 

  除了短消息发送服务,我们还可以实现邮箱发送服务(业务上的 SendMailMessageService),其内部可以调用基础层的发送邮件服务(技术上的 SendMailService),这两个概念容易搞混,需要区分开。通过这个消息领域服务,我们可以在其他的应用程序中进行调用,使用什么消息发送,只需要在调用的时候注入相应的接口实现即可,为什么这么厉害?因为它是所有消息发送的抽象描述,哈哈。

  我们再来看下单元测试代码:

 1 /**
2 * author:xishuai
3 * address:https://www.github.com/yuezhongxin/MessageManager
4 **/
5
6 using MessageManager.Domain.DomainService;
7 using MessageManager.Domain.Entity;
8 using MessageManager.Domain.ValueObject;
9 using System;
10 using Xunit;
11
12 namespace MessageManager.Domain.Tests
13 {
14 public class MessageDomainTest
15 {
16 /// <summary>
17 /// 消息发送-短消息
18 /// </summary>
19 [Fact]
20 public void DomainTest_SendShortMessage()
21 {
22 ISendMessageService sendMessageService = new SendShortMessageService();
23 IContact sender = new Sender("sender");
24 IContact recipient = new Recipient("recipient");
25 Message message = new Message("title", "content ", sender, recipient);
26 Assert.True(sendMessageService.SendMessage(message));
27 }
28 }
29 }

  从单元测试的代码我们可以很清楚的描述发消息这个业务用例,首先创建一个发送短消息的领域服务对象,然后分别创建发送人和接收人对象,标识分别为:sender 和 recipient,下面创建一个消息对象,然后调用领域服务传入消息对象参数进行发送,完成整个的发消息业务。虽然看起来简单,也很容易造成误解,有人也会怀疑领域模型就是这样?其实就是这样,测试用例代码描述的就是业务用例,它体现出来的就是领域模型,当然,SendMessage 方法中只有一段代码,但是它所表达的就是这个业务场景的具体体现。

  有时候领域模型设计不出来,可以先写领域模型测试用例的伪代码,因为领域模型的测试用例反应的就是业务需求,一个完成业务场景的具体过程,这也是一种开发的方式,DDD+TDD=?(某一方面的相加)

后记

  有朋友可能会觉得:如此简单的业务场景,使用领域驱动设计开发,会不会太简单了?或者说根本不适合?我只想说:大哥,如果你觉得简单,请收了我,可好?

  其实任何存在具体的业务场景,不管简单或不简单,都可以使用领域驱动设计开发,领域驱动设计应对的是复杂度,这个复杂度可以理解为未来的复杂度,如果在前期开发的时候,领域模型设计的好,那么后面改东西就会很方便,当然到现在为止,我还只是道听途说,并没有真正体会它的好处,但是我很期待,我想那种感觉应该会很美妙。

  关于本篇博文内容就是这些,不管错与对,欢迎大家讨论,只有这样,大家才可以学到更多,不只是你我哦。

  MessageManager 项目开源地址:

  如果你觉得本篇文章对你有所帮助,请点击右下部“推荐”,^_^

DDD 回归具体的业务场景,Domain Model 再再重新设计的更多相关文章

  1. 拨乱反正:DDD 回归具体的业务场景,Domain Model 再再重新设计

    首先,把最真挚的情感送与梅西,加油! 写在前面 阅读目录: 重申业务场景 Domain Model 设计 后记 上一篇<设计窘境:来自 Repository 的一丝线索,Domain Model ...

  2. 拨开迷雾,找回自我:DDD 应对具体业务场景,Domain Model 到底如何设计?

    写在前面 除了博文内容之外,和 netfocus 兄的讨论,也可以让你学到很多(至少我是这样),不要错过哦. 阅读目录: 迷雾森林 找回自我 开源地址 后记 毫无疑问,领域驱动设计的核心是领域模型,领 ...

  3. 一缕阳光:DDD(领域驱动设计)应对具体业务场景,如何聚焦 Domain Model(领域模型)?

    写在前面 阅读目录: 问题根源是什么? <领域驱动设计-软件核心复杂性应对之道>分层概念 Repository(仓储)职责所在? Domain Model(领域模型)重新设计 Domain ...

  4. No zuo no die:DDD 应对具体业务场景,Domain Model 重新设计

    写在前面 上联:no zuo no die why you try 下联:no try no high give me five 横批: let it go上联:no zuo no die why y ...

  5. DDD 应对具体业务场景,Domain Model 重新设计

    DDD 应对具体业务场景,Domain Model 重新设计 写在前面 上联:no zuo no die why you try 下联:no try no high give me five 横批: ...

  6. DDD(领域驱动设计)应对具体业务场景,Domain Model(领域模型)到底如何设计?

    DDD(领域驱动设计)应对具体业务场景,Domain Model(领域模型)到底如何设计? 写在前面 阅读目录: 迷雾森林 找回自我 开源地址 后记 毫无疑问,领域驱动设计的核心是领域模型,领域模型的 ...

  7. DDD(领域驱动设计)应对具体业务场景,如何聚焦 Domain Model(领域模型)?

    DDD(领域驱动设计)应对具体业务场景,如何聚焦 Domain Model(领域模型)? 阅读目录: 问题根源是什么? <领域驱动设计-软件核心复杂性应对之道>分层概念 Repositor ...

  8. DDD 领域驱动设计-如何完善 Domain Model(领域模型)?

    上一篇:<DDD 领域驱动设计-如何 DDD?> 开源地址:https://github.com/yuezhongxin/CNBlogs.Apply.Sample(代码已更新) 阅读目录: ...

  9. 贫血模型;DTO:数据传输对象(Data Transfer Object);AutoMapper ;Domain Model(领域模型);DDD(领域驱动设计)

    ====================== 我自己的理解 ========================== 一:  DTO  我自己的理解,就是 比如你有一个类,跟数据库的table表结构一模一 ...

随机推荐

  1. Android清除缓存功能来实现

    我们都知道在Android的设置->应用程序中能够查看应用程序的相关信息,当中有一个功能是清除缓存. 如图: watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZ ...

  2. oracle PL/SQL(procedure language/SQL)程序设计之函数+过程+包(转)

    匿名PL/SQL块回顾 DECLARE (可选)     定义在PL/SQL块中要使用的对象 BEGIN (必须)     执行语句 EXCEPTION (可选)     错误处理语句 END; (必 ...

  3. JavaScript通告/订阅的例子

    原文链接: Pub/Sub JavaScript Object原始日期: 2014年6一个月11日本: 2014年6月13日 翻译人员: 铁锚 高效AJAX站点的三大杀器: 事件代理, 浏览历史管理, ...

  4. WebAPI客户端

    封装WebAPI客户端,附赠Nuget打包上传VS拓展工具 一.前言 上篇< WebAPI使用多个xml文件生成帮助文档 >有提到为什么会出现基于多个xml文件生成帮助文档的解决方案,因为 ...

  5. 1023 Train Problem II(卡特兰数)

    Problem Description As we all know the Train Problem I, the boss of the Ignatius Train Station want ...

  6. OData.NET已在 GitHub上开源

    OData.NET已在 GitHub上开源 微软最近已将OData .NET所有类库的源代码全部发布在GitHub上. 以下与OData相关的项目目前都已迁移到GitHub上: ODataLib Ed ...

  7. java_maven_linux_windows下项目运行jar

    我才用的是eclipse自带的export功能,暂时没把maven的打包插件研究清楚 导出jar包后,需要用解压缩打开,把配置文件copy到压缩包的内的顶级目录 保存 写 bat 脚本 @author ...

  8. PHP移动互联网的发展票据(6)——MySQL召回数据库基础架构[1]

    原文地址 :http://www.php100.com/html/php/api/2014/0326/6707.html 一.数据类型 1.整型 数据类型 存储空间 说明 取值范围 TINYINT 1 ...

  9. 了解了解你自己的话zookeeper(从那时起,纠正了一些说法在线)

    1,先看看官方的定义吧: ZooKeeper is a distributed, open-source coordination service for distributed applicatio ...

  10. sqlite学习笔记5:插入数据,查询数据和删除数据

    曾闻:全部的编程都是已数据为中心,觉得很有道理. 所谓数据库数据库,没有数据叫什么数据库,接下来就看看怎样在表中插入数据. 一 插入数据 1 创建一张表 首先为了插入数据,须要先创建一张表: CREA ...