从壹开始微服务 [ DDD ] 之七 ║项目第一次实现 & CQRS初探

时间:2023-03-08 17:47:21
从壹开始微服务 [ DDD ] 之七 ║项目第一次实现 & CQRS初探

前言

哈喽大家周五好,我们又见面了,感谢大家在这个周五读我的文章,经过了三周的时间,当然每周两篇的速度的情况下,咱们简单说了下DDD领域驱动设计的第一部分,主要包括了,《项目入门DDD架构浅析》,《领域、子领域、限界上下文》,《DDD使用意义》,《实体与值对象》,《聚合与聚合根》这五部分内容,主要的是以解释为主,举例子Code为辅的形式,总体来说还是得到一些肯定的,也是我最大的动力了。

上边这五个知识点是DDD领域驱动设计的第一部分 —— D领域;

从今天开始,咱们就说说DDD的第二个D,就是领域服务+领域命令的CQRS,这些偏重动作的一部分;

最后就是第三部分,通过 领域事件、事件源与事件回溯,配合着权限管理,再统一说一下DDD,这一系列就是结束了。

其实通过我看到这里,我发现了,我们在设计DDD的时候,重要的是思路,重要的是在如何进行领域设计,而不是在框架和技术上面,有时候就算是三层也能配和着实现领域设计,之前有小伙伴说到我些的是OOP,嗯,希望等系列写完就可以稍微不一样一些吧。

今天我们的主要工作,就是把前几天在讲述概念的同时,对搭建的项目进行第一次的合围,能运行起来,当然这里还会涉及到之前我们第一个系列的知识,我们也进行复习下,比如:DI依赖注入、EFCore、Automapper数据传输对象,当然还有前几篇文章中的 实体和值对象的部分概念 , 如果您是第一次看我的文章,可能这些今天不会详细说明,可以去我的第一个系列开始学习,好啦,马上开始今天的讲解。

零、今天实现天青色的部分

从壹开始微服务 [ DDD ] 之七 ║项目第一次实现 & CQRS初探

一、项目运行、复习系列一相关知识

1、Automapper定义Config配置文件

1、我们在项目应用层Christ3D.Application 的 AutoMapper 文件夹下,新建AutoMapperConfig.cs 配置文件,

    /// <summary>
/// 静态全局 AutoMapper 配置文件
/// </summary>
public class AutoMapperConfig
{
public static MapperConfiguration RegisterMappings()
{
//创建AutoMapperConfiguration, 提供静态方法Configure,一次加载所有层中Profile定义
//MapperConfiguration实例可以静态存储在一个静态字段中,也可以存储在一个依赖注入容器中。 一旦创建,不能更改/修改。
return new MapperConfiguration(cfg =>
{
//这个是领域模型 -> 视图模型的映射,是 读命令
cfg.AddProfile(new DomainToViewModelMappingProfile());
//这里是视图模型 -> 领域模式的映射,是 写 命令
cfg.AddProfile(new ViewModelToDomainMappingProfile());
});
}
}

这里你可能会问了,咱们之前在 Blog.Core 前后端分离中,为什么没有配置这个Config文件,其实我实验了下,不用配置文件我们也可以达到映射的目的,只不过,我们平时映射文件Profile 比较少,项目启动的时候,每次都会调取下这个配置文件,你可以实验下,如果几十个表,上百个数据库表,启动会比较慢,可以使用创建AutoMapperConfiguration, 提供静态方法Configure,一次加载所有层中Profile定义,大概就是这个意思,这里我先存个疑,有不同意见的欢迎来说我,哈哈欢迎批评。

2、上边代码中  DomainToViewModelMappingProfile 咱们很熟悉,就是平时用到的,但是下边的那个是什么呢,那个就是我们 视图模型 -> 领域模式 的时候的映射,写法和反着的是一样的,你一定会说,那为啥不直接这么写呢,

从壹开始微服务 [ DDD ] 之七 ║项目第一次实现 & CQRS初探

你的想法很棒!这种平时也是可以的,只不过在DDD领域驱动设计中,这个是是视图模型转领域模型,那一定是对领域模型就行命令操作,没错,就是在领域命令中,会用到这里,所以两者不能直接写在一起,这个以后马上会在下几篇文章中说到。

