DDD领域驱动设计 (C# 整理自“老张的哲学”)

2023-10-21 17:36:10 浏览数 (1)

大话DDD领域驱动设计

概念

Domain Driven Design 领域驱动设计

第一个D(Domain):

领域:指围绕业务为核心而划分的实体模块。

第二个D(Driven):

驱动:这里的驱动包含了 领域命令模型验证、领域事件处理、领域事件通知、事件溯源。

第三个D(Design):

设计:这里指架构分层,即应该如何分层?领域逻辑写在哪?与持久化如何交互?如何协调多对象领域逻辑?如何实现逻辑与数据存储解耦等

上面的每一块的内容都很多,不是几句话就能说清楚的。下面我就挑一些重要的来说

Domin领域

领域模型:是对ORM中的M做了进一步的处理,即按照业务范围划分成多个聚合根(多个实体的集合体)。

而在这个聚合根里按照业务数据的特有特性而又拉出了一些概念:实体、值对象

实体特性

1、有唯一的标识,不受状态属性的影响。——可以理解数据表中的主键 2、可变性特征,状态信息一直可以变化。——可以理解成数据表中除主键以外其他信息

值对象特性:

1、它描述了领域中的一个东西 2、可以作为一个不变量。 3、当它被改变时,可以用另一个值对象替换。 4、可以和别的值对象进行相等性比较。

示例:

实体Student(学生):

代码语言:javascript复制
   public class Student : Entity
    {
        protected Student() {
        }
        public Student(Guid id, string name, string email, string phone, DateTime birthDate, Address address)
        {
            Id = id;
            Name = name;
            Email = email;
            Phone = phone;
            BirthDate = birthDate;
            Address = address;
        }
        /// <summary>
        /// 姓名
        /// </summary>
        public string Name { get; private set; }
        /// <summary>
        /// 邮箱
        /// </summary>
        public string Email { get; private set; }
        /// <summary>
        /// 手机
        /// </summary>
        public string Phone { get; private set; }
        /// <summary>
        /// 生日
        /// </summary>
        public DateTime BirthDate { get; private set; }
​
        /// <summary>
        /// 户籍
        /// </summary>
        public Address Address { get; private set; }
​
​
    }

Entity

代码语言:javascript复制
  /// <summary>
    /// 定义领域实体基类
    /// </summary>
    public abstract class Entity
    {
        /// <summary>
        /// 唯一标识
        /// </summary>
        public Guid Id { get; protected set; }
​
        /// <summary>
        /// 重写方法 相等运算
        /// </summary>
        /// <param name="obj"></param>
        /// <returns></returns>
        public override bool Equals(object obj)
        {
            var compareTo = obj as Entity;
​
            if (ReferenceEquals(this, compareTo)) return true;
            if (ReferenceEquals(null, compareTo)) return false;
​
            return Id.Equals(compareTo.Id);
        }
        /// <summary>
        /// 重写方法 实体比较 ==
        /// </summary>
        /// <param name="a">领域实体a</param>
        /// <param name="b">领域实体b</param>
        /// <returns></returns>
        public static bool operator ==(Entity a, Entity b)
        {
            if (ReferenceEquals(a, null) && ReferenceEquals(b, null))
                return true;
​
            if (ReferenceEquals(a, null) || ReferenceEquals(b, null))
                return false;
​
            return a.Equals(b);
        }
        /// <summary>
        /// 重写方法 实体比较 !=
        /// </summary>
        /// <param name="a"></param>
        /// <param name="b"></param>
        /// <returns></returns>
        public static bool operator !=(Entity a, Entity b)
        {
            return !(a == b);
        }
        /// <summary>
        /// 获取哈希
        /// </summary>
        /// <returns></returns>
        public override int GetHashCode()
        {
            return (GetType().GetHashCode() * 907)   Id.GetHashCode();
        }
        /// <summary>
        /// 输出领域对象的状态
        /// </summary>
        /// <returns></returns>
        public override string ToString()
        {
            return GetType().Name   " [Id="   Id   "]";
        }
​
       
    }

值对象Address(家庭住址)

代码语言:javascript复制
    /// <summary>
    /// 地址
    /// </summary>
    [Owned]
    public class Address : ValueObject<Address>
    {
        /// <summary>
        /// 省份
        /// </summary>
        public string Province { get; private set; }
​
        /// <summary>
        /// 城市
        /// </summary>
        public string City { get; private set; }
​
        /// <summary>
        /// 区县
        /// </summary>
        public string County { get; private set; }
​
        /// <summary>
        /// 街道
        /// </summary>
        public string Street { get; private set; }
​
​
        public Address() { }
        public Address(string province, string city,
            string county, string street)
        {
            this.Province = province;
            this.City = city;
            this.County = county;
            this.Street = street;
        }
​
      
​
        protected override bool EqualsCore(Address other)
        {
            throw new NotImplementedException();
        }
​
        protected override int GetHashCodeCore()
        {
            throw new NotImplementedException();
        }
    }

如何划分聚合

1、哪些实体或值对象在一起才能够有效的表达一个领域概念。

比如:订单模型中,必须有订单详情,物流信息等实体或者值对象,这样才能完整的表达一个订单的领域概念,就比如文章开头中提到的那个Code栗子中,OrderItem、Goods、Address等

2、确定好聚合以后,要确定聚合根

  比如:订单模型中,订单表就是整个聚合的聚合根。

代码语言:javascript复制
    public class Order : Entity
    {
        protected Order()
        {
        }
        public Order(Guid id, string name, List<OrderItem> orderItem)
        {
            Id = id;
            Name = name;
            OrderItem = orderItem;
        }
        /// <summary>
        /// 订单名
        /// </summary>
        public string Name { get; private set; }
​
        /// <summary>
        /// 订单详情
        /// </summary>
        public virtual ICollection<OrderItem> OrderItem { get; private set; }
​
    }

3、对象之间是否必须保持一些固定的规则。

比如:Order(一 个订单)必须有对应的客户邮寄信息,否则就不能称为一个有效的Order;同理,Order对OrderLineItem有不变性约束,Order也必须至少有一个OrderLineItem(一条订单明细),否则就不能称为一个有效的Order;

另外,Order中的任何OrderLineItem的数量都不能为0,否则认为该OrderLineItem是无效 的,同时可以推理出Order也可能是无效的。因为如果允许一个OrderLineItem的数量为0的话,就意味着可能会出现所有 OrderLineItem的数量都为0,这就导致整个Order的总价为0,这是没有任何意义的,是不允许的,从而导致Order无效;所以,必须要求 Order中所有的OrderLineItem的数量都不能为0;那么现在可以确定的是Order必须包含一些OrderLineItem,那么应该是通 过引用的方式还是ID关联的方式来表达这种包含关系呢?这就需要引出另外一个问题,那就是先要分析出是OrderLineItem是否是一个独立的聚合 根。回答了这个问题,那么根据上面的规则就知道应该用对象引用还是用ID关联了。

那么OrderLineItem是否是一个独立的聚合根呢?因为聚合根意 味着是某个聚合的根,而聚合有代表着某个上下文边界,而一个上下文边界又代表着某个独立的业务场景,这个业务场景操作的唯一对象总是该上下文边界内的聚合 根。想到这里,我们就可以想想,有没有什么场景是会绕开订单直接对某个订单明细进行操作的。也就是在这种情况下,我们 是以OrderLineItem为主体,完全是在面向OrderLineItem在做业务操作。有这种业务场景吗?没有,我们对 OrderLineItem的所有的操作都是以Order为出发点,我们总是会面向整个Order在做业务操作,比如向Order中增加明细,修改 Order的某个明细对应的商品的购买数量,从Order中移除某个明细,等等类似操作,我们从来不会从OrderlineItem为出发点去执行一些业 务操作;另外,从生命周期的角度去理解,那么OrderLineItem离开Order没有任何存在的意义,也就是说OrderLineItem的生命周 期是从属于Order的。所以,我们可以很确信的回答,OrderLineItem是一个实体。

4、聚合不要设计太大,否则会有性能问题以及业务规则一致性的问题。

对于大聚合,即便可以成功地保持事务一致性,但它可能限制了系统性能和可伸缩性。 系统可能随著时间可能会有越来越多的需求与用户,开发与维护的成本我们不应该忽视。

怎样的聚合才算是"小"聚合呢??

好的做法是使用根实体(Root Entity)来表示聚合,其中只包含最小数量的属性或值类型属性。哪些属性是所需的呢??简单的答案是:那些必须与其他属性保持一致的属性。

比如,Product聚合内的name与description属性,是需要保持一致的,把它们放在两个不同的聚合显然是不恰当的。

5、聚合中的实体和值对象应该具有相同的生命周期,并应该属于一个业务场景。

比如一个最常见的问题:论坛发帖和回复如何将里聚合模型,大家想到这里,联想到上边的订单和订单详情,肯定会peng peng的这样定义;

代码语言:javascript复制
   /// <summary>
    /// 聚合根 发帖
    /// </summary>
    public class Post : AggregateRoot
    {
        public string PostTitle;
        public List<Reply> Reply;//回复
        //...
    }
    /// <summary>
    /// 实体 回复
    /// </summary>
    public class Reply : Entity
    {
        public string Content;
        //...
    }

这样初看是没有什么问题,很正常呀,发帖子是发回复的聚合根,回复必须有一个帖子,不然无效,看似合理的地方却有不合理。

比如,当我要对一个帖子发表回复时,我取出当前帖子信息,嗯,这个很对,但是,如果我对回复进行回复的时候,那就不好了,我每次还是都要取出整个带有很多回复的帖子,然后往里面增加回复,然后保存整个帖子,因为聚合的一致性要求我们必须这么做。无论是在场景还是在并发的情况下这是不行的。

如果帖子和回复在一个聚合内,聚合意味着“修改数据的一个最小单元”,聚合内的所有对象要看成是一个整体最小单元进行保存。这么要求是因为聚合的意义是维护聚合内的不变性,数据一致性; 仔细分析我们会发现帖子和回复之间没有数据一致性要求。所以不需要设计在同一个聚合内。

从场景的角度,我们有发表帖子,发表回复,这两个不同的场景,发表帖子创建的是帖子,而发表回复创建的是回复。但是订单就不一样,我们有创建订单,修改订单这两个场景。这两个场景都是围绕这订单这个聚合展开的。

所以我们应该把回复实体也单独作为一个聚合根来处理:

代码语言:javascript复制
 /// <summary>
    /// 内容
    /// </summary>
    public class Content
    {
        public string Id;
        public DateTime DatePost;
        public string Contents;
        public string Title;
        //...
    }
    /// <summary>
    /// 聚合根 发帖
    /// </summary>
    public class Post : AggregateRoot,ContentBase
    {
        public string Title;
        //...
    }
    /// <summary>
    /// 聚合根 回复
    /// </summary>
    public class Reply : AggregateRoot,ContentBase
    {
        public Content Content;
        public Post Post;//帖子实体聚合根
        //...
    }

聚合是如何联系的

如何联系,在上文的代码中以及由体现了,这里用文字来说明下,具体的可以参考文中的代码

1、聚合根、实体、值对象的区别? 从标识的角度:

聚合根具有全局的唯一标识,而实体只有在聚合内部有唯一的本地标识,值对象没有唯一标识,不存在这个值对象或那个值对象的说法;

从是否只读的角度

聚合根除了唯一标识外,其他所有状态信息都理论上可变;实体是可变的;值对象是只读的;

从生命周期的角度

聚合根有独立的生命周期,实体的生命周期从属于其所属的聚合,实体完全由其所属的聚合根负责管理维护;值对象无生命周期可言,因为只是一个值;

2、聚合根、实体、值对象对象之间如何建立关联? 聚合根到聚合根:通过ID关联;

聚合根到其内部的实体,直接对象引用;

聚合根到值对象,直接对象引用;

实体对其他对象的引用规则:1)能引用其所属聚合内的聚合根、实体、值对象;2)能引用外部聚合根,但推荐以ID的方式关联,另外也可以关联某个外部聚合内的实体,但必须是ID关联,否则就出现同一个实体的引用被两个聚合根持有,这是不允许的,一个实体的引用只能被其所属的聚合根持有;

