基于ABP实现DDD--聚合和聚合根实践

在下面的例子中涉及Repository、Issue、Label、User这4个聚合根,接下来以Issue聚合为例进行分析,其中Issue聚合是由Issue[聚合根]、Comment[实体]、IssueLabel[值对象]组成的集合。.
基于ABP实现DDD--聚合和聚合根实践

1.单个单元原则

  简单理解,一个聚合就是由实体和值对象组成的集合,通过聚合根将所有关联对象绑定在一起,一个聚合是一个相对独立的业务单元。聚合和聚合根原则包括:包含业务原则,单个单元原则,事务边界原则,可序列化原则。接下来通过例子重点介绍下什么是单个单元原则,本质上是为了实现业务规则并保持数据的一致性和完整性。比如,要向Issue中添加Comment,操作如下:

  • 通过聚合根Issue加载所有的实体Comments[该问题的评论列表]和值对象IssueLabels[该问题的标签集合]等。
  • 在Issue类中有个AddComment()方法可以添加一个新的Comment。
  • 通过数据库更新操作将Issue聚合,包括实体和值对象等保存到数据库。

添加Comment到Issue如下所示:

public class IssueAppService : ApplicationService, IIssueAppService
{
    private readonly IRepository<IssueAppService, Guid> _issueRepository;
    public IssueAppService(IRepository<Issue, Guid> issueRepository)
    {
        _issueRepository = issueRepository;
    }
    
    [Authorize]
    public async Task CreateCommentAsync(CreateCommentInput input)
    {
        // 加载Issue对象并包含所有子集合
        var issue = await _issueRepository.GetAsync(input.IssueId);
        // 哪个用户评论了什么内容
        issue.AddComment(CurrentUser.GetId(), input.Text);
        // 保存更改到数据库,执行完后自动调用DbContext.SaveChanges()
        await _issueRepository.UpdateAsync(issue);
    }
}

2.只通过ID引用其它聚合

Repository和Issue的关系是一对多,即一个Repository对应多个Issue:

public class GitRepository : AggregateRoot<Guid>
{
    public string name { get; set; }
    public int StarCount { get; set; }
    public Collection<Issue> Issues { get; set; } //错误实践,不能添加导航属性到其它聚合根
}

public class Issue : AggregateRoot<Guid>
{
    public string Text { get; set; }
    public GitRepository Repository { get; set; } //错误实践,不能添加导航属性到其它聚合根
    public Guid RepositoryId { get; set; } //正确实践
}

3.聚合根要足够小

因为一个聚合将做为一个整体被加载和保存,如果聚合根很大,在读写一个大对象的时候会影响到性能问题。

using Microsoft.VisualBasic;

public class UserRole : ValueObject //值对象
{
    public Guid UserId { get; set; } 
    public Guid RoleId { get; set; }
}

public class Role : AggregateRoot<Guid>
{
    public string Name { get; set; }
    public Collection<UserRole> Users { get; set; } //错误实践,理由是角色对应的用户是增加的
}

public class User : AggregateRoot<Guid>
{
    public string Name { get; set; }
    public Collection<UserRole> Roles { get; set; } //正确实践,理由是用户对应的角色总是有限的
}

官方的建议是一个子集合最多不应包含超过100-150条记录,否则建议为实体单独提取为一个新的聚合根。

4.聚合根/实体中的主键

聚合根通常使用Guid作为主键,聚合根中的实体[不是聚合根]可以使用复合主键。

// 聚合根:单个主键
public class Organization
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    // ...
}

// 实体:复合主键[值对象]
public class OrganizationUser
{
    public Guid OrganizationId { get; set; } //主键
    public Guid UserId { get; set; } //主键
    public bool IsOwner { get; set; }
    // ...
}

一般聚合根中的实体[不是聚合根]是单个主键的,而值对象基本都是复合主键,比如IssueLabel,通过复合主键关联Issue和Label这2个聚合根。

5.业务逻辑和实体中的异常处理

假定有2个业务原则:第1个是锁定的Issue不能重新打开,第2个是不能锁定一个关闭的Issue:

public class Issue:AggregateRoot<Guid>
{
    //...
    public bool IsLocked {get;private set;}
    public bool IsClosed{get;private set;}
    public IssueCloseReason? CloseReason {get;private set;}
    public void Close(IssueCloseReason reason)
    {
        IsClose = true;
        CloseReason =reason;
    }
    public void ReOpen() //重新打开
    {
        if(IsLocked)
        {
            throw new IssueStateException("不能打开⼀个锁定的问题!请先解锁!");
        }
        IsClosed=false;
        CloseReason=null;
    }
    public void Lock() //锁定
    {
        if(!IsClosed)
        {
            throw new IssueStateException("不能锁定⼀个关闭的问题!请先打开!");
        }
    }
    public void Unlock() //解锁
    {
        IsLocked = false;
    }
}

这时会遇到2个问题,一个是异常消息本地化,另一个是HTTP状态码。通过ABP的异常处理系统可以解决这些问题,即IssueStateException类继承自BusinessException类[1]。重写ReOpen方法:

public void ReOpen()
{
    if (IsLocked)
    {
        throw new IssueStateException("IssueTracking:CanNotOpenLockedIssue");
    }
    IsClosed = false;
    CloseReason = null;
}

为了实现本地化消息处理,只用在本地化资源中添加"IssueTracking:CanNotOpenLockedIssue":"不能打开⼀个锁定的问题!请先解锁!"即可。HTTP状态码在BusinessException类中已经处理好了,比如403表示请求禁用,500表示服务器内部错误等。

6.实体中业务逻辑需要用到外部服务

假如业务规则是:一个用户不能同时分配超过3个未解决的问题。这时就需要一个服务,根据User的Id获取已经分配的未解决问题的数目。如何在实体类中实现它呢?暂时解决问题的思路是将外部依赖项作为方法的参数:

public class Issue : AggregateRoot<Guid>
{
    // ...
    public Guid? AssignedUserId { get; private set; } //将实体属性访问器设置私有,这样只能通过方法来访问
    
    // 问题分配方法
    // IUserIssueService:用于获取分配给用户的未解决问题的数量
    public async Task AssignToAsync(AppUser user, IUserIssueService userIssueService)
    {
        var openIssueCount = await userIssueService.GetOpenIssueCountAsync(user.Id);
        if (openIssueCount >= 3)
        {
            throw new BusinessException("IssueTracking:CanNotOpenLockedIssue");
        }
        AssignedUserId = user.Id;
    }
    
    // 清空分配方法
    public void CleanAssignment()
    {
        AssignedUserId = null;
    }
}

这种实现方式虽然满足了业务实现,但是实体变的复杂且难用,一方面实体类依赖外部服务,另一方面在调用方法AssignToAsync的时候需要注入依赖的外部服务IUserIssueService作为参数。比较优雅的实现此业务逻辑的方式是引入领域服务。
说明:聚合和聚合根最佳实践中的用于EF Core和关系型数据库、聚合根/实体构造函数、实体属性访问器和方法这3个部分就不介绍了,感兴趣参考《基于ABP Framework实现领域驱动设计》[2]。

参考文献:
[1]ABP异常处理:https://docs.abp.io/zh-Hans/abp/latest/Exception-Handling
[2]基于ABP Framework实现领域驱动设计:https://url39.ctfile.com/f/2501739-616007877-f3e258?p=2096 (访问密码: 2096)