过年假期在学习了一些领域驱动知识,这里做个汇总。
在领域驱动设计中,程序进行分层,是其重要的一部分,因此这里以分层开始,逐步了解 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 中的实体跟聚合有什么关系和区别呢?
遗憾的是,两者的定义可以互换使用,这可能会令人困惑,但这就是每个人的实际含义。
聚合本身也是一个实体。
对于聚合来说,因为其本身也是一个实体,那么他本身也有字段和属性:
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);
}
}
文章评论
谢谢 很不错 清晰明了 对于我这种初学者很有帮助,不过感觉不够完善, 如果有时间和兴趣的话 建议出一篇 modular monolith with ddd. 顺祝暴富 平安 健康
.NET 5 中带有 MediatR 的 CQRS
https://www.c-sharpcorner.com/article/cqrs-mediatr-in-net-5/
完全模块化整体应用与领域驱动设计方法。
https://github.com/kgrzybek/modular-monolith-with-ddd