天天看點

DDD 領域驅動設計-看我如何應對業務需求變化,愚蠢的應對?

寫在前面

閱讀目錄:

  • 具體業務場景
  • 業務需求變化
  • “愚蠢”的應對
    1. 消息清單實作
    2. 消息詳情頁實作
    3. 消息發送、回複、銷毀等實作
  • 回到原點的一些思考
    1. 業務需求變化,領域模型變化了嗎?
    2. 對象讀取的額外思考
  • 寫在最後

領域驅動設計的核心-Domain Model(領域模型),這個大家都知道,可是,上次關于領域模型的設計分享,要追溯到兩個月之前了,這中間搞了一些有的沒有的東西,比如糾結于倉儲等,說這些東西不重要,其實也蠻重要的,因為它是一個完整應用程式所必須要考慮的東西(Demo 除外),但是相對于領域模型,在領域驅動設計中它才是最重要的。

這篇博文我分享的思路是:一個具體的業務場景,一個現實項目的業務需求變化,應用領域驅動設計,看我是如何應對的???

注意:上面我用的是問号,是以,必不可少的會有一些“坑”,大家在讀的過程中,要“小心”哦。

具體業務場景?沒錯,就是我們熟悉的部落格園站内短消息,詳見:[網站公告]8月17日14:00-15:00(周日下午)釋出新版站内短消息。

上面那次版本釋出,已經過去一個多月的時間了,說是“新版”,其實就是重寫之前短消息的代碼,然後用領域驅動設計的思想去實作,界面換了個“位置”,功能和原來的沒有太大變化。釋出之後,出現了很多的問題,比如前端界面、資料庫優化、代碼不規範等等。有些技術問題可以很快的解決,比如資料庫的索引優化等,但是,有些問題,比如 SELECT FileName,因為程式代碼是基于領域驅動設計的思想去實作的,那你就不能直接去寫

select filename1,filename2,filename2... from tablename

這樣的 SQL 代碼,是以實作起來需要思考很多,這個是比較頭疼的。

我為什麼會說這些問題?因為這些問題,隻有在實際應用項目中才會出現,你搞一個領域驅動設計的簡單 Demo,會出現資料庫性能問題嗎?肯定不會,那也就不會去思考倉儲的一些問題,更談不上一些改變了,是以領域驅動設計的推進,隻有你去實際用它,而不隻是做一些示範的東西,在實際應用中,去發現問題并解決問題,我覺得這樣才會更有價值。

關于短消息這個業務場景,其實我之前寫的一些領域驅動設計博文,都是圍繞着它展開的,很多園友認為這個業務場景是我虛構的,就像之前 netfocus 兄問我:“你說的這個短消息是不是類似于部落格園的短消息?”,我回答:“是的!”,呵呵。後來我發現虛構的業務場景,有時候很難說明問題,比如之前 Jesse Liu 在一篇博文中,提到一個使用者注冊問題,關于這個問題,其實讨論了很久,但最後結果呢?我認為是沒有結果,因為業務場景是虛構的,是以就會造成“公說公有理,婆說婆有理”的情況,以至于大家很難達成一些共識的點。

部落格園短消息的業務場景,真實的不能再真實了,畢竟大家都在實際用,我就不多說了,就是簡單的一個使用者和另一個使用者發消息,然後進行回複什麼的,在之前的一些博文中也有說明,大家可以參考下:我的“第一次”,就這樣沒了:DDD(領域驅動設計)理論結合實踐。

現在的部落格園短消息,為了友善使用者看到之前回複的一些内容,我們在底部增加了“=== 下面是回複資訊 === ”,示意圖:

DDD 領域驅動設計-看我如何應對業務需求變化,愚蠢的應對?

這種方式其實就是把之前回複内容放到新消息内容裡面,然後作為一個新消息進行發送,除去消息内容“備援”不說,還有個弊端就是,如果兩個人回複的次數很多,你會發現,消息内容會變成“一坨XX”,不忍直視。

