拨乱反正: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):
-
用户发起业务,界面层调用应用层的转账操作
-
应用层调用 a123 的转账操作,传入对方账户和转账金额
-
账户 a123 调用 a234 加钱操作
-
a234 将操作添入事务单元,向账户 a123 返回确认
-
账户 a123 调用自身减钱操作,添入事务单元,向应用层返回确认
-
应用层提交事务,向界面层返回确认
相应推理出发送消息业务流程:
-
用户发起发消息业务请求,界面调用应用层转账操作(传入参数为:标题,内容,发件人名称,收件人名称)
-
应用层首先创建一个发件人对象(通过仓储获得),然后再创建收件人对象和消息对象
-
发件人对象调用用户实体中的 SendMessage 操作(参数为收件人对象和消息对象)
-
在发件人对象中的 SendMessage 方法中,收件人对象调用用户实体中的 ReceiveMessage 操作(参数为发件人和消息对象)
-
在以上操作的完成后添加到事务操作(具体就是往消息仓储中添加消息领域对象)
-
应用层提交事务,向界面返回确认。
其实这种设计某种意义上也没有什么问题,至少在短消息系统中,因为发消息本身就是用户的一种行为,但是如果仔细一想就会觉得有些别扭,首先账户转账业务流程和发消息业务流程虽然表面上相似,但是其聚合的对象并不相同,比如账户转账示例中,聚合的是账户,发消息业务场景中如果这样分析应该聚合的是用户,但是很显然并不是,聚合的是消息对象,如果聚合用户就会变成用户消息系统了,这就偏离了大方向,并不是我们所想看到的。还有就是现在的这种设计只适用于短消息业务场景,在邮件和短信业务场景中并不适用,为什么?因为邮件发送和短信发送并不存在用户的概念(这个用户概念并不是现实生活中的人,而是系统中的用户,这个观点很容易造成误解),有的只是一个标识(电子邮件或手机号),用来体现出发件人和收件人的概念,也就是说这种标识并不是一个对象(没有行为的对象),准确的来说应该不是一个实体,那是什么?在领域驱动设计中设计为值对象(为什么要设计成值对象,后面领域模型设计中进行说明),一个没有行为的对象中加入行为操作,本身逻辑就存在问题,所以这种设计是有问题的。
回到发消息这个业务用例上,一个消息对象存在意义的前提是拥有标题、内容、发送人(标识)和接收人(标识),当然还存在一些选填元素,但是主要包含这四个元素,缺少任何一种,就不是一个完整的消息对象,也就是说不能用来发送。发送操作不仅仅是一个对象的行为,而应该是消息领域模型提供的一种服务,也就是领域服务,提供各种消息发送的服务,这一点很容易和基础层的消息发送服务搞混,区分他们只需要记住一点:基础层是技术上的实现,领域是业务上的抽象,因为这个业务场景是消息系统,那发消息就是一种业务用例,而并不是一个技术调用方法。
说了这么多,总结一下所描述的消息业务场景:抽象所有消息业务逻辑(包含短消息、邮件和短信等),应用具体的业务场景(比如短消息)。发消息业务用例:发送人(系统用户)填写消息,包含标题、内容、发送人(标识)、接收人(标识),调用(应用层发送请求)服务(领域服务)发送消息,相当于邮递员投递信件,就是这样的一个过程,至少听起来这么简单,实现起来呢?我觉得那是另一方面的问题了,呵呵。
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-发送人:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
1 /** 2 * author:xishuai 3 * address:https://www.github.com/yuezhongxin/MessageManager 4 **/ 5 6 namespace MessageManager.Domain.ValueObject 7 { 8 public class Sender : IContact 9 { 10 public Sender(string name) 11 { 12 this.Name = name; 13 } 14 public string Name { get; set; } 15 } 16 }
Recipient-接收人:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
1 /** 2 * author:xishuai 3 * address:https://www.github.com/yuezhongxin/MessageManager 4 **/ 5 6 namespace MessageManager.Domain.ValueObject 7 { 8 public class Recipient : IContact 9 { 10 public Recipient(string name) 11 { 12 this.Name = name; 13 } 14 public string Name { get; set; } 15 } 16 }
因为发送人和接收人都是联系人的一种,只不过所扮演的角色不同,所以我们可以把他们抽象出来,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 短消息领域发送服务实现:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
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 /// <summary> 10 /// SendShortMessag领域服务实现-短消息发送 11 /// </summary> 12 public class SendShortMessageService : ISendMessageService 13 { 14 public bool SendMessage(Message message) 15 { 16 return true; 17 } 18 } 19 }
除了短消息发送服务,我们还可以实现邮箱发送服务(业务上的 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 项目开源地址:
- GitHub 开源地址:https://github.com/yuezhongxin/MessageManager
- ASP.NET MVC 发布地址:http://www.xishuaiblog.com:8081/
- ASP.NET WebAPI 发布地址:http://www.xishuaiblog.com:8082/api/Message/GetMessagesBySendUser/小菜
如果你觉得本篇文章对你有所帮助,请点击右下部“推荐”,^_^
微信公众号:你好架构
出处:http://www.cnblogs.com/xishuai/
公众号会不定时的分享有关架构的方方面面,包含并不局限于:Microservices(微服务)、Service Mesh(服务网格)、DDD/TDD、Spring Cloud、Dubbo、Service Fabric、Linkerd、Envoy、Istio、Conduit、Kubernetes、Docker、MacOS/Linux、Java、.NET Core/ASP.NET Core、Redis、RabbitMQ、MongoDB、GitLab、CI/CD(持续集成/持续部署)、DevOps等等。
本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接。