值对象对其他对象的引用规则:只需确保值对象是只读的即可,推荐值对象的所有属性都尽量是值对象;

3、如何识别聚合与聚合根? 明确含义:一个Bounded Context(界定的上下文)可能包含多个聚合,每个聚合都有一个根实体,叫做聚合根;

识别顺序:先找出哪些实体可能是聚合根,再逐个分析每个聚合根的边界,即该聚合根应该聚合哪些实体或值对象;最后再划分Bounded Context;

聚合边界确定法则:根据不变性约束规则(Invariant)。不变性规则有两类:1)聚合边界内必须具有哪些信息,如果没有这些信息就不能称为一个有效的聚合;2)聚合内的某些对象的状态必须满足某个业务规则;

1.一个聚合只有一个聚合根,聚合根是可以独立存在的,聚合中其他实体或值对象依赖与聚合根。

2.只有聚合根才能被外部访问到,聚合根维护聚合的内部一致性。

聚合优缺点

1、优点 其实整篇文章都是在说的聚合的优点,这里简单再概况下:

聚合的出现,很大程度上,帮助了DDD领域驱动设计的全部普及,试想一下,如果没有聚合和聚合根的思维,单单来说DDD,总感觉不是很舒服,而且领域驱动设计所分的子领域和限界上下文都是从更高的一个层面上来区分的,有的项目甚至只有一个限界上下文,那么,聚合的思考和使用,就特别的高效,且有必要。

聚合设计的原则应该是聚合内各个有相互关联的对象之间要保持 不变性!我们平时设计聚合时,一般只考虑到了对象之间的关系,比如看其是否能独立存在,是否必须依赖与某个其他对象而存在。

2、担忧 我接触的DDD中的聚合根的分析设计思路大致是这样:1、业务本质逻辑分析;2、确认聚合对象间的组成关系;3、所有的读写必须沿着这些固有的路径进行。 这是一种静态聚合的设计思路。理论上讲,似乎没有什么问题。但实际上,因为每一个人的思路以及学习能力,甚至是专业领域知识的不同,会导致设计的不合理,特别是按照这个正确的路线设计,如果有偏差,就会达到不同的效果,有时候会事倍功半,反而把罪过强加到DDD领域驱动上,或者增加到聚合上,这也就是大家一直不想去更深层去研究实践这种思想的原因。

DDD本来就是处理复杂业务逻辑设计问题。我看到大家用DDD去分析一些小项目的时候,往往为谁是聚合根而无法达成共识。这说明每个人对业务认识的角度、深度和广度都不同,自然得出的聚合根也不同。试想,这样的情况下,领域模型怎么保持稳定。

不过这也许不是一个大问题,只要我们用心去经营,去学习,去沟通,一切都不是问题!

Design设计

分层

应用层:除了Service和IService、DTO、还有使用 CQRS 方法的查询、接受的命令,事件驱动的通信(集成事件),但是没有业务规则;

领域(模型)层:这里主要放的是领域实体、值对象、聚合和事件模型、Bus等主要都是模型,非贫血;

基础层:就是ORM的持久化相关;

U I 层:显示页面;

领域层(Domain层)

在解决方案中,新建 .net core 类库 Christ3D.Domain ,作为我们的领域层(这是一个臃肿的领域层,以后我们会把领域核心给抽象出来,现在简化是为了说明),然后在该层下,新建 Models 文件夹,存放我们以后的全部领域对象,我们的专业领域设计,都是基于领域对象为基础。

