领域驱动设计思考 20220214

2022年2月14日 2342点热度 0人点赞 2条评论
内容纲要

过年假期在学习了一些领域驱动知识,这里做个汇总。

在领域驱动设计中,程序进行分层,是其重要的一部分,因此这里以分层开始,逐步了解 DDD 中的一些概念。

数据映射层

首先第一步是数据库,数据库这一部分是持久层,负责实体对象和数据库表的映射以及数据库连接配置、数据库上下文配置、ORM 配置等,数据库和缓存它们都是供领域层使用,但是不能直接使用,而是通过仓储的封装。

对于数据库表,在程序中使用实体来对应数据库表,而作为持久层,持久化对象称为 Persistent Object,缩写是 PO,表示持久化的对象。因为实体叫 Entity,很多时候都是用 Entity 做后缀,而使用使用 PO 描述 Entity 对象。

但是在 C# 编程规范中,并不建议使用后缀命名,因此如果你有留意 ABP 的命名方式,可能并不会带有后缀。

如一个实体如下:

    public class TodoItem
    {
        public int Id { get; set; }
        public string Text { get; set; }
    }

在数据持久层中,一方面要配置实体跟数据库表的映射,然后配置好 ORM 框架和 Redis 这些操作客户端,以便供依赖注入到领域层中。

聚合和仓储

仓储是领域层的一部分,仓储要根据领域来划分。

很多人的做法是每个实体做一个仓储,一个表一个仓储,这样可能导致过于繁杂,代码量变大,造成冗余。

例如 User 与 Password 两个表,给 Password 做仓储是没有意义的,而 Password 跟 User 一起才有具体的含义。

User、Password 伪代码如下:

class User
{
    int Id
    string Name
    string Email
}

class Password
{
    int Id
    int UserId
    string Password
}

Password 通过 Password.UserId - User.Id 关联起来,并且很明显 User 是主要的,我们可以叫 User 是基类、主体。这个 “关联” ,便是聚合。

聚合表示一组领域对象(包括实体和值对象),用来表述一个完整的领域概念。而每个聚合都有一个根实体,这个根实体又叫做聚合根。

在 ABP 中,使用 FullAuditedAggregateRoot<>IFullAuditedObject<> 来标识一个聚合。

    public class Author : FullAuditedAggregateRoot<Guid>
    {
        // public T Id
        public string Name { get; private set; }
        public DateTime BirthDate { get; set; }
        public string ShortBio { get; set; }
    }

继续回到 User、Password 的例子。

那么此时,User.Id 可以做聚合根,我们可以通过聚合根把 User、Password 聚合起来。

聚合根一般就是基类主键,有以下约束:

  • 推荐 总是使用 Id 属性做为聚合根主键.
  • 不推荐 在聚合根中使用 复合主键.
  • 推荐 所有的聚合根都使用 Guid 类型 主键.(这点是 ABP 中推荐的)

就第三点来说,对于分布式系统中,可能使用雪花 ID 做主键;还有很多场景不便用 Guid 做主键,因此读者具体而定。

实际使用时,并不需要知道 Password 的 ID,只需要知道 User 即可,Password 主键用于在数据库中存储,对于业务而言不重要,因此可以还可以根据实际情况简化,我们可以将 User 和 Password 的字段整合起来:

class LoginAggregate
{
    int Id
    string Name
    string Email
    string Password
}

这样在我们登录时,如果要检验账号和密码,就不需要先检查 User,接着检查 Password,通过这个聚合,在一个类中检查多个字段。

不过这样写,每次都要复制很多字段呀,当然你可以通过继承等方式解决这个问题,但是这样也会导致关系耦合。

为了表征 User.Id(聚合根),我们可以通过抽象接口,表达这个聚合:

class LoginAggregate :  AggregateRoot<int>
{
    string Name
    string Email
    string Password
}

User 除了跟 Password 有关联,也跟很多表有关联,不可能在一个聚合中把他们都写进去的,在登录场景中,可以加上 User、Password、LoginRecord 三个实体,如果是用户信息维护,则只需要 User。也就是基于 User ,可以有多种聚合,它们都是不一样的,那么怎么设计聚合呢?参考 ABP 中的聚合边界:

推荐 聚合尽可能小. 大多数聚合只有原始属性, 不会有子集合. 把这些视为设计决策:

  • 加载和保存聚合的 性能内存 成本 (请记住,聚合通常是做为一个单独的单元被加载和保存的). 较大的聚合会消耗更多的CPU和内存.
  • 一致性 & 有效性 边界.

而 EFCore 中有导航属性,可以帮助我们简化聚合的编写,EFCore 可以通过外键关系生成对应的关系。

public class Blog
{
    public int BlogId { get; set; }
    public string Url { get; set; }

    public List<Post> Posts { get; set; }
}

public class Post
{
    public int PostId { get; set; }
    public string Title { get; set; }
    public string Content { get; set; }

    public int BlogForeignKey { get; set; }
    public Blog Blog { get; set; }
}

但是因为很多时候,不建议使用外键,因此不能自动生成这种关系。当然 .NET 中的 Freesql 框架可以手动配置导航属性而不需要外键。不过这种关系是写到实体中的。

.NET 中的实体跟聚合有什么关系和区别呢?

遗憾的是,两者的定义可以互换使用,这可能会令人困惑,但这就是每个人的实际含义。