从壹开始微服务 [ DDD ] 之七 ║项目第一次实现 & CQRS初探

3、将 AutoMapper 服务在 Startup 启动

在 Christ3D.UI.Web 项目下,新建 Extensions 扩展文件夹,以后我们的扩展启动服务都写在这里。

新建 AutoMapperSetup.cs

    /// <summary>
/// AutoMapper 的启动服务
/// </summary>
public static class AutoMapperSetup
{
public static void AddAutoMapperSetup(this IServiceCollection services)
{
if (services == null) throw new ArgumentNullException(nameof(services));
//添加服务
services.AddAutoMapper();
//启动配置
AutoMapperConfig.RegisterMappings();
}
}

2、依赖注入 DI

之前我们在上个系列中,是用的Aufac 将整个层注入,今天咱们换个方法,其实之前也有小伙伴提到了,微软自带的 依赖注入方法就可以。

因为这一块属于我们开发的基础,而且也与数据有关,所以我们就新建一个 IoC 层,来进行统一注入

1、新建 Christ3D.Infra.IoC 层,添加统一注入类 NativeInjectorBootStrapper.cs

更新:已经把该注入文件统一放到了web层:

     public static void RegisterServices(IServiceCollection services)
{ // 注入 Application 应用层
services.AddScoped<IStudentAppService, StudentAppService>(); // 注入 Infra - Data 基础设施数据层
services.AddScoped<IStudentRepository, StudentRepository>();
services.AddScoped<StudyContext>();//上下文 }

具体的使用方法和我们Autofac很类型,这里就不说了,相信大家已经很了解依赖注入了。

从壹开始微服务 [ DDD ] 之七 ║项目第一次实现 & CQRS初探

2、在ConfigureServices 中进行服务注入

 // .NET Core 原生依赖注入
// 单写一层用来添加依赖项,可以将IoC与展示层 Presentation 隔离
NativeInjectorBootStrapper.RegisterServices(services);

3、EFCore Code First

1、相信大家也都用过EF,这里的EFCore 也是一样的,如果我们想要使用 CodeFirst 功能的话,就可以直接对其进行配置,

    public class StudyContext : DbContext
{
public DbSet<Student> Students { get; set; } /// <summary>
/// 重写自定义Map配置
/// </summary>
/// <param name="modelBuilder"></param>
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
//对 StudentMap 进行配置
modelBuilder.ApplyConfiguration(new StudentMap()); base.OnModelCreating(modelBuilder);
} /// <summary>
/// 重写连接数据库
/// </summary>
/// <param name="optionsBuilder"></param>
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
// 从 appsetting.json 中获取配置信息
var config = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json")
.Build(); //定义要使用的数据库
//正确的是这样,直接连接字符串即可
//optionsBuilder.UseSqlServer(config.GetConnectionString("DefaultConnection"));
//我是读取的文件内容,为了数据安全
optionsBuilder.UseSqlServer(File.ReadAllText(config.GetConnectionString("DefaultConnection")));
}
}

2、然后我们就可以配置 StudentMap 了,针对不同的领域模型进行配置,但是这里有一个重要的知识点,请往下看:

    /// <summary>
/// 学生map类
/// </summary>
public class StudentMap : IEntityTypeConfiguration<Student>
{
/// <summary>
/// 实体属性配置
/// </summary>
/// <param name="builder"></param>
public void Configure(EntityTypeBuilder<Student> builder)
{
//实体属性Map
builder.Property(c => c.Id)
.HasColumnName("Id"); builder.Property(c => c.Name)
.HasColumnType("varchar(100)")
.HasMaxLength()
.IsRequired(); builder.Property(c => c.Email)
.HasColumnType("varchar(100)")
.HasMaxLength()
.IsRequired(); builder.Property(c => c.Phone)
.HasColumnType("varchar(100)")
.HasMaxLength()
.IsRequired(); //处理值对象配置,否则会被视为实体
builder.OwnsOne(p => p.Address); //可以对值对象进行数据库重命名,还有其他的一些操作,请参考官网
//builder.OwnsOne(
// o => o.Address,
// sa =>
// {
// sa.Property(p => p.County).HasColumnName("County");
// sa.Property(p => p.Province).HasColumnName("Province");
// sa.Property(p => p.City).HasColumnName("City");
// sa.Property(p => p.Street).HasColumnName("Street");
// }
//); //注意:这是EF版本的写法,Core中不能使用!!!
//builder.Property(c => c.Address.City)
// .HasColumnName("City")
// .HasMaxLength(20);
//builder.Property(c => c.Address.Street)
// .HasColumnName("Street")
// .HasMaxLength(20); //如果想忽略当前值对象,可直接 Ignore
//builder.Ignore(c => c.Address);
}
}