定义领域对象 Customer.cs(值对象/聚合/根)
代码语言:javascript复制
/// <summary>
    /// 定义领域对象 Customer
    /// </summary>
    public class Customer
    {
        protected Customer() { }
        public Customer(Guid id, string name, string email, DateTime birthDate)
        {
            Id = id;
            Name = name;
            Email = email;
            BirthDate = birthDate;
        }
​
        public Guid Id { get; private set; }
        public string Name { get; private set; }
        public string Email { get; private set; }
        public DateTime BirthDate { get; private set; }
    }
定义泛型接口 IRepository.cs 及 Customer 领域接口 ICustomerRepository.cs**

这里说下为什么开发中都需要接口层: 在层级结构中,上层模块调用下层模块提供的服务,这里就会存在一种依赖关系,Rebort C. Martin提出了依赖倒置原则大致是如下: 上层模块不应该依赖于下层模块,两者都应该依赖于抽象; 抽象不应该依赖于实现,实现应该依赖于抽象; 这是一个面向接口编程的思想。

在我们的领域层下,新建 Interfaces 文件夹,然后添加泛型接口

在我们专注的领域业务中,我们只需要定义该领域Customer 的相关用例即可(就比如如何CURD,如何发邮件等等,这些都是用户角色Customer的用例流),而不用去关心到底是如何通过哪种技术来实现的,那种ORM去持久化的,这就是领域设计的核心,当然现在有很多小伙伴还是喜欢直接把接口和实现放在一起,也无可厚非,但是不符合DDD领域设计的思想。

可能这个时候你会说,领域层,定义接口和实现方法放在一起也可以嘛,现在我们是看不出来效果的,以后我们会在这里说到领域驱动,领域通知,事件驱动等等知识点的时候,你就会发现,在Domain层来对接口进行实现是那么格格不入,没关系慢慢来~~~

代码语言:javascript复制
/// <summary>
    /// 定义泛型仓储接口,并继承IDisposable,显式释放资源
    /// </summary>
    /// <typeparam name="TEntity"></typeparam>
    public interface IRepository<TEntity> : IDisposable where TEntity : class
    {
        /// <summary>
        /// 添加
        /// </summary>
        /// <param name="obj"></param>
        void Add(TEntity obj);
        /// <summary>
        /// 根据id获取对象
        /// </summary>
        /// <param name="id"></param>
        /// <returns></returns>
        TEntity GetById(Guid id);
        /// <summary>
        /// 获取列表
        /// </summary>
        /// <returns></returns>
        IQueryable<TEntity> GetAll();
        /// <summary>
        /// 根据对象进行更新
        /// </summary>
        /// <param name="obj"></param>
        void Update(TEntity obj);
        /// <summary>
        /// 根据id删除
        /// </summary>
        /// <param name="id"></param>
        void Remove(Guid id);
        /// <summary>
        /// 保存
        /// </summary>
        /// <returns></returns>
        int SaveChanges();
    }
    /// <summary>
    /// ICustomerRepository 接口
    /// 注意,这里我们用到的业务对象,是领域对象
    /// </summary>
    public interface ICustomerRepository : IRepository<Customer>
    {
        //一些Customer独有的接口        Customer GetByEmail(string email);
    }

应用层(Application层)——定义系统的业务功能

如果Repository 应用在应用层,会出现什么情况:这样就致使应用层和基础层(我把数据持久化放在基础层了)通信,忽略了最重要的领域层,领域层在其中起到的作用最多也就是传递一个非常贫血的领域模型,然后通过 Repository 进行“CRUD”,这样的结果是,应用层不变成所谓的 BLL(常说的业务逻辑层)才怪,另外,因为业务逻辑都放在应用层了,领域模型也变得更加贫血。

Application为应用层(也就是我们常说的 Service 层),定义软件要完成的任务,并且指挥表达领域概念的对象来解决问题。这一层所负责的工作对业务来说意义重大,也是与其它系统的应用层进行交互的必要渠道。应用层要尽量简单,不包含业务规则或者知识,而只为下一层中的领域对象协调任务,分配工作,使它们互相协作。它没有反映业务情况的状态,但是却可以具有另外一种状态,为用户或程序显示某个任务的进度。

视图模型——Rich 领域模型(DTO以后说到)

在文章的最后,咱们再回顾下文章开头说的贫血对象模型,相信你应该还有印象,这个就是对刚刚上边这个问题最好的回答,如果我们直接把展示层对接到了基层设施层,那我们势必需要用到领域模型来操作,甚至是对接到视图里,不仅如此,我们还需要验证操作,传值操作等等,那我们又把领域对象模型过多的写到了业务逻辑里去,嗯,这个就不是DDD领域驱动设计了,所以我们需要一个应用层,对外进行数据接口的提供,这里要强调一点,千万不要把应用层最后写满了业务逻辑,业务应该在领域层!!!

在项目根路径下,新建 Christ3D.Application 类库,作为我们的应用层,然后新建 ViewModels 文件夹,用来存放我们的基于UI 的视图模型,它是如何来的,这个下边说到。

代码语言:javascript复制
/// <summary>
    /// 子领域Customer的视图模型
    /// </summary>
    public class CustomerViewModel
    {
        [Key]
        public Guid Id { get; set; }
​
        [Required(ErrorMessage = "The Name is Required")]
        [MinLength(2)]
        [MaxLength(100)]
        [DisplayName("Name")]
        public string Name { get; set; }
​
        [Required(ErrorMessage = "The E-mail is Required")]
        [EmailAddress]
        [DisplayName("E-mail")]
        public string Email { get; set; }
​
        [Required(ErrorMessage = "The BirthDate is Required")]
        [DisplayFormat(ApplyFormatInEditMode = true, DataFormatString = "{0:yyyy-MM-dd}")]
        [DataType(DataType.Date, ErrorMessage = "Data em formato inválido")]
        [DisplayName("Birth Date")]
        public DateTime BirthDate { get; set; }
    }
定义应用服务接口 ICustomerAppService,依赖抽象思想

在我们的应用层下,新建 Interfaces 文件夹,用来存放我们的对外服务接口,然后添加 Customer服务接口类,这里要说明下,在应用层对外接口中,我们就不需要定义泛型基类了,因为已经没有必要,甚至是无法抽象的,

代码语言:javascript复制
/// <summary>
    /// 定义 ICustomerAppService 服务接口
    /// 并继承IDisposable,显式释放资源
    /// 注意这里我们使用的对象,是视图对象模型
    /// </summary>
    public interface ICustomerAppService : IDisposable
    {
        void Register(CustomerViewModel customerViewModel);
        IEnumerable<CustomerViewModel> GetAll();
        CustomerViewModel GetById(Guid id);
        void Update(CustomerViewModel customerViewModel);
        void Remove(Guid id);
    }
实现应用服务接口 CustomerAppService.cs ,对接基层设施层

在我们的应用层下,新建 Services 文件夹,用来存放我们对服务接口的实现类

代码语言:javascript复制
/// <summary>
    /// CustomerAppService 服务接口实现类,继承 服务接口
    /// 通过 DTO 实现视图模型和领域模型的关系处理
    /// 作为调度者,协调领域层和基础层,
    /// 这里只是做一个面向用户用例的服务接口,不包含业务规则或者知识
    /// </summary>
    public class CustomerAppService : ICustomerAppService
    {
        private readonly ICustomerRepository _customerRepository;
​
        public CustomerAppService(ICustomerRepository customerRepository)
        {
            _customerRepository = customerRepository;
        }
​
        public IEnumerable<CustomerViewModel> GetAll()
        {
            return null;
            //return _customerRepository.GetAll().ProjectTo<CustomerViewModel>();
        }
​
        public CustomerViewModel GetById(Guid id)
        {
            return null;
            //return _mapper.Map<CustomerViewModel>(_customerRepository.GetById(id));
        }
​
        public void Register(CustomerViewModel customerViewModel)
        {
            //var registerCommand = _mapper.Map<RegisterNewCustomerCommand>(customerViewModel);
        }
​
        public void Update(CustomerViewModel customerViewModel)
        {
            //var updateCommand = _mapper.Map<UpdateCustomerCommand>(customerViewModel);
        }
​
        public void Remove(Guid id)
        {
            //var removeCommand = new RemoveCustomerCommand(id);
        }
​
        public void Dispose()
        {
            GC.SuppressFinalize(this);
        }
    }