後來,我們也看不下去了,是以決定做一些改變,仿照 iMessage 或 QQ 那種消息模式,示意圖:

DDD 領域驅動設計-看我如何應對業務需求變化,愚蠢的應對?

這種方式和上面那“一坨XX”形成了鮮明對比,對話模式的消息顯示,使使用者體驗度更好,就像兩個人面對面說話一樣,很輕松也很簡潔,我想我們這種方式的改變,會讓你“愛上”我們短消息的。

對,沒錯,這就是業務需求變化,我們在應用程式開發的過程中,需求是一直不斷變化的,我們要做的就是不斷完善和适應這種需求變化,當然每個人應對的方式不同,下面看一下我“愚蠢”的應對。

我個人覺得這一節點内容非常重要,在領域驅動設計的過程中,也是很多人常掉進的“坑”,因為我們長期受“腳本模式”的影響,在業務需求變化後,應用程式需要做出調整,但是你會不自覺的“跑偏”,這就偏離了領域驅動設計的思想,最後使你的應用程式變得“不倫不類”。

當時為了很快的在應用程式中實作這種功能,我說的是技術上實作,完全沒有用領域驅動的思想去考慮,我是怎麼思考的呢?先從 UI 上考慮,主要是兩個界面:

  • 消息清單:收件箱、發件箱和未讀消息清單。
  • 消息詳情:消息詳情頁。

之前短消息不管發送,回複,還是轉發,都是作為一個新短消息進行發送的,“消息的上下文”作為一個消息的附屬體,放在新短息内容中,也就是說,你把之前發送的消息删掉,在新回複的短消息内容中,是仍然看到之前發送内容的,這個在清單的顯示就是單獨進行顯示,但新的需求變化就不能這樣進行操作了,這個就有點像兩個人聊一個話題,裡面都是我們針對這個話題進行讨論的内容,在清單顯示的時候,首先,标題顯示就是這個話題的标題,就像郵件回複一樣,我們可以加上“消息标題(3)”,這個“3”,就表示兩個人回複了3次。

其實用話題這個邏輯是有些不準确的,畢竟我們是短消息項目,我們可以這樣想,我給 netfocus 發了一個标題為:“打個招呼”,内容為:“hello netfocus”的消息,然後他給我進行了回複:“hello xishuai”,可能後面還有一些消息回複内容,但都是針對我發的第一條消息回複,也就是說下面都是回複内容,那這個在消息清單顯示的時候,标題就顯示為“打個招呼(3)”,後面時間為最新回複時間,示意圖:

DDD 領域驅動設計-看我如何應對業務需求變化,愚蠢的應對?

上面是 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、還是六邊形架構,總歸先嘗試實作再說,有問題不可怕,可怕的是不懂得改正。

DDD 領域驅動設計-看我如何應對業務需求變化,愚蠢的應對?

在領域驅動設計的道路上,有很多你意想不到的情況發生,稍微不注意,你就會偏離的大方向,很遺憾,我沒有針對這次的業務需求變化,做出一些具體的實作,但我覺得意識到問題很重要,這篇博文分享希望能與你産生一些共鳴。

作者:田園裡的蟋蟀

微信公衆号:你好架構

出處:http://www.cnblogs.com/xishuai/

公衆号會不定時的分享有關架構的方方面面,包含并不局限于:Microservices(微服務)、Service Mesh(服務網格)、DDD/TDD、Spring Cloud、Dubbo、Service Fabric、Linkerd、Envoy、Istio、Conduit、Kubernetes、Docker、MacOS/Linux、Java、.NET Core/ASP.NET Core、Redis、RabbitMQ、MongoDB、GitLab、CI/CD(持續內建/持續部署)、DevOps等等。

本文版權歸作者和部落格園共有,歡迎轉載,但未經作者同意必須保留此段聲明,且在文章頁面明顯位置給出原文連接配接。

分享到:

QQ空間

新浪微網誌

騰訊微網誌

微信

更多

繼續閱讀