写在前面
阅读目录:
领域驱动设计的核心-Domain Model(领域模型),这个大家都知道,可是,上次关于领域模型的设计分享,要追溯到两个月之前了,这中间搞了一些有的没有的东西,比如纠结于仓储等,说这些东西不重要,其实也蛮重要的,因为它是一个完整应用程序所必须要考虑的东西(Demo 除外),但是相对于领域模型,在领域驱动设计中它才是最重要的。
这篇博文我分享的思路是:一个具体的业务场景,一个现实项目的业务需求变化,应用领域驱动设计,看我是如何应对的???
注意:上面我用的是问号,所以,必不可少的会有一些“坑”,大家在读的过程中,要“小心”哦。
具体业务场景
具体业务场景?没错,就是我们熟悉的博客园站内短消息,详见:[网站公告]8月17日14:00-15:00(周日下午)发布新版站内短消息。
上面那次版本发布,已经过去一个多月的时间了,说是“新版”,其实就是重写之前短消息的代码,然后用领域驱动设计的思想去实现,界面换了个“位置”,功能和原来的没有太大变化。发布之后,出现了很多的问题,比如前端界面、数据库优化、代码不规范等等。有些技术问题可以很快的解决,比如数据库的索引优化等,但是,有些问题,比如 SELECT FileName,因为程序代码是基于领域驱动设计的思想去实现的,那你就不能直接去写select filename1,filename2,filename2... from tablename
这样的 SQL 代码,所以实现起来需要思考很多,这个是比较头疼的。
我为什么会说这些问题?因为这些问题,只有在实际应用项目中才会出现,你搞一个领域驱动设计的简单 Demo,会出现数据库性能问题吗?肯定不会,那也就不会去思考仓储的一些问题,更谈不上一些改变了,所以领域驱动设计的推进,只有你去实际用它,而不只是做一些演示的东西,在实际应用中,去发现问题并解决问题,我觉得这样才会更有价值。
关于短消息这个业务场景,其实我之前写的一些领域驱动设计博文,都是围绕着它展开的,很多园友认为这个业务场景是我虚构的,就像之前 netfocus 兄问我:“你说的这个短消息是不是类似于博客园的短消息?”,我回答:“是的!”,呵呵。后来我发现虚构的业务场景,有时候很难说明问题,比如之前 Jesse Liu 在一篇博文中,提到一个用户注册问题,关于这个问题,其实讨论了很久,但最后结果呢?我认为是没有结果,因为业务场景是虚构的,所以就会造成“公说公有理,婆说婆有理”的情况,以至于大家很难达成一些共识的点。
博客园短消息的业务场景,真实的不能再真实了,毕竟大家都在实际用,我就不多说了,就是简单的一个用户和另一个用户发消息,然后进行回复什么的,在之前的一些博文中也有说明,大家可以参考下:我的“第一次”,就这样没了:DDD(领域驱动设计)理论结合实践。
业务需求变化
现在的博客园短消息,为了方便用户看到之前回复的一些内容,我们在底部增加了“=== 下面是回复信息 === ”,示意图:
这种方式其实就是把之前回复内容放到新消息内容里面,然后作为一个新消息进行发送,除去消息内容“冗余”不说,还有个弊端就是,如果两个人回复的次数很多,你会发现,消息内容会变成“一坨XX”,不忍直视。
后来,我们也看不下去了,所以决定做一些改变,仿照 iMessage 或 QQ 那种消息模式,示意图:
这种方式和上面那“一坨XX”形成了鲜明对比,对话模式的消息显示,使用户体验度更好,就像两个人面对面说话一样,很轻松也很简洁,我想我们这种方式的改变,会让你“爱上”我们短消息的。
对,没错,这就是业务需求变化,我们在应用程序开发的过程中,需求是一直不断变化的,我们要做的就是不断完善和适应这种需求变化,当然每个人应对的方式不同,下面看一下我“愚蠢”的应对。
“愚蠢”的应对
我个人觉得这一节点内容非常重要,在领域驱动设计的过程中,也是很多人常掉进的“坑”,因为我们长期受“脚本模式”的影响,在业务需求变化后,应用程序需要做出调整,但是你会不自觉的“跑偏”,这就偏离了领域驱动设计的思想,最后使你的应用程序变得“不伦不类”。
当时为了很快的在应用程序中实现这种功能,我说的是技术上实现,完全没有用领域驱动的思想去考虑,我是怎么思考的呢?先从 UI 上考虑,主要是两个界面:
- 消息列表:收件箱、发件箱和未读消息列表。
- 消息详情:消息详情页。
消息列表实现
之前短消息不管发送,回复,还是转发,都是作为一个新短消息进行发送的,“消息的上下文”作为一个消息的附属体,放在新短息内容中,也就是说,你把之前发送的消息删掉,在新回复的短消息内容中,是仍然看到之前发送内容的,这个在列表的显示就是单独进行显示,但新的需求变化就不能这样进行操作了,这个就有点像两个人聊一个话题,里面都是我们针对这个话题进行讨论的内容,在列表显示的时候,首先,标题显示就是这个话题的标题,就像邮件回复一样,我们可以加上“消息标题(3)”,这个“3”,就表示两个人回复了3次。
其实用话题这个逻辑是有些不准确的,毕竟我们是短消息项目,我们可以这样想,我给 netfocus 发了一个标题为:“打个招呼”,内容为:“hello netfocus”的消息,然后他给我进行了回复:“hello xishuai”,可能后面还有一些消息回复内容,但都是针对我发的第一条消息回复,也就是说下面都是回复内容,那这个在消息列表显示的时候,标题就显示为“打个招呼(3)”,后面时间为最新回复时间,示意图:
上面是 netfocus 的收件箱示意图,收件箱列表显示的逻辑就是以发件人和标题为一个标识,比如 Jesse Liu 也给 netfocus 发了一个“打个招呼”的消息,虽然标题一样,但发件人不一样,所以列表显示两条消息。
那代码怎么实现这个功能呢?贴出代码看看:
public async Task<IEnumerable<MessageListDTO>> GetInbox(Contact reader, PageQuery pageQuery)
{
var query = efContext.Context.Set<Message>()
.Where(new InboxSpecification(reader).GetExpression()).GroupBy(m => new { m.Sender.ID, m.Title }).Select(m => m.OrderByDescending(order => order.ID).FirstOrDefault());
int skip = (pageQuery.PageIndex - 1) * pageQuery.PageSize;
int take = pageQuery.PageSize;
return await query.SortByDescending(sp => sp.ID).Skip(skip).Take(take)
.Project().To<MessageListDTO>().ToListAsync();//MessageListDTO 为上一版本遗留问题(Select FileName),暂时没动。
}
GetInbox 是 MessageRepository 中的操作,其实原本收件箱的代码不是这样处理的,你会看到,现在的代码其实就是 Linq 的代码拼接,我当时这样处理就是为了可以方便查询,现在看确实像“一坨XX”,代码我就不多说了,上面列表显示功能是可以实现的,除去回复数显示,其实你会看到,这个就是对发件人和标题进行筛选,选取发送时间最新的那一条消息。
虽然这段 Linq 代码看起来很“简单”,但是如果你跟踪一下生成的 SQL 代码,会发现它是非常的臃肿,没办法,为了实现功能,然后就不得不去优化数据库,主要是对索引的优化,这个当时优化了好久,也没有找到合适的优化方案,最后不得不重新思考这样做是不是不合理?这完全是技术驱动啊,后来,我发现,在领域驱动设计的道路上,我已经完全“跑偏”了。
消息详情页实现
业务需求的变化,其实主要是消息详情页的变化,从上面那张消息详情页示意图就可以看出,刚才上面说了,收件箱列表显示是对标题和发件人的筛选,其实详情页就是通过标题和发件人找出回复消息,然后通过发送时间降序排列。具体操作是,在收件箱中点击一条消息,然后通过这条消息和发件人去仓储中找这条消息的回复消息,示例代码:
public async Task<IEnumerable<Message>> GetMessages(Message message, Contact reader)
{
if (message.Recipient.ID == reader.ID)
{
return await GetAll(Specification<Message>.Eval(m => m.Title == message.Title
&& ((m.Sender.ID == message.Sender.ID && m.Recipient.ID == message.Recipient.ID && (m.DisplayType == MessageDisplayType.OutboxAndInbox || m.DisplayType == MessageDisplayType.Inbox))
|| (m.Recipient.ID == message.Sender.ID && m.Sender.ID == message.Recipient.ID && (m.DisplayType == MessageDisplayType.OutboxAndInbox || m.DisplayType == MessageDisplayType.Outbox)))),
sp => sp.ID, SortOrder.Ascending).ToListAsync();
}
else
{
return await GetAll(Specification<Message>.Eval(m => m.Title == message.Title
&& ((m.Sender.ID == message.Sender.ID && m.Recipient.ID == message.Recipient.ID && (m.DisplayType == MessageDisplayType.OutboxAndInbox || m.DisplayType == MessageDisplayType.Outbox))
|| (m.Recipient.ID == message.Sender.ID && m.Sender.ID == message.Recipient.ID && (m.DisplayType == MessageDisplayType.OutboxAndInbox || m.DisplayType == MessageDisplayType.Inbox)))),
sp => sp.ID, SortOrder.Ascending).ToListAsync();
}
}
不知道你是否能看懂,反正我现在看这段代码是需要思考一下的,呵呵。消息详情页基本上就是这样实现的,还有一些是在应用层获取“点击消息”,UI 中消息显示判断等一些操作。
消息发送、回复、销毁等实现
其实除了上面列表和详情页的变化,消息发送、回复和销毁实现也需要做出调整,因为消息领域模型没有任何变动,发送消息还是按照之前的发送逻辑,所以发送消息是没有变化的,回复消息也没有大的变化,只不过回复的时候需要获取一下消息标题,因为除了第一条发送消息需要填写标题,之后的消息回复是不需要填写标题的,需要添加的只不过是消息内容。消息销毁的改动相对来说大一点,因为之前都是独立的消息发送,所以可以对每个独立的消息进行销毁操作,但是从上面消息详情页示意图中可以看到,独立的消息是不能销毁的,只能销毁这个完整的消息,也就是详情页最下面的删除按钮,示例代码:
public async Task<OperationResponse> DeleteMessage(int messageId, string readerLoginName)
{
IContactRepository contactRepository = new ContactRepository();
IMessageRepository messageRepository = new MessageRepository();
Message message = await messageRepository.GetByKey(messageId);
if (message == null)
{
return OperationResponse.Error("抱歉!获取失败!错误:消息不存在");
}
Contact reader = await contactRepository.GetContactByLoginName(readerLoginName);
if (reader == null)
{
return OperationResponse.Error("抱歉!删除失败!错误:操作人不存在");
}
if (!message.CanRead(reader))
{
throw new Exception("抱歉!获取失败!错误:没有权限删除");
}
message.DisposeMessage(reader);
var messages = await messageRepository.GetMessages(message, reader);
foreach (Message item in messages)
{
item.DisposeMessage(reader);
messageRepository.Update(item);
}
await messageRepository.Context.Commit();
return OperationResponse.Success("删除成功");
}
这个是应用层中消息销毁操作,可以看到应用层的这个操作代码很凌乱,这就是为了实现而实现的代价,除了消息销毁,还有一个操作就是消息状态设置,也就是消息“未读”和“已读”设置,这个代码实现在应用层 ReadMessage 操作中,代码更加凌乱,我就不贴出来了,和消息销毁操作比较类似,消息状态设置只不过设置一些状态而已。
回到原点的一些思考
为什么我会详细描述我当时实现的思路?其实就是想让你和我产生一些共鸣,上面的一些实现操作,完全是为了实现而实现,不同的应用场景下的业务需求变化是不同的,但思考的方式一般都是想通的,也就是说如果你能正确应对这个业务需求变化,那换一个应用场景,你照样可以应对,如果你不能正确应对,那领域驱动设计就是“空头白话”,为什么?因为领域驱动设计就是更好的应对业务需求变化的。
其实上面的需求变化,我们已经变相的实现了,只不过没有发布出来,就像一个多月之前的发布公告中所说,“Does your code look like this?”,如果按照这种方式实现了,那以后的短消息代码,就是那一坨面条,惨不忍睹。
回到原点的一些思考,其实就是回到领域模型去看待这次的业务需求变化,关于这部分内容,我还没有准确的做法,这边我说一下自己的理解:
业务需求变化,领域模型变化了吗?
首先,在之前的实现中,消息列表显示这部分内容,应该是应用层中体现的,所以在领域模型中可以暂时不考虑,这个在仓储中应该着重思考下。那领域模型变化了什么?先说发送消息,这个变化了吗?我觉得没有,还是点对点的发送一个消息,这个之前是用 SendSiteMessageService 领域服务实现的,逻辑也没有太大的变化,那回复消息呢?其实我觉得这是最大的一个变化,如果你看之前的回复代码,我是没有在领域模型中实现回复消息操作的,为什么?因为我当时认为,回复消息其实也是发送消息,所以在应用层中回复消息操作,其实就是调用的 SendSiteMessageService 领域服务,这个现在看来,是不应该这样实现的。
我们先梳理一下回复消息这个操作的处理流程,这个其实上面有过分析,除了第一条消息是发送以外,之后的消息都是回复操作,这就要有一个标识,用来说明这条消息是回复的那一条发送消息,那这个怎么来设计呢?回复消息设计成实体好?还是值对象好?我个人觉得,应该设计成实体,原因大家想想就知道了,虽然它依附于发送消息存在,但是它也是唯一的,比如一个人给另外两个人回复同样内容的消息,那这两个回复消息应该都是独立存在的,那这个依附关系怎么处理呢?我们可以在消息实体中添加一个标识,用来表示它回复的是那条消息。
上面这个确定之后,那我们如何实现回复消息操作呢?我们可以用一个领域服务实现,比如 ReplySiteMessageService,用来处理回复消息的一些操作,这个和 SendSiteMessageService 领域服务可能会有些不同,比如一个人 1 天只能发送 200 条消息,但是这个逻辑我们就不能放在回复消息领域服务中,回复只是针对一个人的回复,所以这个可以不做限制,发送是针对任何人的,为了避免广告推广,这个我们必须要做一个发送限制,当然具体实现,就要看需求的要求了。
除了回复消息这个变化,说多一点,消息状态(未读和已读)和消息销毁,这个可能也会有细微的变化,比如消息状态,在消息列表中打开一个消息,其实就是把这条消息的回复内容都设置成已读了,我们之前的设计是针对独立的消息状态,也就是说每个消息都有一个消息状态,按照这种方式,其实我们可以把这个状态放在发送消息实体中,如果有人回复了,那这个消息状态就是设置为未读,回复消息没有任何状态,如果这样设计的话,有点像值对象的感觉,可以从消息实体中独立出来一个回复消息值对象,当然这只是我的一种思路。消息销毁和这个消息状态比较类似,这边就不多说了,除了这两个变化,其实还有一些细节需要考虑,这个只能在实现中进行暴露出来了。
对象读取的额外思考
这个其实是我看了仓储那惨不忍睹的实现代码,所引起的一些思考,你可以读一下,这样的一篇博文:你正在以错误的方式使用ORM。
仓储在领域驱动设计的作用,可以看作是实体的存储仓库,我们获取实体对象就要经过仓储,仓储的实现可以是任何方式,但传输对象必须是聚合根对象,这个在理论中没有什么问题,但是在实际项目中,我们从仓储中获取对象,一般有两种用途:
- 用于领域模型中的一些验证操作。
- 用于应用层中的 DTO 对象转化。
第一种没有什么问题,但是第二种,这个就不可避免的造成性能问题,也就是上面文中 Jimmy(AutoMapper 作者)所说的 Select N 问题,这个我之前也遇到过,最后的解决方式,我是按照他在 AutoMapper 映射的一些扩展,也就是上面代码中的 Project().To(),但这样就不可避免的违背了领域驱动设计中仓储的一些思想。
关于这个内容,我不想说太多,重点是上面领域模型的思考,仓储的问题,我是一定要做一些改变的,因为它现在的实现,让强迫症的我感觉到非常不爽,不管是 CQRS、ES、还是六边形架构,总归先尝试实现再说,有问题不可怕,可怕的是不懂得改正。
写在最后
在领域驱动设计的道路上,有很多你意想不到的情况发生,稍微不注意,你就会偏离的大方向,很遗憾,我没有针对这次的业务需求变化,做出一些具体的实现,但我觉得意识到问题很重要,这篇博文分享希望能与你产生一些共鸣。