拨乱反正:DDD 回归具体的业务场景,Domain Model 再再重新设计
首先,把最真挚的情感送与梅西,加油!
写在前面
阅读目录:
上一篇《设计窘境:来自 Repository 的一丝线索,Domain Model 再重新设计》。
讲本篇内容之前,先回顾上一篇所讨论的内容,主要是 Repository(仓储)的职责问题,属于领域?还是应用层?其实到头来也没有准确的结论,但是最终比较偏向于仓储定义在领域,实现在基础层,调用在应用层。你可能有些疑问,为什么要讨论仓储的职责问题?看过上一篇的内容你可能会有些答案,这也就是上一篇博文标题,为什么是“设计窘境”的原因。
本篇博文标题定义为:”拨乱反正“,就像战乱纷争的年代,清剿叛军,回归大统,所表达的意思就是,排除一切干扰因素,回归正确的业务场景,然后进行干净的领域模型设计。领域驱动设计这个实践系列已经写了大概六七篇博文了,从领域模型到底如何设计?到领域模型重新设计,到领域模型再重新设计,到现在的领域模型再再重新设计。。。对,被你看出来了,领域驱动设计中最重要的就是领域模型的设计,但是到现在为止,领域模型的设计一直没有完成,而是一次一次的被推翻重建,道路是曲折的,前途是光明的,但是这个过程真是太痛苦了,回顾现有的这个过程,就会发现,为什么领域模型设计这么难?原因就是领域模型中的职责分配问题,谁存在?谁不存在?谁属于谁?谁不属于谁?谁是谁的?谁是谁的谁的谁(好像是一段歌词,不好意思哈,打顺手了)?但这一切的前提都是建立在正确的业务场景之上,那我们这个业务场景究竟是什么?希望你能从本篇博文中找到些许答案。
如果想了解前面大大小小的“坑”,请访问《[17]小菜学习编程-DDD》,如果不想了解(推荐),那就请从本篇博文开始了解吧。
重申业务场景
其实这个项目(MessageManager)一开始设计的时候,定义为短消息系统,也就是类似博客园短消息系统,发送人给接收人发送一个短消息,接收人接收到这个短消息进行查看,发送人和接收人可以查看各自的收发件箱,大概也就是这样的一个业务场景。但是后来才发现真正的业务场景是,短消息系统中的“短”字应该去掉,也就变成了消息系统,不一定只适用于短消息发送,还可能发送邮件、发送短信等,但是这种发送不会像短消息那种需要进行持久化,他们只是一个发送的动作。
有朋友可能看到这可能会有些疑问,发送邮件、发送短信这种信息发送,不应该属于应用层所干的事吗?其实这很容易造成误解,比如一个在线商城应用程序中,业务需求要求每提交一笔订单发送一封邮件给客户,我们一般会在应用层接收来自领域处理完订单的请求后,调用基础层提供的服务完成邮件发送,这种设计是没有什么问题的。需要明确的是现在的业务场景是消息系统,而不是在线商城,聚焦的是消息业务,那所有具体的消息都是业务,也就包含邮件发送和短信发送,因为他们是属于消息的一种。
毫无疑问,我们现在的消息系统就必须要抽离出,所有消息的抽象业务逻辑,以适用于所有消息业务场景的具体应用,我觉得这才是《道德经》中“有之以为利,无之以为用”的真谛所在(以前关于这个观点的解读,现在感觉都是在瞎扯),消息领域模型所展现的就是“无”,体现出来的结果就是“有”。说具体点就是,所有消息应用包含什么东西,我觉得就是三个对象:发送人对象、接收人对象和消息对象,这三个对象组成一个完整的消息系统,不管短消息、邮件和短信都是如此,三者缺一不可,缺少任何一种就不是一个完整的消息系统,可能在不同的消息场景中会有些变化,比如短消息系统中,发送人是一个用户对象,但是在邮件和短信系统中,发送人只是一个邮箱和手机号标识,但是它也代表着发送人所表达的意义。
除了抽离出消息系统所存在的对象,还要去了解整个消息业务场景中所表现的过程,在消息系统中最重要的一个用例就是消息发送,因为查看消息或者查看收发件箱只在短消息业务场景下,邮件和短信的查看可以通过电子邮箱和手机查看,在消息系统中只存在发消息这个业务,那发消息的流程是什么?首先必须有发件人(标识),填写一个消息,贴上收件人(标识),然后送给邮递员进行邮递,短消息、邮件和短信发送都是这个过程,那我们再来看看下之前用户实体的设计:
/**
* author:xishuai
* address:https://www.github.com/yuezhongxin/MessageManager
**/ using System;
using System.Collections.Generic; namespace MessageManager.Domain.Entity
{
public class User : IAggregateRoot
{
public User(string loginName, string displayName)
{
if (string.IsNullOrEmpty(loginName))
{
throw new ArgumentException("loginName can't be null");
}
if (string.IsNullOrEmpty(displayName))
{
throw new ArgumentException("displayName can't be null");
}
this.ID = Guid.NewGuid().ToString();
this.LoginName = loginName;
this.DisplayName = displayName;
this.SendMessages = new List<Message>();
this.ReceiveMessages = new List<Message>();
} public string ID { get; set; }
public string LoginName { get; private set; }
public string DisplayName { get; private set; }
public virtual ICollection<Message> SendMessages { get; set; }
public virtual ICollection<Message> ReceiveMessages { get; set; } public void SendMessage(User receiveUser, Message message)
{
this.SendMessages.Add(message);
receiveUser.ReceiveMessage(this, message);
}
private void ReceiveMessage(User sendUser, Message message)
{
this.ReceiveMessages.Add(message);
}
}
}
在之前的设计中,我们把用户设计成一个实体,而且是一个独立于消息的聚合根,因为需要通过其他属性获取到用户,但是在现在的消息业务场景中,用户是不需要存储的,也就是说用户的获取或验证都是通过外部实现的,很显然现在的这种设计就有点不合理了。还有就是用户实体下的 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 抽象接口:
/**
* author:xishuai
* address:https://www.github.com/yuezhongxin/MessageManager
**/ namespace MessageManager.Domain.ValueObject
{
public interface IContact
{
string Name { get; set; }
}
}
Sender-发送人:
/**
* author:xishuai
* address:https://www.github.com/yuezhongxin/MessageManager
**/ namespace MessageManager.Domain.ValueObject
{
public class Sender : IContact
{
public Sender(string name)
{
this.Name = name;
}
public string Name { get; set; }
}
}
Recipient-接收人:
/**
* author:xishuai
* address:https://www.github.com/yuezhongxin/MessageManager
**/ namespace MessageManager.Domain.ValueObject
{
public class Recipient : IContact
{
public Recipient(string name)
{
this.Name = name;
}
public string Name { get; set; }
}
}
因为发送人和接收人都是联系人的一种,只不过所扮演的角色不同,所以我们可以把他们抽象出来,IContact 接口中只有一个 Name 属性,用来表示我们上面所讨论的标识,用 Name 也更符合现实生活中的名称(发送人和接收人)。
消息领域模型中的对象确定后,下面就是发送消息服务了,因为消息业务场景中,不只包含短消息的发送,还有邮箱发送和短信发送等,所以我们需要把消息领域服务抽象出来。
ISendMessageService 发送消息领域服务:
/**
* author:xishuai
* address:https://www.github.com/yuezhongxin/MessageManager
**/ using MessageManager.Domain.Entity;
namespace MessageManager.Domain.DomainService
{
public interface ISendMessageService
{
bool SendMessage(Message message);
}
}
SendShortMessageService 短消息领域发送服务实现:
/**
* author:xishuai
* address:https://www.github.com/yuezhongxin/MessageManager
**/ using MessageManager.Domain.Entity;
namespace MessageManager.Domain.DomainService
{
/// <summary>
/// SendShortMessag领域服务实现-短消息发送
/// </summary>
public class SendShortMessageService : ISendMessageService
{
public bool SendMessage(Message message)
{
return true;
}
}
}
除了短消息发送服务,我们还可以实现邮箱发送服务(业务上的 SendMailMessageService),其内部可以调用基础层的发送邮件服务(技术上的 SendMailService),这两个概念容易搞混,需要区分开。通过这个消息领域服务,我们可以在其他的应用程序中进行调用,使用什么消息发送,只需要在调用的时候注入相应的接口实现即可,为什么这么厉害?因为它是所有消息发送的抽象描述,哈哈。
我们再来看下单元测试代码:
/**
* author:xishuai
* address:https://www.github.com/yuezhongxin/MessageManager
**/ using MessageManager.Domain.DomainService;
using MessageManager.Domain.Entity;
using MessageManager.Domain.ValueObject;
using System;
using Xunit; namespace MessageManager.Domain.Tests
{
public class MessageDomainTest
{
/// <summary>
/// 消息发送-短消息
/// </summary>
[Fact]
public void DomainTest_SendShortMessage()
{
ISendMessageService sendMessageService = new SendShortMessageService();
IContact sender = new Sender("sender");
IContact recipient = new Recipient("recipient");
Message message = new Message("title", "content ", sender, recipient);
Assert.True(sendMessageService.SendMessage(message));
}
}
}
从单元测试的代码我们可以很清楚的描述发消息这个业务用例,首先创建一个发送短消息的领域服务对象,然后分别创建发送人和接收人对象,标识分别为: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/小菜
如果你觉得本篇文章对你有所帮助,请点击右下部“推荐”,^_^
拨乱反正:DDD 回归具体的业务场景,Domain Model 再再重新设计的更多相关文章
- DDD 回归具体的业务场景,Domain Model 再再重新设计
DDD 回归具体的业务场景,Domain Model 再再重新设计 首先,把最真挚的情感送与梅西,加油! 写在前面 阅读目录: 重申业务场景 Domain Model 设计 后记 上一篇<设计窘 ...
- 拨开迷雾,找回自我:DDD 应对具体业务场景,Domain Model 到底如何设计?
写在前面 除了博文内容之外,和 netfocus 兄的讨论,也可以让你学到很多(至少我是这样),不要错过哦. 阅读目录: 迷雾森林 找回自我 开源地址 后记 毫无疑问,领域驱动设计的核心是领域模型,领 ...
- 一缕阳光:DDD(领域驱动设计)应对具体业务场景,如何聚焦 Domain Model(领域模型)?
写在前面 阅读目录: 问题根源是什么? <领域驱动设计-软件核心复杂性应对之道>分层概念 Repository(仓储)职责所在? Domain Model(领域模型)重新设计 Domain ...
- 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 ...
- DDD 应对具体业务场景,Domain Model 重新设计
DDD 应对具体业务场景,Domain Model 重新设计 写在前面 上联:no zuo no die why you try 下联:no try no high give me five 横批: ...
- DDD(领域驱动设计)应对具体业务场景,Domain Model(领域模型)到底如何设计?
DDD(领域驱动设计)应对具体业务场景,Domain Model(领域模型)到底如何设计? 写在前面 阅读目录: 迷雾森林 找回自我 开源地址 后记 毫无疑问,领域驱动设计的核心是领域模型,领域模型的 ...
- DDD(领域驱动设计)应对具体业务场景,如何聚焦 Domain Model(领域模型)?
DDD(领域驱动设计)应对具体业务场景,如何聚焦 Domain Model(领域模型)? 阅读目录: 问题根源是什么? <领域驱动设计-软件核心复杂性应对之道>分层概念 Repositor ...
- DDD 领域驱动设计-如何完善 Domain Model(领域模型)?
上一篇:<DDD 领域驱动设计-如何 DDD?> 开源地址:https://github.com/yuezhongxin/CNBlogs.Apply.Sample(代码已更新) 阅读目录: ...
- 【DDD】领域驱动设计实践 —— Domain层实现
本文是DDD框架实现讲解的第三篇,主要介绍了DDD的Domain层的实现,详细讲解了entity.value object.domain event.domain service的职责,以及如何识别出 ...
随机推荐
- 配置python环境变量(转)
默认情况下,在windows下安装python之后,系统并不会自动添加相应的环境变量.此时不能在命令行直接使用python命令. 1.首先需要在系统中注册python环境变量:假设python的安装路 ...
- Python 爬虫2——环境配置
关于环境配置的操作,其实非常简单,假如不使用第三方的框架的话,只需要安装Python即可完成后续的操作. 一.Python的安装和配置: windows系统的安装配置过程如下,假如是Mac系统,可参考 ...
- css中关于居中的问题
居中是最常用的一种css格式,不同的居中方法适和不同的环境中,下面总结了几种常用的居中方法,你可以不用它,但是无论你是一个资深前端大牛,还是小小初学者,当你见到它的时候不认识它就是你的不对啦!!! h ...
- C++中的显式类型转化
类型转化也许大家并不陌生,int i; float j; j = (float)i; i = (int)j; 像这样的显式转化其实很常见,强制类型转换可能会丢失部分数据,所以如果不加(int)做强制转 ...
- angularjs服务-service
Service 的特性 ①service都是单例的 ②service由$injector 负责实例化 ③service在整个应用的声明周期中存在,可以用来共享数据 ④在需要使用的地方利用依赖注入ser ...
- Android四大组件--事务详解(转)
一.什么是事务 事务是访问数据库的一个操作序列,数据库应用系统通过事务集来完成对数据库的存取.事务的正确执行使得数据库从一种状态转换成另一种状态. 事务必须服从ISO/IEC所制定的ACID原则. ...
- 【转】ubuntu下最好用的输入法fcitx-sunpinyin
http://www.freetstar.com/index.php/ubuntu-most-use-friendly-fcitx-sunpinyin 今天难得折腾一会儿输入法,对于系统美化方面的 ...
- Python黑帽编程2.8 套接字编程
Python黑帽编程2.8 套接字编程 套接字编程在本系列教程中地位并不是很突出,但是我们观察网络应用,绝大多数都是基于Socket来做的,哪怕是绝大多数的木马程序也是如此.官方关于socket编程的 ...
- HTML5特性速记图
今天推荐大家一张HTML5特性速记图,供大家平时查阅,也可以打印放在电脑旁帮助速记.速查.此图笔者收集于网络图片.
- Emacs 配置文件
以下是我整理的 emacs 配置文件,供刚开始玩 emacs 的同学参考.网上有人说:emacs 是神的编辑器,如果能够用到这样的编辑器,那这个人就是神了.从我个人的经验来看,emacs 是一把利器, ...