zoukankan      html  css  js  c++  java
  • 应对业务需求变化?

    应对业务需求变化?

    写在前面

    阅读目录:

    领域驱动设计的核心-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

    仓储在领域驱动设计的作用,可以看作是实体的存储仓库,我们获取实体对象就要经过仓储,仓储的实现可以是任何方式,但传输对象必须是聚合根对象,这个在理论中没有什么问题,但是在实际项目中,我们从仓储中获取对象,一般有两种用途:

    1. 用于领域模型中的一些验证操作。
    2. 用于应用层中的 DTO 对象转化。

    第一种没有什么问题,但是第二种,这个就不可避免的造成性能问题,也就是上面文中 Jimmy(AutoMapper 作者)所说的 Select N 问题,这个我之前也遇到过,最后的解决方式,我是按照他在 AutoMapper 映射的一些扩展,也就是上面代码中的 Project().To(),但这样就不可避免的违背了领域驱动设计中仓储的一些思想。

    关于这个内容,我不想说太多,重点是上面领域模型的思考,仓储的问题,我是一定要做一些改变的,因为它现在的实现,让强迫症的我感觉到非常不爽,不管是 CQRS、ES、还是六边形架构,总归先尝试实现再说,有问题不可怕,可怕的是不懂得改正。

    写在最后

    在领域驱动设计的道路上,有很多你意想不到的情况发生,稍微不注意,你就会偏离的大方向,很遗憾,我没有针对这次的业务需求变化,做出一些具体的实现,但我觉得意识到问题很重要,这篇博文分享希望能与你产生一些共鸣。

  • 相关阅读:
    ZOJ 3818 Pretty Poem
    HDU 4597 Play Game
    HDU 4497 GCD and LCM
    CSU 1335 高桥和低桥
    UVA 10791 Minimum Sum LCM
    CSU 1119 Collecting Coins
    CSU 1120 病毒
    UVA 12169 Disgruntled Judge
    HDU 1301 Jungle Roads
    POJ 1258 Agri-Net
  • 原文地址:https://www.cnblogs.com/Leo_wl/p/3990551.html
Copyright © 2011-2022 走看看