• 拨乱反正: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-发送人:

     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 }
    View Code

      Recipient-接收人:

     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 }
    View Code

      因为发送人和接收人都是联系人的一种,只不过所扮演的角色不同,所以我们可以把他们抽象出来,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 短消息领域发送服务实现:

     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 }
    View Code

      除了短消息发送服务,我们还可以实现邮箱发送服务(业务上的 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 项目开源地址:

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

  • 相关阅读:
    echarts属性杂记
    vue工作问题小计
    vue 表单数据修改,导致页面列表数据被同步修改问题的解决。
    利用syslog记录日志的简单日志函数
    1 概述
    PowerDesigner 如何自定义Data Type
    Mybatisplus读取(GeoJson)和保存Postgis geography数据
    【Mybatis】model类通过注解忽略某属性
    如何利用PostGIS正确计算距离和面积
    Linux上编写监控jar包重启脚本
  • 原文地址:https://www.cnblogs.com/xishuai/p/ddd_domain_model_design.html
Copyright © 2020-2023  润新知