重要知识点:

我们以前用的时候,都是每一个实体对应一个数据库表,或者有一些关联,比如一对多的情况,就拿我们现在项目中使用到的来说,我们的 Student 实体中,有一个 Address 的值对象,值对象大家肯定都知道的,是没有状态,保证不变性的一个值,但是在EFCore 的Code First 中,系统会需要我们提供一个 Address 的主键,因为它会认为这是一个表结构,如果我们为 Address 添加主键,那就是定义成了实体,这个完全不是我们想要的,我们设计的原则是一切以领域设计为核心,不能为了数据库而修改模型。

如果把 Address 当一个实体,增加主键,就可以Code First通过,但是这个对我们来说是不行的,我们是从领域设计中考虑,需要把它作为值对象,是作为数据库字段,你也许会想着直接把 Address 拆开成多个字段放到 Student 实体类中作为属性,我感觉这样也是不好的,这样就达不到我们领域模型的作用了。

我通过收集资料,我发现可以用上边注释的方法,直接在 StudentMap 中配置,但是我失败了,一直报错

//builder.Property(c => c.Address.City)
// .HasColumnName("City")
// .HasMaxLength(20);

The property 'Student.Address' is of type 'Address' which is not supported by current database provider. Either change the property CLR type or ignore the property using the '[NotMapped]' attribute or by using 'EntityTypeBuilder.Ignore' in 'OnModelCreating'.

本来想放弃的时候,还是强大的博客园博文功能,让我找到一个大神,然后我参考官网,找到了这个方法。https://docs.microsoft.com/en-us/ef/core/modeling/owned-entities

builder.OwnsOne(p => p.Address);//记得在 Address 值对象上增加一个 [Owned] 特性。

3、Code First 到数据库

我们可以通过以下nuget 命令来控制,这里就不细说了,相信大家用的很多了

//1、初始化迁移记录 Init 自定义
Add-Migration Init //2、将当前 Init 的迁移记录更新到数据库
update-database Init

然后就可以看到我们的的数据库已经生成:

从壹开始微服务 [ DDD ] 之七 ║项目第一次实现 & CQRS初探

以后大家在迁移数据库的时候,可能会遇到问题,因为本项目有两个上下文,大家可以指定其中的操作

从壹开始微服务 [ DDD ] 之七 ║项目第一次实现 & CQRS初探

4、添加页面,运行

1、到这里我们就已经把整体调通了,然后新建 StudentController.cs ,添加 CURD 页面

 //还是构造函数注入
private readonly IStudentAppService _studentAppService; public StudentController(IStudentAppService studentAppService)
{
_studentAppService = studentAppService;
} // GET: Student
public ActionResult Index()
{
return View(_studentAppService.GetAll());
}

2、运行项目,就能看到结果

从壹开始微服务 [ DDD ] 之七 ║项目第一次实现 & CQRS初探

这个时候,我们已经通过了 DI 进行注入,然后通过Dtos 将我们的领域模型,转换成了视图模型,进行展示,也许这个时候你会发现,这个很正常呀,平时都是这么做的,也没有看到有什么高端的地方,聪明的你一定会想到更远的地方,这里我们是用领域模型 -> 视图模型的DTO,也就是我们平时说的查询模式,

那有查询,肯定有编辑模式,我们就会有 视图模型,传入,然后转换领域模型,中间当然还有校验等等(不是简单的视图模型的判空,还有其他的复杂校验,比如年龄,字符串),这个时候,如果我们直接用 视图模型 -> 领域模型的话,肯定会有污染,至少会把读和写混合在一起,

 public void Register(StudentViewModel StudentViewModel)
{
//这里引入领域设计中的写命令 还没有实现
//请注意这里如果是平时的写法,必须要引入Student领域模型,会造成污染 _StudentRepository.Add(_mapper.Map<Student>(StudentViewModel));
}