目前这里还没有具体使用基础层的仓储,为什么呢,因为应用层是面向视图对象模型,不涉及到业务,而基础设施层和领域层是基于 领域对象模型,面向业务的,所以我们需要用到 DTO ,这一块以后我们会说到。

基础设施层(Infrastruct层)

一个安静的数据管理员 —— 仓储

这里就简单的说两句为什么一直要使用仓储,而不直接接通到 EFCore 上:

1、我们驱动设计的核心是什么,就是最大化的解决项目中出现的痛点,上边的小故事就是一个栗子,随着技术的更新,面向接口开发同时也变的特别重要,无论是方便重构,还是方便IoC,依赖注入等等,都需要一个仓储接口来实现这个目的。

2、仓储还有一个重要的特征就是分为仓储定义部分和仓储实现部分,在领域模型中我们定义仓储的接口,而在基础设施层实现具体的仓储。

这样做的原因是:由于仓储背后的实现都是在和数据库打交道,但是我们又不希望客户(如应用层)把重点放在如何从数据库获取数据的问题上,因为这样做会导致客户(应用层)代码很混乱,很可能会因此而忽略了领域模型的存在。所以我们需要提供一个简单明了的接口,供客户使用,确保客户能以最简单的方式获取领域对象,从而可以让它专心的不会被什么数据访问代码打扰的情况下协调领域对象完成业务逻辑。这种通过接口来隔离封装变化的做法其实很常见,我们需要什么数据直接拿就行了,而不去管具体的操作逻辑。

3、由于客户面对的是抽象的接口并不是具体的实现,所以我们可以随时替换仓储的真实实现,这很有助于我们做单元测试。

总结:现在随着开发,越来越发现接口的好处,不仅仅是一个持久化层需要一层接口,小到一个缓存类,或者日志类,我们都需要一个接口的实现,就比如现在我就很喜欢用依赖注入的方式来开发,这样可以极大的减少依赖,还有增大代码的可读性。

建立我们第一个限界上下文

限界上下文已经说的很明白了,是从战术技术上来解释说明战略中的领域概念,你想一下,我们如何在代码中直接体现领域的概念?当然没办法,领域是一个通过语言,领域专家和技术人员都能看懂的一套逻辑,而代码中的上下文才是实实在在的通过技术来实现。

大家可以在回头看看上边的那个故事栗子,下边都一个“请注意”三个字,里边就是我们上下文中所包含的部分内容,其实限界上下文并没有想象中的那么复杂,我们只需要理解成是一个虚拟的边界,把不属于这个子领域的内容踢出去,对外解耦,但是内部通过聚合的。

a、在基础设施层下新建一个 appsetting.json 配置文件

用于我们的特定的数据库连接,当然我们可以公用 api 层的配置文件,这里单独拿出来,用于配合着下边的EFCore,进行注册。

用于我们的特定的数据库连接,当然我们可以公用 api 层的配置文件,这里单独拿出来,用于配合着下边的EFCore,进行注册。

代码语言:javascript复制
{
  "ConnectionStrings": {
    "DefaultConnection": "server=.;uid=sa;pwd=123;database=EDU"
  },
  "Logging": {
    "IncludeScopes": false,
    "LogLevel": {
      "Default": "Debug",
      "System": "Information",
      "Microsoft": "Information"
    }
  }
}

b、新建系统核心上下文

在Christ3D.Infrastruct.Data 基础设施数据层新建 Context 文件夹,以后在基础设施层的上下文都在这里新建,比如事件存储上下文(上文中存储事件痕迹的子领域),

然后新建教务领域中的核心子领域——学习领域上下文,StudyContext.cs,这个时候你就不用问我,为啥在教务系统领域中,学习领域是核心子领域了吧。

代码语言:javascript复制
 /// <summary>
    /// 定义核心子领域——学习上下文
    /// </summary>
    public class StudyContext : DbContext
    {
        public DbSet<Student> Students { get; set; }
​
        /// <summary>
        /// 重写自定义Map配置
        /// </summary>
        /// <param name="modelBuilder"></param>
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            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"));
        }
    }

在这个上下文中,有领域模型 Student ,还有以后说到的聚合,领域事件(上文中的修改手机号)等。

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

C、引入我们的ORM框架 —— EFCore

这里边有三个 Nuget 包,

代码语言:javascript复制
Microsoft.EntityFrameworkCore//EFCore核心包
Microsoft.EntityFrameworkCore.SqlServer//EFCore的SqlServer辅助包
Microsoft.Extensions.Configuration.FileExtensions//appsetting文件扩展包
Microsoft.Extensions.Configuration.Json//appsetting 数据json读取包

这里给大家说下,如果你不想通过nuget管理器来引入,因为比较麻烦,你可以直接对项目工程文件 Christ3D.Infrastruct.Data.csproj 进行编辑 ,保存好后,项目就直接引用了

代码语言:javascript复制
<Project Sdk="Microsoft.NET.Sdk">
​
  <PropertyGroup>
    <TargetFramework>netcoreapp2.1</TargetFramework>
  </PropertyGroup>
​
  <ItemGroup>
    <ProjectReference Include="..Christ3D.DomainChrist3D.Domain.csproj" />
  </ItemGroup>
​
  //就是下边这一块
  <ItemGroup>
    <PackageReference Include="Microsoft.EntityFrameworkCore" Version="2.2.0-preview3-35497" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="2.2.0-preview3-35497" />
    <PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="2.2.0-preview3-35497" />
    <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="2.2.0-preview3-35497" />
  </ItemGroup>
  //就是上边这些
  
</Project>
添加我们的实体Map

Christ3D.Infrastruct.Data 基础设施数据层新建 Mappings 文件夹,以后在基础设施层的map文件都在这里建立,

然后新建学生实体map,StudentMap.cs

代码语言:javascript复制
/// <summary>
    /// 学生map类
    /// </summary>
    public class StudentMap : IEntityTypeConfiguration<Student>
    {
        /// <summary>
        /// 实体属性配置
        /// </summary>
        /// <param name="builder"></param>
        public void Configure(EntityTypeBuilder<Student> builder)
        {
            builder.Property(c => c.Id)
                .HasColumnName("Id");
​
            builder.Property(c => c.Name)
                .HasColumnType("varchar(100)")
                .HasMaxLength(100)
                .IsRequired();
​
            builder.Property(c => c.Email)
                .HasColumnType("varchar(100)")
                .HasMaxLength(11)
                .IsRequired();
        }
    }
用EFCore来完成基类仓储实现类

将我们刚刚创建好的上下文注入到基类仓储中

代码语言:javascript复制
/// <summary>
    /// 泛型仓储,实现泛型仓储接口
    /// </summary>
    /// <typeparam name="TEntity"></typeparam>
    public class Repository<TEntity> : IRepository<TEntity> where TEntity : class
    {
        protected readonly StudyContext Db;
        protected readonly DbSet<TEntity> DbSet;
​
        public Repository(StudyContext context)
        {
            Db = context;
            DbSet = Db.Set<TEntity>();
        }
​
        public virtual void Add(TEntity obj)
        {
            DbSet.Add(obj);
        }
​
        public virtual TEntity GetById(Guid id)
        {
            return DbSet.Find(id);
        }
​
        public virtual IQueryable<TEntity> GetAll()
        {
            return DbSet;
        }
​
        public virtual void Update(TEntity obj)
        {
            DbSet.Update(obj);
        }
​
        public virtual void Remove(Guid id)
        {
            DbSet.Remove(DbSet.Find(id));
        }
​
        public int SaveChanges()
        {
            return Db.SaveChanges();
        }
​
        public void Dispose()
        {
            Db.Dispose();
            GC.SuppressFinalize(this);
        }
    }
完善实现应用层Service方法