这个回答可以参考:https://stackoverflow.com/questions/32353835/difference-between-an-entity-and-an-aggregate-in-domain-driven-design

聚合本身也是一个实体。

对于聚合来说,因为其本身也是一个实体,那么他本身也有字段和属性:

    public class Author : FullAuditedAggregateRoot<Guid>
    {
        public string Name { get; private set; }
        public DateTime BirthDate { get; set; }
        public string ShortBio { get; set; }

注意,Name 字段被设置为 private ,因为不能随意修改。
因此,如果要修改,则需要在聚合中定义相关方法来处理。

        internal Author(Guid id, [NotNull] string name, DateTime birthDate, [CanBeNull] string shortBio = null) :
            base(id)
        {
            SetName(name);
            BirthDate = birthDate;
            ShortBio = shortBio;
        }

        internal Author ChangeName([NotNull] string name)
        {
            SetName(name);
            return this;
        }
        private void SetName([NotNull] string name)
        {
            Name = Check.NotNullOrWhiteSpace(name, nameof(name), AuthorConsts.MaxNameLength);
        }

关于聚合和实体,就到此为止。

由于本文中,只涉及简单的说明,推荐读者参考别人的设计思想和理论:https://zhuanlan.zhihu.com/p/359672528

仓储和领域层

在领域层中,应当包含以下内容:

  • 实体&聚合根

  • 值对象

  • 仓储

  • 领域服务

在 ABP 中,一般为一个聚合做一个仓储。

"在领域层和数据映射层之间进行中介,使用类似集合的接口来操作领域对象." (Martin Fowler).

简单来说,领域服务中不应该直接操作 ORM 、Redis 这种框架,领域服务也不关心这个数据从数据库中获取还是从缓存中获取,领域服务只关心怎么操作这些数据。这就需要仓储。

下面写个简单的示例。

例如,一个产品,用户可以下单买它,我们在后台中,要查看这个产品有多少人下单购买。

实体:

       public class User
        {
            public int Id { get; set; }
            public string Name { get; set; }
            public string Email { get; set; }
        }

        public class Product
        {
            public int Id { get; set; }
            public string Name { get; set; }
            // 产品的其它属性
        }

        public class Order
        {
            public int ProductId { get; set; }

            public int Id { get; set; }
            public int UserId { get; set; }
            public long CreationTime { get; set; }
            public double Money { get; set; }
        }

聚合:

        public class OderAggregat
        {
            public int ProductId { get; set; }

            public int Id { get; set; }
            public int UserId { get; set; }
            public long CreationTime { get; set; }
            public double Money { get; set; }

            public User User { get; set; }
        }

        public class ProductAggregat
        {
            public int Id { get; set; }
            public string Name { get; set; }

            public List<OderAggregat>  Orders { get; set; }
    }

一个简单的仓储:

        public class ProductRepository
        {
            private readonly AppCenterContext _context;
            private readonly ILogger<ProductRepository> _logger;
            public ProductRepository(AppCenterContext context,ILoggerFactory<ProductRepository> loggerFactory)
            {
                _context = context;
                _logger = loggerFactory.CreateLogger<ProductRepository>();
            }

            // 获取相关订单的数量
            public async Task<int> GetOrderCountAsync(int productId)
            {
                var count = await _context.Order.Select
                    .Where(x => x.ProductId == productId).CountAsync();
                return count;
            }
        }

在仓储中封装针对此特点场景,编写对应的操作方法,还可以在此加入缓存。而在领域服务中,可以如果依赖注入,使用仓储类。

使用仓储和领域服务

创建一个仓储:

    // 仓储
    public class AccountRepository
    {
     public IEneumerable<Object> GetAsync()
     {
         ...
     }
     public DataResult InsertAsync(Account account)
     {
         ...
     }
    }

创建一个领域服务:

    public class AccountManger
    {
        private readonly AccountRepository _accountRepository;

        public AccountManger(AccountRepository accountRepository)
        {
            _accountRepository = accountRepository;
        }

        public DataResult InsertAsync(Account account)
        {
            if(账号已经存在)
            {
                return ...
            }
            return await _accountRepository.InsertAsync(account);
        }
    }

领域服务使用 Manager 后缀。

领域服务中,不需要对仓储进行封装,仓储的获取数据和插入数据接口都是原始的,插入数据时并不判断各种用户名、邮箱等是否已经存在的逻辑,而领域服务中需要确认数据是否可以插入,然后再在仓储中插入。

应用服务:

public class AccountService
{
    private readonly AccountRepository _accountRepository;
    private readonly AccountManger _accountManger;

    // 获取接口直接用 仓储的
     public IEneumerable<Object> GetAsync()
     {
            return await _accountRepository.GetAsync();
     }
    // c
     public DataResult InsertAsync(Account account)
     {
            return await _accountManger.InsertAsync(account);
     }
}

痴者工良

高级程序员劝退师

文章评论

  • l-7-l

    谢谢 很不错 清晰明了 对于我这种初学者很有帮助,不过感觉不够完善, 如果有时间和兴趣的话 建议出一篇 modular monolith with ddd. 顺祝暴富 平安 健康

    2022年11月16日
  • 痴者工良

    .NET 5 中带有 MediatR 的 CQRS
    https://www.c-sharpcorner.com/article/cqrs-mediatr-in-net-5/

    完全模块化整体应用与领域驱动设计方法。
    https://github.com/kgrzybek/modular-monolith-with-ddd

    2022年7月20日