qileilove

          blog已經轉移至github,大家請訪問 http://qaseven.github.io/

          看我如何應對業務需求變化,“愚蠢”的應對?

          領域驅動設計的核心-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、還是六邊形架構,總歸先嘗試實現再說,有問題不可怕,可怕的是不懂得改正。
            寫在最后
            在領域驅動設計的道路上,有很多你意想不到的情況發生,稍微不注意,你就會偏離的大方向,很遺憾,我沒有針對這次的業務需求變化,做出一些具體的實現,但我覺得意識到問題很重要,這篇博文分享希望能與你產生一些共鳴。

          posted on 2014-10-16 09:56 順其自然EVO 閱讀(209) 評論(0)  編輯  收藏 所屬分類: 測試學習專欄

          <2014年10月>
          2829301234
          567891011
          12131415161718
          19202122232425
          2627282930311
          2345678

          導航

          統計

          常用鏈接

          留言簿(55)

          隨筆分類

          隨筆檔案

          文章分類

          文章檔案

          搜索

          最新評論

          閱讀排行榜

          評論排行榜

          主站蜘蛛池模板: 灯塔市| 呼和浩特市| 教育| 剑川县| 海阳市| 满城县| 饶河县| 华阴市| 和政县| 天全县| 枣庄市| 冕宁县| 新沂市| 道孚县| 新河县| 盐城市| 石渠县| 吴堡县| 呼图壁县| 青浦区| 浮山县| 阜平县| 志丹县| 富民县| 平昌县| 青浦区| 离岛区| 琼结县| 阳江市| 深泽县| 黎城县| 台北市| 内江市| 公主岭市| 木里| 靖江市| 祁东县| 蓝田县| 乌拉特前旗| 大丰市| 新乡市|