这个时候我们知道,因为我们的应用层的模型的视图模型 StudentViewModel ,但是我们的仓储接口使用的是 Student 业务领域模型,这个时候该怎么办呢,聪明的你一定会想到咱们在上一个系列中所说到的两个知识点,1、DTO的Automapper,然后就是2、引用仓储接口的 IoC 依赖注入,咱们今天就先简单配置下 DTO。这两个内容如果不是很清楚,可以翻翻咱们之前的系列教程内容。

1、在应用层,新建 AutoMapper 文件夹,我们以后的配置文件都放到这里,新建DomainToViewModelMappingProfile.cs

代码语言:javascript复制
 /// <summary>
 /// 配置构造函数,用来创建关系映射
 /// </summary>
 public DomainToViewModelMappingProfile()
 {
     CreateMap<Student, StudentViewModel>();
 }

这些代码你一定很熟悉的,这里就不多说了,如果一头雾水请看我的第一个系列文章吧。

2、完成 StudentAppService.cs 的设计

代码语言:javascript复制
namespace Christ3D.Application.Services
{
    /// <summary>
    /// StudentAppService 服务接口实现类,继承 服务接口
    /// 通过 DTO 实现视图模型和领域模型的关系处理
    /// 作为调度者,协调领域层和基础层,
    /// 这里只是做一个面向用户用例的服务接口,不包含业务规则或者知识
    /// </summary>
    public class StudentAppService : IStudentAppService
    {
        //注意这里是要IoC依赖注入的,还没有实现
        private readonly IStudentRepository _StudentRepository;
        //用来进行DTO
        private readonly IMapper _mapper;
​
        public StudentAppService(
            IStudentRepository StudentRepository,
            IMapper mapper
            )
        {
            _StudentRepository = StudentRepository;
            _mapper = mapper;
        }
​
        public IEnumerable<StudentViewModel> GetAll()
        {
            //第一种写法 Map            return _mapper.Map<IEnumerable<StudentViewModel>>(_StudentRepository.GetAll());            //第二种写法 ProjectTo            //return (_StudentRepository.GetAll()).ProjectTo<StudentViewModel>(_mapper.ConfigurationProvider);
        }
​
        public StudentViewModel GetById(Guid id)
        {
            return _mapper.Map<StudentViewModel>(_StudentRepository.GetById(id));
        }
​
        public void Register(StudentViewModel StudentViewModel)
        {
            //判断是否为空等等 还没有实现
​
            _StudentRepository.Add(_mapper.Map<Student>(StudentViewModel));
        }
​
        public void Update(StudentViewModel StudentViewModel)
        {
            _StudentRepository.Update(_mapper.Map<Student>(StudentViewModel));
​
        }
​
        public void Remove(Guid id)
        {
            _StudentRepository.Remove(id);
​
        }
​
        public void Dispose()
        {
            GC.SuppressFinalize(this);
        }
    }
}

表现层(UI层)

Automapper定义Config配置文件

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

代码语言:javascript复制
   /// <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领域驱动设计中,这个是是视图模型转领域模型,那一定是对领域模型就行命令操作,没错,就是在领域命令中,会用到这里,所以两者不能直接写在一起,这个以后马上会在下几篇文章中说到。

3、将 AutoMapper 服务在 Startup 启动

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

新建 AutoMapperSetup.cs

代码语言:javascript复制
    /// <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();
        }
    }
依赖注入 DI

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

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

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

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

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

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

2、在ConfigureServices 中进行服务注入

代码语言:javascript复制
 // .NET Core 原生依赖注入
 // 单写一层用来添加依赖项,可以将IoC与展示层 Presentation 隔离
 NativeInjectorBootStrapper.RegisterServices(services);
EFCore Code First

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

代码语言:javascript复制
    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 了,针对不同的领域模型进行配置,但是这里有一个重要的知识点,请往下看:

代码语言:javascript复制
    /// <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(100)
                .IsRequired();
​
            builder.Property(c => c.Email)
                .HasColumnType("varchar(100)")
                .HasMaxLength(11)
                .IsRequired();
​
            builder.Property(c => c.Phone)
                .HasColumnType("varchar(100)")
                .HasMaxLength(20)
                .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 命令来控制,这里就不细说了,相信大家用的很多了

代码语言:javascript复制
//1、初始化迁移记录 Init 自定义
Add-Migration Init
​
//2、将当前 Init 的迁移记录更新到数据库
update-database Init 

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

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

添加页面,运行

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

代码语言:javascript复制
 //还是构造函数注入
 private readonly IStudentAppService _studentAppService;
​
 public StudentController(IStudentAppService studentAppService)
 {
     _studentAppService = studentAppService;
 }
​
 // GET: Student
 public ActionResult Index()
 {
     return View(_studentAppService.GetAll());
 }

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

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

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

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

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

Driven驱动

小故事

故事就从这里开始:咱们有一个学校,就叫从壹大学(我瞎起的名字哈哈),我们从壹大学要开发一套教务系统,这个系统涵盖了学校的方方面面,从德智体美劳都有,其中就有一个管理后台,任何人都可以登录进去,学习查看自己的信息和成绩等,老师可以选择课程或者修改自己班级的学生的个人信息的,现在就说其中的一个小栗子 —— 班主任更改学生的手机号。我们就用普通的写法,就是我们平时在写或者现在在用的流程来设计这个小方法。

请注意:当前系统就是一个 领域,里边会有很多 子领域,这个大家应该都能懂。

1、后台管理,修改学生的手机号

这个方法逻辑很简单,就是把学生的手机号更新一下就行,平时咱们一定是咣咣把数据库建好,然后新建实体类,然后就开始写这样的一批方法了,话不多说,直接看看怎么写(这是伪代码):

代码语言:javascript复制
/// <summary>
/// 后台修改学生手机号方法
/// </summary>
/// <param name="NewPhoNumber"></param>
/// <param name="StudentId"></param>
/// <param name="TeacherId"></param>
public void UpdateStudentPhone(string newPhoNumber,int studentId,int teacherId)
{
 
    //核心1:连数据,获取学生信息,然后做修改,再保存数据库。
​
  
​
}

这个方法特别正确,而且是核心算法,简单来看,已经满足我们的需求了,但是却不是完整的,为什么呢,因为只要是管理系统涉及到的一定是有权限问题,然后我们就很开始和DBA讨论增加权限功能。

请注意:这里说到的修改手机号的方法,就是我们之后要说到的领域事件,学生就是我们的领域模型,当然这里边还有聚合根,值对象等等,都从这些概念中提炼出来。

2、为我们的系统增加一个刚需

刚需就是指必须使用到的一些功能,是仅此于核心功能的下一等级,如果按照我们之前的方法,我们就很自然的修改了下我们的方法。

故事:领导说,上边的方法好是好,但是必须增加一个功能强大的权限系统,不仅能学生自己登录修改,还可以老师,教务处等等多方修改,还不能冲突,嗯。

代码语言:javascript复制
/// <summary>
/// 后台修改学生手机号方法
/// </summary>
/// <param name="NewPhoNumber"></param>
/// <param name="StudentId"></param>
/// <param name="TeacherId"></param>
public void UpdateStudentPhone(string newPhoNumber,int studentId,int teacherId)
{
    //重要2:首先要判断当然 Teacher 是否有权限(比如只有班主任可以修改本班)
    //注意这个时候已经把 Teacher 这个对象,给悄悄的引进来了。
​
    //------------------------------------------------------------
​
    //核心:连数据,获取学生信息,然后做修改,再保存数据库。
​
  
​
​
}

这个时候你一定会说我们可以使用JWT这种呀,当然你说的对,是因为咱们上一个系列里说到这个了,这个也有设计思想在里边,今天咱们就暂时先用平时咱们用到的上边这个方法,集成到一起来说明,只不过这个时候我们发现我们的的领域里,不仅仅多了 Teacher 这个其他模型,而且还多了与主方法无关,或者说不是核心的事件。