那该怎么办呢,这个时候CQRS 就登场了!请往下看。

二、CQRS 读写分离初探

从上边的问题中,我们发现,在DDD领域驱动设计中,我们是一起以领域模型为核心的,这个时候出现了几个概念:

1、DDD中四种模型

如果你是从我的系列的第一篇开始读,你应该已经对这两个模型很熟悉了,领域模型,视图模型,当然,还有咱们一直开发中使用到的数据模型,那第四个是什么呢?

  1. 数据模型:面向持久化,数据的载体。
  2. 领域模型:面向业务,行为的载体。
  3. 视图模型:面向UI(向外),数据的载体。
  4. 命令模型:面向UI(向内),数据的载体。

这个命令模型Command,就是解决了我们的 视图模型到领域模型中,出现污染的问题。其他 命令模型,就和我们的领域模型、视图模型是一样的,也是一个数据载体,这不过它可以配和着事件,进行复杂的操作控制,这个以后会慢慢说到。

如果你要问写到哪里,这里简单说一下,具体的搭建下次会说到,就是在我们的 应用层 AutoMapper 文件夹下,我们的 ViewModelToDomainMappingProfile.cs

 public class ViewModelToDomainMappingProfile : Profile
{
public ViewModelToDomainMappingProfile()
{
//这里以后会写领域命令,所以不能和DomainToViewModelMappingProfile写在一起。
//学生视图模型 -> 添加新学生命令模型
CreateMap<StudentViewModel, RegisterNewStudentCommand>()
.ConstructUsing(c => new RegisterNewStudentCommand(c.Name, c.Email, c.BirthDate));
//学生视图模型 -> 更新学生信息命令模型
CreateMap<StudentViewModel, UpdateStudentCommand>()
.ConstructUsing(c => new UpdateStudentCommand(c.Id, c.Name, c.Email, c.BirthDate));
}

2、传统 CURD 命令有哪些问题

1、使用同一个对象实体来进行数据库读写可能会太粗糙,大多数情况下,比如编辑的时候可能只需要更新个别字段,但是却需要将整个对象都穿进去,有些字段其实是不需要更新的。在查询的时候在表现层可能只需要个别字段,但是需要查询和返回整个实体对象。

2、使用同一实体对象对同一数据进行读写操作的时候,可能会遇到资源竞争的情况,经常要处理的锁的问题,在写入数据的时候,需要加锁。读取数据的时候需要判断是否允许脏读。这样使得系统的逻辑性和复杂性增加,并且会对系统吞吐量的增长会产生影响。

3、同步的,直接与数据库进行交互在大数据量同时访问的情况下可能会影响性能和响应性,并且可能会产生性能瓶颈。

4、由于同一实体对象都会在读写操作中用到,所以对于安全和权限的管理会变得比较复杂。

这里面很重要的一个问题是,系统中的读写频率比,是偏向读,还是偏向写,就如同一般的数据结构在查找和修改上时间复杂度不一样,在设计系统的结构时也需要考虑这样的问题。解决方法就是我们经常用到的对数据库进行读写分离。 让主数据库处理事务性的增,删,改操作(Insert,Update,Delete)操作,让从数据库处理查询操作(Select操作),数据库复制被用来将事务性操作导致的变更同步到集群中的从数据库。这只是从DB角度处理了读写分离,但是从业务或者系统上面读和写仍然是存放在一起的。他们都是用的同一个实体对象。

要从业务上将读和写分离,就是接下来要介绍的命令查询职责分离模式。

3、什么是 CQRS 读写分离

以下信息来自@寒江独钓的博文,我看着写的很好:

CQRS最早来自于Betrand Meyer(Eiffel语言之父,开-闭原则OCP提出者)提到的一种 命令查询分离 (Command Query Separation,CQS) 的概念。其基本思想在于,任何一个对象的方法可以分为两大类:

  • 命令(Command):不返回任何结果(void),但会改变对象的状态。
  • 查询(Query):返回结果,但是不会改变对象的状态,对系统没有副作用。

根据CQS的思想,任何一个方法都可以拆分为命令和查询两部分,比如:

