关于DDD聚合设计的一些思考

前言

聚合作为领域模型中重要的业务功能单元,它的设计是领域建模过程中非常重要的工作。其中聚合根的判断并非一件易事,往往给人一种似是而非的感觉,让人难以捉摸,陷入两难的境地。今天笔者就想以博客园为例来探讨下:博客 (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)

深入理解DDD的聚合模式 - 知乎 (zhihu.com)

posted @ 2023-08-29 15:14  天行健君子以自强  阅读(2061)  评论(16编辑  收藏  举报