这个时候,我们在某些特定的方法里,已经完成权限,我们很开心,然后交给学校验收,发现很好,然后就上线了,故事的第一篇就这么结束了,你会想,难道还有第二篇么,没错!事务总是源源不断的的进来的,请耐心往下看。

请注意:这个权限问题就是 切面AOP 编程问题,以前已经说到了,这个时候你能想到JWT,说明很不错了,当然还可以用Id4等。

3、给系统增加一个事件痕迹存储

这个不知道你是否能明白,这个说白了就是操作日志,当然你可以和错误日志呀,接口访问日志一起联想,我感觉也是可以的,不过我更喜欢把它放在事件上,而不是日志这种数据上。

故事:经过一年的使用,系统安静平稳,没有bug,一切正常,但是有一天,学生小李自己换了一个手机号,然后就去系统修改,竟然发现自己的个人信息已经被修改了(是班主任改的),小李很神奇这件事,然后就去查,当然是没有记录的,这个时候反馈给技术部门,领导结合着其他同学的意见,决定增加一个痕迹历史记录页,将痕迹跟踪提上了日程。我们就这么开发了。

代码语言:javascript复制
/// <summary>
/// 后台修改学生手机号方法
/// </summary>
/// <param name="NewPhoNumber"></param>
/// <param name="StudentId"></param>
/// <param name="TeacherId"></param>
public void UpdateStudentPhone(string newPhoNumber,int studentId,int teacherId)
{
    //重要:首先要判断当然 Teacher 是否有权限(比如只有班主任可以修改本班)
    //注意这个时候已经把 Teacher 这个对象,给悄悄的引进来了。
​
    //------------------------------------------------------------
​
    //核心:连数据,或者学生信息,然后做修改,再保存数据库。
​
    //------------------------------------------------------------
​
    //协同3:痕迹跟踪(你可以叫操作日志),获取当然用户信息,和老师信息,连同更新前后的信息,一起保存到数据库,甚至是不同的数据库地址。
    //注意,这个是一个突发的,项目上线后的需求
​
​
}

这个时候你可能会说,这个项目太假了,不会发生这样的事情,这些问题都应该在项目开发的时候讨论出来,并解决掉,真的是这样的么,这样的事情多么常见呀,我们平时开发的时候,就算是一个特别成熟的领域,也会在项目上线后,增加删除很多东西,这个只是一个个例,大家联想下平时的工作即可。

这个时候如果我们还采用这个方法,你会发现要修改很多地方,如果说我们只有几十个方法还行,我们就粘贴复制十分钟就行,但是我们项目有十几个用户故事,每一个故事又有十几个到几十个不等的用例流,你想想,如果我们继续保持这个架构,我们到底应该怎么开发,可能你会想到,还有权限管理的那个AOP思想,写一个切面,可是真的可行么,我们现在不仅仅要获取数据前和数据后两块,还有用户等信息,切面我感觉是很有困难的,当然你也好好思考思考。

这个时候你会发现,咱们平时开发的普通的框架已经支撑不住了,或者是已经很困难了,一套系统改起来已经过去很久了,而且不一定都会修改正确,如果一个地方出错,当前方法就受影响,一致性更别说了,试想下,如果我们开发一个在线答题系统,就因为记录下日志或者什么的,导致结果没有保存好,学生是会疯的。第二篇就这么结束了,也许你的耐心已经消磨一半了,也许我们以为一起安静的时候,第三个故事又开始了。

请注意:这个事件痕迹记录就涉及到了 事件驱动事件源 相关问题,以后会说到。

4、再增加一个站内通知业务

故事:我们从壹大学新换了一个PM,嗯,在数据安全性,原子性的同时,更注重大家信息的一致性 —— 任何人修改都需要给当前操作人,被操作人,管理员或者教务处发站内消息通知,这个时候你会崩溃到哭的。

代码语言:javascript复制
/// <summary>
/// 后台修改学生手机号方法
/// </summary>
/// <param name="NewPhoNumber"></param>
/// <param name="StudentId"></param>
/// <param name="TeacherId"></param>
public void UpdateStudentPhone(string newPhoNumber,int studentId,int teacherId)
{
    //重要:首先要判断当然 Teacher 是否有权限(比如只有班主任可以修改本班)
    //注意这个时候已经把 Teacher 这个对象,给悄悄的引进来了。
​
    //------------------------------------------------------------
​
    //核心:连数据,或者学生信息,然后做修改,再保存数据库。
​
    //------------------------------------------------------------
​
    //协同:痕迹跟踪(你可以叫操作日志),获取当然用户信息,和老师信息,连同更新前后的信息,一起保存到数据库,甚至是不同的数据库地址。
    //注意,这个是一个突发的,项目上线后的需求
​
    //------------------------------------------------------------
​
    //协同4:消息通知,把消息同时发给指定的所有人。
​
​
}

这个时候我就不具体说了,相信都已经离职了吧,可是这种情况就是每天都在发生。

请注意:上边咱们这个伪代码所写的,就是DDD的 通用领域语言,也可以叫 战略设计

5、DDD领域驱动设计就能很好的解决

上边的这个问题不知道是否能让你了解下软件开发中的痛点在哪里,二十年前 Eric Evans 就发现了,并提出了领域驱动设计的思想,就是通过将一个领域进行划分成不同的子领域,各个子领域之间通过限界上下文进行分隔,在每一个限界上下文中,有领域模型,领域事件,聚合,值对象等等,各个上下文互不冲突,互有联系,保证内部的一致性,这些以后会说到。

如果你对上下文不是很明白,你可以暂时把它理解成子领域,领域的概念是从战略设计来说的,上下文这些是从战术设计上来说的。

具体的请参考我的上一篇文章《三 ║ 简单说说:领域、子域、限界上下文》

你也许会问,那我们如何通过DDD领域驱动设计来写上边的修改手机号这个方法呢,这里简单画一下,只是说一个大概意思,切分领域以后,每一个领域之间互不联系,有效的避免了牵一发而动全身的问题,而且我们可以很方便进行扩展,自定义扩展上下文,当然如果你想在教学子领域下新增一个年级表,那就不用新建上下文了,直接在改学习上下文中操作即可,具体的代码如何实现,咱们以后会慢慢说到。

总结:这个时候你通过上边的这个栗子,不知道你是否明白了,我们为什么要在大型的项目中,使用DDD领域设计,并配合这CQRS和事件驱动架构来搭建项目了,它所解决的就是我们在上边的小故事中提到的随着业务的发展,困难值呈现指数增长的趋势了。

CQRS读写分离初探

1、DDD中四种模型

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

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

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

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

代码语言:javascript复制
 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的思想,任何一个方法都可以拆分为命令和查询两部分,比如:

代码语言:javascript复制
  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两个方法,如下:

代码语言:javascript复制
 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分离了读写职责之后,可以对数据进行读写分离操作来改进性能,可扩展性和安全。如下图:

4、CQRS 的应用场景

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

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

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

领域模型、视图模型的相互转换

领域模型

Student

代码语言:javascript复制
  public class Student : Entity
    {
        protected Student() {
        }
        public Student(Guid id, string name, string email, string phone, DateTime birthDate, Address address)
        {
            Id = id;
            Name = name;
            Email = email;
            Phone = phone;
            BirthDate = birthDate;
            Address = address;
        }
        /// <summary>
        /// 姓名
        /// </summary>
        public string Name { get; private set; }
        /// <summary>
        /// 邮箱
        /// </summary>
        public string Email { get; private set; }
        /// <summary>
        /// 手机
        /// </summary>
        public string Phone { get; private set; }
        /// <summary>
        /// 生日
        /// </summary>
        public DateTime BirthDate { get; private set; }
​
        /// <summary>
        /// 户籍
        /// </summary>
        public Address Address { get; private set; }
​
​
    }

Address