  public StudentViewModel Update(StudentViewModel StudentViewModel)
{
//更新操作
_StudentRepository.Update(_mapper.Map<Student>(StudentViewModel)); //查询操作
return _mapper.Map<StudentViewModel>(_StudentRepository.GetById(StudentViewModel.Id));
}

这个方法,我们执行了一个命令即对更新Student,同时又执行了一个Query,即查询返回了Student的值,如果按照CQS的思想,该方法可以拆成Command和Query两个方法,如下:

 public StudentViewModel GetById(Guid id)
{
return _mapper.Map<StudentViewModel>(_StudentRepository.GetById(id));
} public void Update(StudentViewModel StudentViewModel)
{
_StudentRepository.Update(_mapper.Map<Student>(StudentViewModel));
}

操作和查询分离使得我们能够更好的把握对象的细节,能够更好的理解哪些操作会改变系统的状态。当然CQS也有一些缺点,比如代码需要处理多线程的情况。

CQRS是对CQS模式的进一步改进成的一种简单模式。 它由Greg Young在CQRS, Task Based UIs, Event Sourcing agh! 这篇文章中提出。“CQRS只是简单的将之前只需要创建一个对象拆分成了两个对象,这种分离是基于方法是执行命令还是执行查询这一原则来定的(这个和CQS的定义一致)”。

CQRS使用分离的接口将数据查询操作(Queries)和数据修改操作(Commands)分离开来,这也意味着在查询和更新过程中使用的数据模型也是不一样的。这样读和写逻辑就隔离开来了。

使用CQRS分离了读写职责之后,可以对数据进行读写分离操作来改进性能,可扩展性和安全。如下图:

从壹开始微服务 [ DDD ] 之七 ║项目第一次实现 & CQRS初探

4、CQRS 的应用场景

在下场景中,可以考虑使用CQRS模式:

  1. 当在业务逻辑层有很多操作需要相同的实体或者对象进行操作的时候。CQRS使得我们可以对读和写定义不同的实体和方法,从而可以减少或者避免对某一方面的更改造成冲突;
  2. 对于一些基于任务的用户交互系统,通常这类系统会引导用户通过一系列复杂的步骤和操作,通常会需要一些复杂的领域模型,并且整个团队已经熟悉领域驱动设计技术。写模型有很多和业务逻辑相关的命令操作的堆,输入验证,业务逻辑验证来保证数据的一致性。读模型没有业务逻辑以及验证堆,仅仅是返回DTO对象为视图模型提供数据。读模型最终和写模型相一致。
  3. 适用于一些需要对查询性能和写入性能分开进行优化的系统,尤其是读/写比非常高的系统,横向扩展是必须的。比如,在很多系统中读操作的请求时远大于写操作。为适应这种场景,可以考虑将写模型抽离出来单独扩展,而将写模型运行在一个或者少数几个实例上。少量的写模型实例能够减少合并冲突发生的情况
  4. 适用于一些团队中,一些有经验的开发者可以关注复杂的领域模型,这些用到写操作,而另一些经验较少的开发者可以关注用户界面上的读模型。
  5. 对于系统在将来会随着时间不段演化,有可能会包含不同版本的模型,或者业务规则经常变化的系统
  6. 需要和其他系统整合,特别是需要和事件溯源Event Sourcing进行整合的系统,这样子系统的临时异常不会影响整个系统的其他部分。

这里我只是把CQRS的初衷简单说了一下,下一节我们会重点来讲解 读写分离 的过程,以及命令是怎么配合着 Validations 进行验证的。

三、结语

今天暂时就写到这里吧,通过今天的学习,我们复习了第一系列中的依赖注入DI、DTO数据传输对象以及EFCore 的相关操作,重点说明了下,我们在DDD领域驱动设计中,如何在领域实体和值对象中,通过Code First生成数据库,并且强调了在领域设计中,一切要以领域模型为核心。最后简单引入了 CQRS 读写分离模式的简单概念,我会在下一节继续深入对其进行研究。

四、GitHub & Gitee

https://github.com/anjoy8/ChristDDD

https://gitee.com/laozhangIsPhi/ChristDDD