關於DDD中聚合設計的思考(以部落格園為例)

2023-08-29 18:06:22

前言

聚合作為領域模型中重要的業務功能單元,它的設計是領域建模過程中非常重要的工作。其中聚合根的判斷並非一件易事,往往給人一種似是而非的感覺,讓人難以捉摸,陷入兩難的境地。今天筆者就想以部落格園為例來探討下:部落格 (Blog) 和評論 (Comment) 究竟是不是一個聚合?

問題探討

眾所周知,在部落格這個領域中,核心子域就是寫部落格。從部落格這個限界上下文中,我們很容易提煉出部落格和評論兩個領域物件,兩者之間是一種從屬關係。那麼我們該如何來進行聚合設計呢?先來回顧下DDD中聚合的概念:

聚合(Aggregate)定義了一組具有內聚關係的相關物件的集合,我們把聚合看作是一個修改資料的單元。每個聚合都含有一個根實體,叫做聚合根(Aggregate Root),它是聚合的管理者。

我想很多人心中已有答案:部落格和評論不就是一個具有從屬關係的聚合嗎?所有評論都是圍繞部落格而存在的,一旦部落格被刪除後,那麼評論也將不復存在。當我們要檢視或發表評論,我們必須先找到自己感興趣的部落格才行。試想,現實中存不存在這樣的業務場景,可以繞開部落格直接檢視或發表評論? 答案是否。因此在部落格這個聚合裡,部落格就是聚合根,而評論就是實體。如果僅靠從屬關係來判定聚合的話,筆者認為依據是不充分的,繼續往下看。

現在我們再來思考:部落格和評論除了從屬關係之外,兩者之間還存在哪些約束關係?根據部落格園現有的功能,我們可以得出以下業務規則:每個使用者都可以發表評論,但只能修改和刪除自己的評論,只有博主可以刪除別人評論。最終轉換成的業務程式碼,大致如下:

    /// <summary>
    /// 部落格
    /// </summary>
    public class Blog : IAggregateRoot
    {
        /// <summary>
        /// 部落格Id
        /// </summary>
        public Guid Id { get; private set; }

        /// <summary>
        /// 博主Id
        /// </summary>
        public Guid OwnerId { get; private set; }

        /// <summary>
        /// 評論集合
        /// </summary>
        public ICollection<Comment> Comments { get; private set; }

        /// <summary>
        /// 新增評論
        /// </summary>
        /// <param name="commentId">評論Id</param>
        /// <param name="commentId">評論內容</param>
        /// <param name="userId">使用者Id</param>
        public void AddComment(Guid commentId, string content, Guid userId)
        {
            var comment = new Comment(commentId, content, Id, userId);
            Comments.Add(comment);
        }

        /// <summary>
        /// 刪除評論
        /// </summary>
        /// <param name="commentId">評論Id</param>
        /// <param name="userId">使用者Id</param>
        public void RemoveComment(Guid commentId, Guid userId)
        {
            var comment = Comments.Single(c => c.Id == commentId);
            if (comment.OwnerId != userId && OwnerId != userId)
            {
                throw new UserFriendlyException("不能刪除別人的評論");
            }
            Comments.Remove(comment);
        }

        /// <summary>
        /// 修改評論
        /// </summary>
        /// <param name="commentId">評論Id</param>
        /// <param name="content">評論內容</param>
        /// <param name="userId">使用者Id</param>
        public void UpdateComment(Guid commentId, string content, Guid userId)
        {
            var comment = Comments.Single(c => c.Id == commentId);
            if (comment.OwnerId != userId)
            {
                throw new UserFriendlyException("不能修改別人的評論");
            }
            comment.Content = content;
        }
    }

從上述程式碼中,細心的網友不難發現:部落格和評論之間維持的是一種很弱的業務關係,而且每次增加、刪除或修改評論,都必須先把評論全部檢索出來(因為聚合是一個完整的資料單元)。肯定會有人質疑:這樣做是否有必要?如果評論很多的話,對查詢效能沒有影響嗎?這是筆者曾經看到過的一篇博文《部落格園的大牛們,被你們害慘了,Entity Framework從來都不需要去寫Repository設計模式》,評論數多達291條,從效能角度而言,這的確是一件令人擔憂的事情。於是,我們就該反思領域建模哪裡出了問題?

我們再來看評論這個領域物件,它本身是一個實體,且含有特定的業務規則:即每個使用者只能對別人的評論發表支援/反對意見,這樣才能客觀公正反映評論的價值。因此在評論這個領域中,評論和評論意見就是一個獨立的聚合。評論是聚合根,評論意見是實體。一旦評論被刪除,所有的支援/反對意見將毫無意義。

看到這裡,我們終於明白了一個真相:相同的領域物件在不同的上下文中,它既可以是實體,也可以是聚合根。回頭再看部落格園這個例子,部落格本身是聚合根沒錯,它除了和評論關聯之外,實際上還關聯了合集和標籤等其它領域物件。 如果把評論當成部落格聚合裡的實體的話,你會發現部落格這個聚合過於龐大,管理的實體太多,內部邏輯關係也更加複雜,同時還存在不可忽視的效能問題。因此,我們有必要將評論提升成為聚合根,這樣既避免了效能問題,同時也讓部落格聚合根的職責變得簡單。這裡請思考一個問題:假設部落格園改變業務規則,部落格可以關閉評論,且評論數量不得超過10條,那麼評論可否作為部落格聚合中的實體呢?

最後總結

DDD中的聚合是可以拆分的最小功能單元,它是用來封裝真正的業務不變性,而不是簡單地將物件組合在一起。如果把聚合比作組織,那聚合根就是這個組織的管理者,負責協調實體和值物件,一起完成共同的業務邏輯。同時聚合的設計並不是一成不變的,需要根據業務規則和實際情況來調整,哪怕一開始建模是正確的。還有一點就是聚合要儘量設計得小,這樣獨立性才高,才能適應業務的變化。以上就是筆者對聚合的一些思考,如有不當之處,請指正。

參考資料

如何運用領域驅動設計 - 聚合 - 句幽 - 部落格園 (cnblogs.com)

聚合(根)、實體、值物件精煉思考總結 - netfocus - 部落格園 (cnblogs.com)