代码语言:javascript复制
 /// <summary>
    /// 地址
    /// </summary>
    [Owned]
    public class Address : ValueObject<Address>
    {
        /// <summary>
        /// 省份
        /// </summary>
        public string Province { get; private set; }
​
        /// <summary>
        /// 城市
        /// </summary>
        public string City { get; private set; }
​
        /// <summary>
        /// 区县
        /// </summary>
        public string County { get; private set; }
​
        /// <summary>
        /// 街道
        /// </summary>
        public string Street { get; private set; }
​
​
        public Address() { }
        public Address(string province, string city,
            string county, string street)
        {
            this.Province = province;
            this.City = city;
            this.County = county;
            this.Street = street;
        }
​
      
​
        protected override bool EqualsCore(Address other)
        {
            throw new NotImplementedException();
        }
​
        protected override int GetHashCodeCore()
        {
            throw new NotImplementedException();
        }
    }
方式一: 视图模型的结构采用和领域模型一样的结构结构

StudentViewModel

代码语言:javascript复制
  public class StudentViewModel
    {
        [Key]
        public Guid Id { get; set; }
​
        [Required(ErrorMessage = "The Name is Required")]
        [MinLength(2)]
        [MaxLength(100)]
        [DisplayName("Name")]
        public string Name { get; set; }
​
        [Required(ErrorMessage = "The E-mail is Required")]
        [EmailAddress]
        [DisplayName("E-mail")]
        public string Email { get; set; }
​
        [Required(ErrorMessage = "The BirthDate is Required")]
        [DisplayFormat(ApplyFormatInEditMode = true, DataFormatString = "{0:yyyy-MM-dd}")]
        [DataType(DataType.Date, ErrorMessage = "Date in invalid format")]
        [DisplayName("Birth Date")]
        public DateTime BirthDate { get; set; }
​
​
        [Required(ErrorMessage = "The Phone is Required")]
        [Phone]
        //[Compare("ConfirmPhone")]
        [DisplayName("Phone")]
        public string Phone { get; set; }
​
​
        public AddressViewModel Address { get; set; }
       
    }

AddressViewModel

代码语言:javascript复制
  /// <summary>
    /// 地址
    /// </summary>
    public class AddressViewModel
    {
        /// <summary>
        /// 省份
        /// </summary>
        [Required(ErrorMessage = "The Province is Required")]
        [DisplayName("Province")]
        public string Province { get; set; }
​
        /// <summary>
        /// 城市
        /// </summary>
        public string City { get; set; }
​
        /// <summary>
        /// 区县
        /// </summary>
        public string County { get; set; }
​
        /// <summary>
        /// 街道
        /// </summary>
        public string Street { get; set; }
​
​
​
    }
方式二:视图模型采用扁平结构,通过automap,完成和领域模型的转换

StudentViewModel

代码语言:javascript复制
    public class StudentViewModel
    {
        [Key]
        public Guid Id { get; set; }
​
        [Required(ErrorMessage = "The Name is Required")]
        [MinLength(2)]
        [MaxLength(100)]
        [DisplayName("Name")]
        public string Name { get; set; }
​
        [Required(ErrorMessage = "The E-mail is Required")]
        [EmailAddress]
        [DisplayName("E-mail")]
        public string Email { get; set; }
​
        [Required(ErrorMessage = "The BirthDate is Required")]
        [DisplayFormat(ApplyFormatInEditMode = true, DataFormatString = "{0:yyyy-MM-dd}")]
        [DataType(DataType.Date, ErrorMessage = "Date in invalid format")]
        [DisplayName("Birth Date")]
        public DateTime BirthDate { get; set; }
​
​
        [Required(ErrorMessage = "The Phone is Required")]
        [Phone]
        //[Compare("ConfirmPhone")]
        [DisplayName("Phone")]
        public string Phone { get; set; }
​
        /// <summary>
        /// 省份
        /// </summary>
        [Required(ErrorMessage = "The Province is Required")]
        [DisplayName("Province")]
        public string Province { get; set; }
​
        /// <summary>
        /// 城市
        /// </summary>
        public string City { get; set; }
​
        /// <summary>
        /// 区县
        /// </summary>
        public string County { get; set; }
​
        /// <summary>
        /// 街道
        /// </summary>
        public string Street { get; set; }
    }

AutoMapper转换

1、复杂领域模型转换到视图模型

代码语言:javascript复制
 /// <summary>
 /// 配置构造函数,用来创建关系映射
 /// </summary>
 public DomainToViewModelMappingProfile()
 {
     CreateMap<Student, StudentViewModel>()
         .ForMember(d => d.County, o => o.MapFrom(s => s.Address.County))
         .ForMember(d => d.Province, o => o.MapFrom(s => s.Address.Province))
         .ForMember(d => d.City, o => o.MapFrom(s => s.Address.City))
         .ForMember(d => d.Street, o => o.MapFrom(s => s.Address.Street))
         ;
​
  
 }

这个时候,我们看Index页面,户籍信息也出来了

2、视图模型转换到复杂领域模型

代码语言:javascript复制
 public ViewModelToDomainMappingProfile()
 {
     //手动进行配置
     CreateMap<StudentViewModel, Student>()
      .ForPath(d => d.Address.Province, o => o.MapFrom(s => s.Province))
      .ForPath(d => d.Address.City, o => o.MapFrom(s => s.City))
      .ForPath(d => d.Address.County, o => o.MapFrom(s => s.County))
      .ForPath(d => d.Address.Street, o => o.MapFrom(s => s.Street))
      ;
​
 }

这里将 Student 中的户籍信息,一一匹配到视图模型中的属性。

然后我们测试数据,不仅仅可以把数据获取到,还可以成功的转换过去:

命令模型

创建命令抽象基类

在 Christ3D.Domain.Core 领域核心层中,新建Commands文件夹,并该文件夹下创建抽象命令基类 Command,这里可能有小伙伴会问,这个层的作用,我就简单再说下,这个层的作用是为了定义核心的领域知识的,说人话就是很多基类,比如 Entity 是领域模型的基类,ValueObject 是值对象的基类,这里的Command 是领域命令的基类,当然,你也可以把他放到领域层中,用一个 Base 文件夹来表示,这小问题就不要争议了。

代码语言:javascript复制
namespace Christ3D.Domain.Core.Commands
{
    /// <summary>
    /// 抽象命令基类
    /// </summary>
    public abstract class Command 
    {
        //时间戳
        public DateTime Timestamp { get; private set; }
        //验证结果,需要引用FluentValidation
        public ValidationResult ValidationResult { get; set; }
​
        protected Command()
        {
            Timestamp = DateTime.Now;
        }
​
        //定义抽象方法,是否有效
        public abstract bool IsValid();
    }
}

思考:为什么要单单顶一个抽象方法 IsValid();

定义 StudentCommand ,领域命令模型

上边的领域基类建好以后,我们就需要给每一个领域模型,建立领域命令了,这里有一个小小的绕,你这个时候需要静一静,想一想,

1、为什么每一个领域模型都需要一个命令模型。

2、为什么是一个抽象类。

代码语言:javascript复制
namespace Christ3D.Domain.Commands
{
    /// <summary>
    /// 定义一个抽象的 Student 命令模型
    /// 继承 Command
    /// 这个模型主要作用就是用来创建命令动作的,不是用来实例化存数据的,所以是一个抽象类
    /// </summary>
    public abstract class StudentCommand : Command
    {
        public Guid Id { get; protected set; }//注意:set 都是 protected 的
​
        public string Name { get; protected set; }
​
        public string Email { get; protected set; }
​
        public DateTime BirthDate { get; protected set; }
​
        public string Phone { get; protected set; }
    }
}

希望这个时候你已经明白了上边的两个问题了,如果不是很明白,请再好好思考下,如果已经明白了,请继续往下走。

基于命令模型,创建各种动作指令

上边的模型创造出来了,咱们需要用它来实现各种动作命令了,比如 CUD 操作(Create/Update/Delete),肯定是没有 R(Read) 查询的。这里就重点说一下创建吧,剩下两个都一样。

代码语言:javascript复制
namespace Christ3D.Domain.Commands
{
    /// <summary>
    /// 注册一个添加 Student 命令
    /// 基础抽象学生命令模型
    /// </summary>
    public class RegisterStudentCommand : StudentCommand
    {
        // set 受保护,只能通过构造函数方法赋值
        public RegisterStudentCommand(string name, string email, DateTime birthDate, string phone)
        {
            Name = name;
            Email = email;
            BirthDate = birthDate;
            Phone = phone;
        }
​
        // 重写基类中的 是否有效 方法
        // 主要是为了引入命令验证 RegisterStudentCommandValidation。
        public override bool IsValid()
        {
            ValidationResult = new RegisterStudentCommandValidation().Validate(this);//注意:这个就是命令验证,我们会在下边实现它
            return ValidationResult.IsValid;
        }
    }
}

这里你应该就能明白第一步的那个问题了吧:为什么要单单顶一个抽象方法 IsValid();

不仅仅是验证当前命令模型是否有效(无效是指:数据有错误、验证失败等等),只有有效了才可以往下继续走(比如持久化等 ),还要获取验证失败的情况下,收录哪些错误信息,并返回到前台,这个就是

代码语言:javascript复制
new RegisterStudentCommandValidation()

的作用。注意这里还没有实现,我们接下来就会实现它。

添加学生命令写完了,然后就是更新 UpdateStudentCommand 和 删除 RemoveStudentCommand 了,这里就不多说了。

命令验证

基于StudentCommand 创建抽象验证基类

在上边的领域命令中,我们定义一个公共的抽象命令基类,在验证中,FluentValidation已经为我们定义好了一个抽象基类 AbstractValidator,所以我们只需要继承它就行。

代码语言:javascript复制
namespace Christ3D.Domain.Validations
{
    /// <summary>
    /// 定义基于 StudentCommand 的抽象基类 StudentValidation
    /// 继承 抽象类 AbstractValidator
    /// 注意需要引用 FluentValidation
    /// 注意这里的 T 是命令模型
    /// </summary>
    /// <typeparam name="T">泛型类</typeparam>
    public abstract class StudentValidation<T> : AbstractValidator<T> where T : StudentCommand
    {
        //受保护方法,验证Name
        protected void ValidateName()
        {       //定义规则,c 就是当前 StudentCommand 类
            RuleFor(c => c.Name)
                .NotEmpty().WithMessage("姓名不能为空")//判断不能为空,如果为空则显示Message
                .Length(2, 10).WithMessage("姓名在2~10个字符之间");//定义 Name 的长度
        }
​
        //验证年龄
        protected void ValidateBirthDate()
        {
            RuleFor(c => c.BirthDate)
                .NotEmpty()
                .Must(HaveMinimumAge)//Must 表示必须满足某一个条件,参数是一个bool类型的方法,更像是一个委托事件
                .WithMessage("学生应该14岁以上!");
        }
​
        //验证邮箱
        protected void ValidateEmail()
        {
            RuleFor(c => c.Email)
                .NotEmpty()
                .EmailAddress();
        }
        //验证手机号
        protected void ValidatePhone()
        {
            RuleFor(c => c.Phone)
                .NotEmpty()
                .Must(HavePhone)
                .WithMessage("手机号应该为11位!");
        }
​
        //验证Guid
        protected void ValidateId()
        {
            RuleFor(c => c.Id)
                .NotEqual(Guid.Empty);
        }
​
        // 表达式
        protected static bool HaveMinimumAge(DateTime birthDate)
        {
            return birthDate <= DateTime.Now.AddYears(-14);
        }
        // 表达式
        protected static bool HavePhone(string phone)
        {
            return phone.Length == 11;
        }
    }
}

关于 FluentValidation 的使用,这里就不多说了,大家可以自己使用,基本的也就是这么多了,当然大家也可以自己写一些复杂的运算,这里要说的重点是,大家应该也已经发现了,每一个验证方法都是独立的,互不影响,就算是有一个出现错误(当然不是编译错误),也不会影响当前整个领域命令,也就等同于不影响当前事件操作,是不是和以前相比,不仅方便而且安全性更高了。

这个时候我们定义了这个抽象的学生验证基类,剩下的就是需要针对不同的,每一个领域命令,设计领域验证了。

实现各个领域命令模型的验证操作

这里就简单说一个添加学生的命令验证,我们实现 StudentValidation<RegisterStudentCommand> ,并初始化相应的命令,这里可以看到,我们可以很自由针对某一个命令,随心随意的设计不同的验证,而且很好的进行管控,比如以后我们不要对名字控制了,我们只需要去掉这个方法。亦或者我们以后不仅支持手机号,还支持座机,这里就可以简单的增加一个即可。

代码语言:javascript复制
namespace Christ3D.Domain.Validations
{
    /// <summary>
    /// 添加学生命令模型验证
    /// 继承 StudentValidation 基类
    /// </summary>
    public class RegisterStudentCommandValidation : StudentValidation<RegisterStudentCommand>
    {
        public RegisterStudentCommandValidation()
        {
            ValidateName();//验证姓名
            ValidateBirthDate();//验证年龄
            ValidateEmail();//验证邮箱
            ValidatePhone();//验证手机号
            //可以自定义增加新的验证
        }
    }
}

说到了这里,相信你应该也命令了领域驱动的第一个小部分了,就是我们的每一个操作是如何生成命令并进行验证的,那聪明的你一定会问了,我们如何操作这些领域命令呢,总得有一个驱动程序吧,它们自己肯定是不会运行的,不错!请继续往下看。

命令总线

创建一个中介处理程序接口

在我们的核心领域层 Christ3D.Domain.Core 中,新建 Bus 文件夹,然后创建中介处理程序接口 IMediatorHandler.cs

代码语言:javascript复制
namespace Christ3D.Domain.Core.Bus
{
    /// <summary>
    /// 中介处理程序接口
    /// 可以定义多个处理程序
    /// 是异步的
    /// </summary>
    public interface IMediatorHandler
    {
        /// <summary>
        /// 发布命令,将我们的命令模型发布到中介者模块
        /// </summary>
        /// <typeparam name="T"> 泛型 </typeparam>
        /// <param name="command"> 命令模型,比如RegisterStudentCommand </param>
        /// <returns></returns>
        Task SendCommand<T>(T command) where T : Command;
    }
}

发布命令:就好像我们调用某招聘平台,发布了一个招聘命令。

一个低调的中介者工具 —— MediatR

微软官方eshopOnContainer开源项目中使用到了该工具, mediatR 是一种中介工具,解耦了消息处理器和消息之间耦合的类库,支持跨平台 .net Standard和.net framework https://github.com/jbogard/MediatR/wiki 这里是原文地址。其作者也是Automapper的作者。 功能要是简述的话就俩方面: request/response 请求响应 //咱们就采用这个方式 pub/sub 发布订阅

使用方法:通过 .NET CORE 自带的 IoC 注入

引用 MediatR nuget:install-package MediatR

引用IOC扩展 nuget:installpackage MediatR.Extensions.Microsoft.DependencyInjection //扩展包

使用方式:

services.AddMediatR(typeof(MyxxxHandler));//单单注入某一个处理程序

services.AddMediatR(typeof(Startup).GetTypeInfo().Assembly);//目的是为了扫描Handler的实现对象并添加到IOC的容器中

代码语言:javascript复制
//参考示例
​
//请求响应方式(request/response),三步走:
//步骤一:创建一个消息对象,需要实现IRequest,或IRequest<> 接口,表明该对象是处理器的一个对象
public class Ping : IRequest<string>
{
}
​
//步骤二:创建一个处理器对象
public class PingHandler : IRequestHandler<Ping, string>
{
    public Task<string> Handle(Ping request, CancellationToken cancellationToken)
    {
        return Task.FromResult("老张的哲学");
    }
}
​
//步骤三:最后,通过mediator发送一个消息
var response = await mediator.Send(new Ping());
​
Debug.WriteLine(response); // "老张的哲学"
项目中实现中介处理程序接口

这里就不讲解为什么要使用 MediatR 来实现我们的中介者模式了,因为我没有找到其他的

0 人点赞