DDD领域驱动设计 (C# 整理自“老张的哲学”)
概念
Domain Driven Design 领域驱动设计
第一个D(Domain):
领域:指围绕业务为核心而划分的实体模块。
第二个D(Driven):
驱动:这里的驱动包含了 领域命令模型验证、领域事件处理、领域事件通知、事件溯源。
第三个D(Design):
设计:这里指架构分层,即应该如何分层?领域逻辑写在哪?与持久化如何交互?如何协调多对象领域逻辑?如何实现逻辑与数据存储解耦等
上面的每一块的内容都很多,不是几句话就能说清楚的。下面我就挑一些重要的来说
Domin领域
领域模型:是对ORM中的M做了进一步的处理,即按照业务范围划分成多个聚合根(多个实体的集合体)。
而在这个聚合根里按照业务数据的特有特性而又拉出了一些概念:实体、值对象
实体特性:
1、有唯一的标识,不受状态属性的影响。——可以理解数据表中的主键 2、可变性特征,状态信息一直可以变化。——可以理解成数据表中除主键以外其他信息
值对象特性:
1、它描述了领域中的一个东西 2、可以作为一个不变量。 3、当它被改变时,可以用另一个值对象替换。 4、可以和别的值对象进行相等性比较。
示例:
实体Student(学生):
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
/// <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(家庭住址)
/// <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、确定好聚合以后,要确定聚合根
比如:订单模型中,订单表就是整个聚合的聚合根。
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的这样定义;
/// <summary> /// 聚合根 发帖 /// </summary> public class Post : AggregateRoot { public string PostTitle; public List<Reply> Reply;//回复 //... } /// <summary> /// 实体 回复 /// </summary> public class Reply : Entity { public string Content; //... }
这样初看是没有什么问题,很正常呀,发帖子是发回复的聚合根,回复必须有一个帖子,不然无效,看似合理的地方却有不合理。
比如,当我要对一个帖子发表回复时,我取出当前帖子信息,嗯,这个很对,但是,如果我对回复进行回复的时候,那就不好了,我每次还是都要取出整个带有很多回复的帖子,然后往里面增加回复,然后保存整个帖子,因为聚合的一致性要求我们必须这么做。无论是在场景还是在并发的情况下这是不行的。
如果帖子和回复在一个聚合内,聚合意味着“修改数据的一个最小单元”,聚合内的所有对象要看成是一个整体最小单元进行保存。这么要求是因为聚合的意义是维护聚合内的不变性,数据一致性; 仔细分析我们会发现帖子和回复之间没有数据一致性要求。所以不需要设计在同一个聚合内。
从场景的角度,我们有发表帖子,发表回复,这两个不同的场景,发表帖子创建的是帖子,而发表回复创建的是回复。但是订单就不一样,我们有创建订单,修改订单这两个场景。这两个场景都是围绕这订单这个聚合展开的。
所以我们应该把回复实体也单独作为一个聚合根来处理:
/// <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(值对象/聚合/根)
/// <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层来对接口进行实现是那么格格不入,没关系慢慢来~~~
/// <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 的视图模型,它是如何来的,这个下边说到。
/// <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服务接口类,这里要说明下,在应用层对外接口中,我们就不需要定义泛型基类了,因为已经没有必要,甚至是无法抽象的,
/// <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 文件夹,用来存放我们对服务接口的实现类
/// <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,进行注册。
{ "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,这个时候你就不用问我,为啥在教务系统领域中,学习领域是核心子领域了吧。
/// <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 包,
Microsoft.EntityFrameworkCore//EFCore核心包
Microsoft.EntityFrameworkCore.SqlServer//EFCore的SqlServer辅助包
Microsoft.Extensions.Configuration.FileExtensions//appsetting文件扩展包
Microsoft.Extensions.Configuration.Json//appsetting 数据json读取包
这里给大家说下,如果你不想通过nuget管理器来引入,因为比较麻烦,你可以直接对项目工程文件 Christ3D.Infrastruct.Data.csproj 进行编辑 ,保存好后,项目就直接引用了
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>netcoreapp2.1</TargetFramework> </PropertyGroup> <ItemGroup> <ProjectReference Include="..\Christ3D.Domain\Christ3D.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
/// <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来完成基类仓储实现类
将我们刚刚创建好的上下文注入到基类仓储中
/// <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
/// <summary> /// 配置构造函数,用来创建关系映射 /// </summary> public DomainToViewModelMappingProfile() { CreateMap<Student, StudentViewModel>(); }
这些代码你一定很熟悉的,这里就不多说了,如果一头雾水请看我的第一个系列文章吧。
2、完成 StudentAppService.cs 的设计
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 配置文件,
/// <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
/// <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层:
public static void RegisterServices(IServiceCollection services) { // 注入 Application 应用层 services.AddScoped<IStudentAppService, StudentAppService>(); // 注入 Infra - Data 基础设施数据层 services.AddScoped<IStudentRepository, StudentRepository>(); services.AddScoped<StudyContext>();//上下文 }
具体的使用方法和我们Autofac很类型,这里就不说了,相信大家已经很了解依赖注入了。
2、在ConfigureServices 中进行服务注入
// .NET Core 原生依赖注入
// 单写一层用来添加依赖项,可以将IoC与展示层 Presentation 隔离
NativeInjectorBootStrapper.RegisterServices(services);
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(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 命令来控制,这里就不细说了,相信大家用的很多了
//1、初始化迁移记录 Init 自定义
Add-Migration Init
//2、将当前 Init 的迁移记录更新到数据库
update-database Init
然后就可以看到我们的的数据库已经生成:
以后大家在迁移数据库的时候,可能会遇到问题,因为本项目有两个上下文,大家可以指定其中的操作
添加页面,运行
1、到这里我们就已经把整体调通了,然后新建 StudentController.cs ,添加 CURD 页面
//还是构造函数注入 private readonly IStudentAppService _studentAppService; public StudentController(IStudentAppService studentAppService) { _studentAppService = studentAppService; } // GET: Student public ActionResult Index() { return View(_studentAppService.GetAll()); }
2、运行项目,就能看到结果
这个时候,我们已经通过了 DI 进行注入,然后通过Dtos 将我们的领域模型,转换成了视图模型,进行展示,也许这个时候你会发现,这个很正常呀,平时都是这么做的,也没有看到有什么高端的地方,聪明的你一定会想到更远的地方,这里我们是用领域模型 -> 视图模型的DTO,也就是我们平时说的查询模式,
那有查询,肯定有编辑模式,我们就会有 视图模型,传入,然后转换领域模型,中间当然还有校验等等(不是简单的视图模型的判空,还有其他的复杂校验,比如年龄,字符串),这个时候,如果我们直接用 视图模型 -> 领域模型的话,肯定会有污染,至少会把读和写混合在一起,
public void Register(StudentViewModel StudentViewModel) { //这里引入领域设计中的写命令 还没有实现 //请注意这里如果是平时的写法,必须要引入Student领域模型,会造成污染 _StudentRepository.Add(_mapper.Map<Student>(StudentViewModel)); }
那该怎么办呢,这个时候CQRS 就登场了!请往下看。
Driven驱动
小故事
故事就从这里开始:咱们有一个学校,就叫从壹大学(我瞎起的名字哈哈),我们从壹大学要开发一套教务系统,这个系统涵盖了学校的方方面面,从德智体美劳都有,其中就有一个管理后台,任何人都可以登录进去,学习查看自己的信息和成绩等,老师可以选择课程或者修改自己班级的学生的个人信息的,现在就说其中的一个小栗子 —— 班主任更改学生的手机号。我们就用普通的写法,就是我们平时在写或者现在在用的流程来设计这个小方法。
请注意:当前系统就是一个 领域,里边会有很多 子领域,这个大家应该都能懂。
1、后台管理,修改学生的手机号
这个方法逻辑很简单,就是把学生的手机号更新一下就行,平时咱们一定是咣咣把数据库建好,然后新建实体类,然后就开始写这样的一批方法了,话不多说,直接看看怎么写(这是伪代码):
/// <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、为我们的系统增加一个刚需
刚需就是指必须使用到的一些功能,是仅此于核心功能的下一等级,如果按照我们之前的方法,我们就很自然的修改了下我们的方法。
故事:领导说,上边的方法好是好,但是必须增加一个功能强大的权限系统,不仅能学生自己登录修改,还可以老师,教务处等等多方修改,还不能冲突,嗯。
/// <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,一切正常,但是有一天,学生小李自己换了一个手机号,然后就去系统修改,竟然发现自己的个人信息已经被修改了(是班主任改的),小李很神奇这件事,然后就去查,当然是没有记录的,这个时候反馈给技术部门,领导结合着其他同学的意见,决定增加一个痕迹历史记录页,将痕迹跟踪提上了日程。我们就这么开发了。
/// <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,嗯,在数据安全性,原子性的同时,更注重大家信息的一致性 —— 任何人修改都需要给当前操作人,被操作人,管理员或者教务处发站内消息通知,这个时候你会崩溃到哭的。
/// <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中四种模型
如果你是从我的系列的第一篇开始读,你应该已经对这两个模型很熟悉了,领域模型,视图模型,当然,还有咱们一直开发中使用到的数据模型,那第四个是什么呢?
-
数据模型:面向持久化,数据的载体。
-
领域模型:面向业务,行为的载体。
-
视图模型:面向UI(向外),数据的载体。
-
命令模型:面向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语言之父,
-
命令(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使用分离的接口将数据查询操作(Queries)和数据修改操作(Commands)分离开来,这也意味着在查询和更新过程中使用的数据模型也是不一样的。这样读和写逻辑就隔离开来了。
使用CQRS分离了读写职责之后,可以对数据进行读写分离操作来改进性能,可扩展性和安全。如下图:
4、CQRS 的应用场景
在下场景中,可以考虑使用CQRS模式:
-
当在业务逻辑层有很多操作需要相同的实体或者对象进行操作的时候。CQRS使得我们可以对读和写定义不同的实体和方法,从而可以减少或者避免对某一方面的更改造成冲突;
-
对于一些基于任务的用户交互系统,通常这类系统会引导用户通过一系列复杂的步骤和操作,通常会需要一些复杂的领域模型,并且整个团队已经熟悉领域驱动设计技术。写模型有很多和业务逻辑相关的命令操作的堆,输入验证,业务逻辑验证来保证数据的一致性。读模型没有业务逻辑以及验证堆,仅仅是返回DTO对象为视图模型提供数据。读模型最终和写模型相一致。
-
适用于一些需要对查询性能和写入性能分开进行优化的系统,尤其是读/写比非常高的系统,横向扩展是必须的。比如,在很多系统中读操作的请求时远大于写操作。为适应这种场景,可以考虑将写模型抽离出来单独扩展,而将写模型运行在一个或者少数几个实例上。少量的写模型实例能够减少合并冲突发生的情况
-
适用于一些团队中,一些有经验的开发者可以关注复杂的领域模型,这些用到写操作,而另一些经验较少的开发者可以关注用户界面上的读模型。
-
对于系统在将来会随着时间不段演化,有可能会包含不同版本的模型,或者业务规则经常变化的系统
-
需要和其他系统整合,特别是需要和事件溯源Event Sourcing进行整合的系统,这样子系统的临时异常不会影响整个系统的其他部分。
这里我只是把CQRS的初衷简单说了一下,下一节我们会重点来讲解 读写分离 的过程,以及命令是怎么配合着 Validations 进行验证的。
领域模型、视图模型的相互转换
领域模型
Student
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
/// <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
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
/// <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
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、复杂领域模型转换到视图模型
/// <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、视图模型转换到复杂领域模型
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 文件夹来表示,这小问题就不要争议了。
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、为什么是一个抽象类。
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) 查询的。这里就重点说一下创建吧,剩下两个都一样。
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();
不仅仅是验证当前命令模型是否有效(无效是指:数据有错误、验证失败等等),只有有效了才可以往下继续走(比如持久化等 ),还要获取验证失败的情况下,收录哪些错误信息,并返回到前台,这个就是
new RegisterStudentCommandValidation()
的作用。注意这里还没有实现,我们接下来就会实现它。
添加学生命令写完了,然后就是更新 UpdateStudentCommand 和 删除 RemoveStudentCommand 了,这里就不多说了。
命令验证
基于StudentCommand 创建抽象验证基类
在上边的领域命令中,我们定义一个公共的抽象命令基类,在验证中,FluentValidation已经为我们定义好了一个抽象基类 AbstractValidator,所以我们只需要继承它就行。
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> ,并初始化相应的命令,这里可以看到,我们可以很自由针对某一个命令,随心随意的设计不同的验证,而且很好的进行管控,比如以后我们不要对名字控制了,我们只需要去掉这个方法。亦或者我们以后不仅支持手机号,还支持座机,这里就可以简单的增加一个即可。
namespace Christ3D.Domain.Validations { /// <summary> /// 添加学生命令模型验证 /// 继承 StudentValidation 基类 /// </summary> public class RegisterStudentCommandValidation : StudentValidation<RegisterStudentCommand> { public RegisterStudentCommandValidation() { ValidateName();//验证姓名 ValidateBirthDate();//验证年龄 ValidateEmail();//验证邮箱 ValidatePhone();//验证手机号 //可以自定义增加新的验证 } } }
说到了这里,相信你应该也命令了领域驱动的第一个小部分了,就是我们的每一个操作是如何生成命令并进行验证的,那聪明的你一定会问了,我们如何操作这些领域命令呢,总得有一个驱动程序吧,它们自己肯定是不会运行的,不错!请继续往下看。
命令总线
创建一个中介处理程序接口
在我们的核心领域层 Christ3D.Domain.Core 中,新建 Bus 文件夹,然后创建中介处理程序接口 IMediatorHandler.cs
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的容器中
//参考示例 //请求响应方式(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 来实现我们的中介者模式了,因为我没有找到其他的😂,具体的使用方法很简单,就和我们的缓存 IMemoryCache 一样,通过注入,调用该接口即可,如果你还是不清楚的话,先往下看吧,应该也能看懂。
添加 nuget 包:MediatR
注意:我这里把包安装到了Christ3D.Domain.Core 核心领域层了,因为还记得上边的那个大图么,我说到的,一条贯穿项目的线,所以这个中介处理程序接口在其他地方也用的到(比如领域层),所以我在核心领域层,安装了这个nuget包。注意安装包后,需要编译下当前项目。
实现我们的中介处理程序接口
更新:我放到了基础设施层了,新建一个Bus文件夹
namespace Christ3D.Infra.Bus { /// <summary> /// 一个密封类,实现我们的中介记忆总线 /// </summary> public sealed class InMemoryBus : IMediatorHandler { //构造函数注入 private readonly IMediator _mediator; public InMemoryBus(IMediator mediator) { _mediator = mediator; } /// <summary> /// 实现我们在IMediatorHandler中定义的接口 /// 没有返回值 /// </summary> /// <typeparam name="T"></typeparam> /// <param name="command"></param> /// <returns></returns> public Task SendCommand<T>(T command) where T : Command { return _mediator.Send(command);//这里要注意下 command 对象 } } }
这个send方法,就是我们的中介者来替代对象,进行命令的分发,这个时候你可以会发现报错了,我们F12看看这个方法:
可以看到 send 方法的入参,必须是MediarR指定的 IRequest 对象,所以,我们需要给我们的 Command命令基类,再继承一个抽象类:
这个时候,我们的中介总线就搞定了。
删除命令模型在Controller中的使用
1、把领域命令模型 从 controller 中去掉
只需要一个service调用即可
这个时候我们文字开头的第一个问题就出现了,我们先把 Controller 中的命令模型验证去掉,然后在我们的应用层 Service 中调用,这里先看看文章开头的第二个问题方法(当然是不对的方法):
public void Register(StudentViewModel StudentViewModel) { RegisterStudentCommand registerStudentCommand = new RegisterStudentCommand(studentViewMod.........ewModel.Phone); //如果命令无效,证明有错误 if (!registerStudentCommand.IsValid()) { List<string> errorInfo = new List<string>(); //获取到错误,请思考这个Result从哪里来的 //..... //对错误进行记录,还需要抛给前台 ViewBag.ErrorData = errorInfo; } _StudentRepository.Add(_mapper.Map<Student>(StudentViewModel)); _StudentRepository.SaveChanges(); }
且不说这里边语法各种有问题(比如不能用 ViewBag ,当然你可能会说用缓存),单单从整体设计上就很不舒服,这样仅仅是从api接口层,挪到了应用服务层,这一块明明是业务逻辑,业务逻辑就是领域问题,应该放到领域层。
而且还有文章说到的第四个问题,这里也没有解决,就是这里依然有领域模型 Student ,没有实现命令模型、领域模型等的交互通讯。
说到这里,你可能脑子里有了一个大胆的想法,还记得上边说的中介者模式么,就是很好的实现了多个对象之间的通讯,还不破坏各自的内部逻辑,使他们只关心自己的业务逻辑,那具体如果使用呢,请往下看。
在 StudentAppService 服务中,调用中介处理接口
通过构造函数注入我们的中介处理接口,这个大家应该都会了吧
//注意这里是要IoC依赖注入的,还没有实现 private readonly IStudentRepository _StudentRepository; //用来进行DTO private readonly IMapper _mapper; //中介者 总线 private readonly IMediatorHandler Bus; public StudentAppService( IStudentRepository StudentRepository, IMediatorHandler bus, IMapper mapper ) { _StudentRepository = StudentRepository; _mapper = mapper; Bus = bus; }
然后修改服务方法
public void Register(StudentViewModel StudentViewModel) { //这里引入领域设计中的写命令 还没有实现 //请注意这里如果是平时的写法,必须要引入Student领域模型,会造成污染 //_StudentRepository.Add(_mapper.Map<Student>(StudentViewModel)); //_StudentRepository.SaveChanges(); var registerCommand = _mapper.Map<RegisterStudentCommand>(StudentViewModel); Bus.SendCommand(registerCommand); }
最后记得要对服务进行注入,这里有两个点
1、ConfigureServices 中添加 MediatR 服务
// Adding MediatR for Domain Events // 领域命令、领域事件等注入 // 引用包 MediatR.Extensions.Microsoft.DependencyInjection services.AddMediatR(typeof(Startup));
2、在我们的 NativeInjectorBootStrapper.cs 依赖注入文件中,注入我们的中介总线接口
services.AddScoped<IMediatorHandler, InMemoryBus>();
老张说:这里的注入,就是指,每当我们访问 IMediatorHandler 处理程序的时候,就是实例化 InmemoryBus 对象。
到了这里,我们才完成了第一步,命令总线的定义,也就是中介处理接口的定义与使用,那具体是如何进行分发的呢,我们又是如何进行数据持久化,保存数据的呢?请往下看,我们先说下工作单元。
命令总线分发
添加一个命令处理程序基类 CommandHandler.cs
namespace Christ3D.Domain.CommandHandlers { /// <summary> /// 领域命令处理程序 /// 用来作为全部处理程序的基类,提供公共方法和接口数据 /// </summary> public class CommandHandler { // 注入工作单元 private readonly IUnitOfWork _uow; // 注入中介处理接口(目前用不到,在领域事件中用来发布事件) private readonly IMediatorHandler _bus; // 注入缓存,用来存储错误信息(目前是错误方法,以后用领域通知替换) private IMemoryCache _cache; /// <summary> /// 构造函数注入 /// </summary> /// <param name="uow"></param> /// <param name="bus"></param> /// <param name="cache"></param> public CommandHandler(IUnitOfWork uow, IMediatorHandler bus, IMemoryCache cache) { _uow = uow; _bus = bus; _cache = cache; } //工作单元提交 //如果有错误,下一步会在这里添加领域通知 public bool Commit() { if (_uow.Commit()) return true; return false; } } }
这个还是很简单的,只是提供了一个工作单元的提交,下边会增加对领域通知的伪处理。
定义学生命令处理程序 StudentCommandHandler.cs
namespace Christ3D.Domain.CommandHandlers { /// <summary> /// Student命令处理程序 /// 用来处理该Student下的所有命令 /// 注意必须要继承接口IRequestHandler<,>,这样才能实现各个命令的Handle方法 /// </summary> public class StudentCommandHandler : CommandHandler, IRequestHandler<RegisterStudentCommand, Unit>, IRequestHandler<UpdateStudentCommand, Unit>, IRequestHandler<RemoveStudentCommand, Unit> { // 注入仓储接口 private readonly IStudentRepository _studentRepository; // 注入总线 private readonly IMediatorHandler Bus; private IMemoryCache Cache; /// <summary> /// 构造函数注入 /// </summary> /// <param name="studentRepository"></param> /// <param name="uow"></param> /// <param name="bus"></param> /// <param name="cache"></param> public StudentCommandHandler(IStudentRepository studentRepository, IUnitOfWork uow, IMediatorHandler bus, IMemoryCache cache ) : base(uow, bus, cache) { _studentRepository = studentRepository; Bus = bus; Cache = cache; } // RegisterStudentCommand命令的处理程序 // 整个命令处理程序的核心都在这里 // 不仅包括命令验证的收集,持久化,还有领域事件和通知的添加 public Task<Unit> Handle(RegisterStudentCommand message, CancellationToken cancellationToken) { // 命令验证 if (!message.IsValid()) { // 错误信息收集 NotifyValidationErrors(message); return Task.FromResult(new Unit()); } // 实例化领域模型,这里才真正的用到了领域模型 // 注意这里是通过构造函数方法实现 var customer = new Student(Guid.NewGuid(), message.Name, message.Email, message.Phone, message.BirthDate); // 判断邮箱是否存在 // 这些业务逻辑,当然要在领域层中(领域命令处理程序中)进行处理 if (_studentRepository.GetByEmail(customer.Email) != null) { //这里对错误信息进行发布,目前采用缓存形式 List<string> errorInfo = new List<string>() { "The customer e-mail has already been taken." }; Cache.Set("ErrorData", errorInfo); return Task.FromResult(new Unit()); } // 持久化 _studentRepository.Add(customer); // 统一提交 if (Commit()) { // 提交成功后,这里需要发布领域事件 // 比如欢迎用户注册邮件呀,短信呀等 // waiting.... } return Task.FromResult(new Unit()); } // 同上,UpdateStudentCommand 的处理方法 public Task<Unit> Handle(UpdateStudentCommand message, CancellationToken cancellationToken) { // 省略... } // 同上,RemoveStudentCommand 的处理方法 public Task<Unit> Handle(RemoveStudentCommand message, CancellationToken cancellationToken) { // 省略... } // 手动回收 public void Dispose() { _studentRepository.Dispose(); } } }
注入我们的处理程序
在我们的IoC项目中,注入我们的命令处理程序,这个时候,你可能有疑问,为啥是这样的,下边我讲原理的时候会说明。
// Domain - Commands services.AddScoped<IRequestHandler<RegisterStudentCommand, Unit>, StudentCommandHandler>(); services.AddScoped<IRequestHandler<UpdateStudentCommand, Unit>, StudentCommandHandler>(); services.AddScoped<IRequestHandler<RemoveStudentCommand, Unit>, StudentCommandHandler>()
好啦!这个时候我们已经成功的,顺利的,把由中介总线发出的命令,借助中介者 MediatR ,通过一个个处理程序,把我们的所有命令模型,领域模型,验证模型,当然还有以后的领域事件,和领域通知联系在一起了,只有上边两个类,甚至说只需要一个 StudentCommandHandler.cs 就能搞定,因为另一个 CommandHandler 仅仅是一个基类,完全可以合并在 StudentCommandHandler 类里,是不是感觉很神奇,如果这个时候你没有感觉到他的好处,请先停下往下看的眼睛,仔细思考一下,如果我们不采用这个方法,我们会是怎么的工作:
在 API 层的controller中,进行参数验证,然后if else 判断,
接下来在服务器中写持久化,然后也要对持久化中的错误信息,返回到 API 层;
不仅如此,我们还需要提交成功后,进行发邮件,或者发短信等子业务逻辑(当然这一块,咱们还没实现,不过已经挖好了坑,下一节会说到。);
最后,我们可能以后会说,添加成功和删除成功发的邮件方法不一样,甚至还有其他;
现在想想,如果这样的工作,我们的业务逻辑需要写在哪里?毫无疑问的,当然是在API层和应用层,我们领域层都干了什么?只有简单的一个领域模型和仓储接口!那这可真的不是DDD领域驱动设计的第二个D —— 驱动。
但是现在我们采用中介者模式,用命令驱动的方法,情况就不是这样了,我们在API 层的controller中,只有一行代码,在应用服务层也只有两行;
var registerCommand = _mapper.Map<RegisterStudentCommand>(StudentViewModel);
Bus.SendCommand(registerCommand);
到这个时候,我们已经从根本上,第二次了解了DDD领域驱动设计所带来的不一样的快感(第一次是领域、聚合、值对象等相关概念)。当然可能还不是很透彻,至少我们已经通过第一条总线——命令总线,来实现了复杂多模型直接的通讯了,下一篇我们说领域事件的时候,你会更清晰。那聪明的你一定就会问了:
好吧,你说的这些我懂了,也大概知道了怎么用,那它们是如何运行的呢?不知道过程,反而无法理解其作用!没错,那接下来,我们就具体说一说这个命令是如何分发的,请耐心往下看。
用缓存来记录错误通知
这里仅仅是一个小小的乱入补充,上边已经把流程调通了,如果你想看看什么效果,这里就出现了一个问题,我们的错误通知信息没有办法获取,因为之前我们用的是ViewBag,这里无效,当然Session等都无效了,因为我们是在整个项目的多个类库之间使用,只能用 Memory 缓存了。
a、命令处理程序基类CommandHandler 中,添加公共方法
//将领域命令中的验证错误信息收集 //目前用的是缓存方法(以后通过领域通知替换) protected void NotifyValidationErrors(Command message) { List<string> errorInfo = new List<string>(); foreach (var error in message.ValidationResult.Errors) { errorInfo.Add(error.ErrorMessage); } //将错误信息收集 _cache.Set("ErrorData", errorInfo); }
b、在Student命令处理程序中调用
C、自定义视图模型中加载
/// <summary> /// Alerts 视图组件 /// 可以异步,也可以同步,注意方法名称,同步的时候是Invoke /// 我写异步是为了为以后做准备 /// </summary> /// <returns></returns> public async Task<IViewComponentResult> InvokeAsync() { // 获取到缓存中的错误信息 var errorData = _cache.Get("ErrorData"); var notificacoes = await Task.Run(() => (List<string>)errorData); // 遍历添加到ViewData.ModelState 中 notificacoes?.ForEach(c => ViewData.ModelState.AddModelError(string.Empty, c)); return View(); }
这都是很简单,就不多说了,下一讲的领域事件,再好好说吧。
这个时候记得要在API的controller中,每次把缓存清空。
D、效果浏览
整体流程就是这样:
事件模型
可能这句话不是很好理解,那说人话就是:我们之前每一个领域模型都会有不同的命令,那每一个命令执行完成,都会有对应的后续事件(比如注册和删除用户肯定是不一样的),当然这个是看具体的业务而定,就比如我们的订单领域模型,主要的有下单、取消订单、删除订单等。
我个人感觉,每一个命令模型都会有对应的事件模型,而且一个命令处理方法可能有多个事件方法。具体的请看:
定义领域事件标识基类
就如上边我们说到的,我们可以定义一个接口,也可以定义一个抽象类,我比较习惯用抽象类,在核心领域层 Christ3D.Domain.Core 中的Events 文件夹中,新建Event.cs 事件基类:
namespace Christ3D.Domain.Core.Events { /// <summary> /// 事件模型 抽象基类,继承 INotification /// 也就是说,拥有中介者模式中的 发布/订阅模式 /// </summary> public abstract class Event : INotification { // 时间戳 public DateTime Timestamp { get; private set; } // 每一个事件都是有状态的 protected Event() { Timestamp = DateTime.Now; } } }
定义添加Student 的事件模型
当然还会有删除和更新的事件模型,这里就用添加作为栗子,在领域层 Christ3D.Domain 中,新建 Events 文件夹,用来存放我们所有的事件模型,
因为是 Student 模型,所以我们在 Events 文件夹下,新建 Student 文件夹,并新建 StudentRegisteredEvent.cs 学生添加事件类:
namespace Christ3D.Domain.Events { /// <summary> /// Student被添加后引发事件 /// 继承事件基类标识 /// </summary> public class StudentRegisteredEvent : Event { // 构造函数初始化,整体事件是一个值对象 public StudentRegisteredEvent(Guid id, string name, string email, DateTime birthDate, string phone) { Id = id; Name = name; Email = email; BirthDate = birthDate; Phone = phone; } public Guid Id { get; set; } public string Name { get; private set; } public string Email { get; private set; } public DateTime BirthDate { get; private set; } public string Phone { get; private set; } } }
事件总线分发
定义事件总线接口
在中介处理接口IMediatorHandler中,定义引发事件接口,作为发布者,完整的 IMediatorHandler.cs 应该是这样的
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; /// <summary> /// 引发事件,通过总线,发布事件 /// </summary> /// <typeparam name="T"> 泛型 继承 Event:INotification</typeparam> /// <param name="event"> 事件模型,比如StudentRegisteredEvent,</param> /// 请注意一个细节:这个命名方法和Command不一样,一个是RegisterStudentCommand注册学生命令之前,一个是StudentRegisteredEvent学生被注册事件之后 /// <returns></returns> Task RaiseEvent<T>(T @event) where T : Event; } }
实现总线分发接口
在基层设施总线层的记忆总线 InMemoryBus.cs 中,实现我们上边的事件分发总线接口:
/// <summary> /// 引发事件的实现方法 /// </summary> /// <typeparam name="T">泛型 继承 Event:INotification</typeparam> /// <param name="event">事件模型,比如StudentRegisteredEvent</param> /// <returns></returns> public Task RaiseEvent<T>(T @event) where T : Event { // MediatR中介者模式中的第二种方法,发布/订阅模式 return _mediator.Publish(@event); }
注意这里使用的是中介模式的第二种——发布/订阅模式,想必这个时候就不用给大家解释为什么要使用这个模式了吧(提示:不需要对请求进行必要的响应,与请求/响应模式做对比思考)。现在我们把事件总线定义(是一个发布者)好了,下一步就是如何定义事件模型和处理程序了也就是订阅者,如果上边的都看懂了,请继续往下走。
定义领域事件的处理程序Handler
这个和我们的命令处理程序一样,只不过我们的命令处理程序是总线在应用服务层分发的,而事件处理程序是在领域层的命令处理程序中被总线引发的,可能有点儿拗口,看看下边代码就清楚了,就是一个引用场景的顺序问题。
在领域层Chirst3D.Domain 中,新建 EventHandlers 文件夹,用来存放我们的事件处理程序,然后新建 Student事件模型的处理程序 StudentEventHandler.cs:
namespace Christ3D.Domain.EventHandlers { /// <summary> /// Student事件处理程序 /// 继承INotificationHandler<T>,可以同时处理多个不同的事件模型 /// </summary> public class StudentEventHandler : INotificationHandler<StudentRegisteredEvent>, INotificationHandler<StudentUpdatedEvent>, INotificationHandler<StudentRemovedEvent> { // 学习被注册成功后的事件处理方法 public Task Handle(StudentRegisteredEvent message, CancellationToken cancellationToken) { // 恭喜您,注册成功,欢迎加入我们。 return Task.CompletedTask; } // 学生被修改成功后的事件处理方法 public Task Handle(StudentUpdatedEvent message, CancellationToken cancellationToken) { // 恭喜您,更新成功,请牢记修改后的信息。 return Task.CompletedTask; } // 学习被删除后的事件处理方法 public Task Handle(StudentRemovedEvent message, CancellationToken cancellationToken) { // 您已经删除成功啦,记得以后常来看看。 return Task.CompletedTask; } } }
相信大家应该都能看的明白,在上边的注释已经很清晰的表达了响应的作用,如果有看不懂,咱们可以一起交流。
好啦,现在第二步已经完成,剩下最后一步:如何通过事件总线分发我们的事件模型了。
在事件总线EventBus中引发事件
这个使用起来很简单,主要是我们在命令处理程序中,处理完了持久化以后,接下来调用我们的事件总线,对不同的事件模型进行分发,就比如我们的 添加Student 命令处理程序方法中,我们通过工作单元添加成功后,需要做下一步,比如发邮件,那我们就需要这么做。
在命令处理程序 StudentCommandHandler.cs 中,完善我们的提交成功的处理:
// 持久化 _studentRepository.Add(customer); // 统一提交 if (Commit()) { // 提交成功后,这里需要发布领域事件 // 比如欢迎用户注册邮件呀,短信呀等 Bus.RaiseEvent(new StudentRegisteredEvent(customer.Id, customer.Name, customer.Email, customer.BirthDate,customer.Phone)); }
这样就很简单的将我们的事件模型分发到了事件总线中去了,这个时候记得要在 IoC 原生注入类NativeInjectorBootStrapper中,进行注入。关于触发过程下边我简单说一下。
4、整体事件驱动执行过程
说到了这里,你可能发现和命令总线很相似,也可能不是很懂,简单来说,整体流程是这样的:
1、首先我们在命令处理程序中调用事件总线来引发事件 Bus.RaiseEvent(........);
2、然后在Bus中,将我们的事件模型进行包装成固定的格式 _mediator.Publish(@event);
3、然后通过注入的方法,将包装后的事件模型与事件处理程序进行匹配,系统执行事件模型,就自动实例化事件处理程序 StudentEventHandler;
4、最后执行我们Handler 中各自的处理方法 Task Handle(StudentRegisteredEvent message)。
希望正好也温习下命令总线的执行过程。
依赖注入事件模型和处理程序
// Domain - Events
// 将事件模型和事件处理程序匹配注入
services.AddScoped<INotificationHandler<StudentRegisteredEvent>, StudentEventHandler>();
services.AddScoped<INotificationHandler<StudentUpdatedEvent>, StudentEventHandler>();
services.AddScoped<INotificationHandler<StudentRemovedEvent>, StudentEventHandler>();
领域通知
领域通知模型 DomainNotification
这个通知模型,就像是一个消息队列一样,在我们的内存中,通过通知处理程序进行发布和使用,有自己的生命周期,当被访问并调用完成的时候,会手动对其进行回收,以保证数据的完整性和一致性,这个就很好的解决了咱们之前用Memory缓存通知信息的弊端。
在我们的核心领域层 Christ3D.Domain.Core 中,新建文件夹 Notifications ,然后添加领域通知模型 DomainNotification.cs:
namespace Christ3D.Domain.Core.Notifications { /// <summary> /// 领域通知模型,用来获取当前总线中出现的通知信息 /// 继承自领域事件和 INotification(也就意味着可以拥有中介的发布/订阅模式) /// </summary> public class DomainNotification : Event { // 标识 public Guid DomainNotificationId { get; private set; } // 键(可以根据这个key,获取当前key下的全部通知信息) // 这个我们在事件源和事件回溯的时候会用到,伏笔 public string Key { get; private set; } // 值(与key对应) public string Value { get; private set; } // 版本信息 public int Version { get; private set; } public DomainNotification(string key, string value) { DomainNotificationId = Guid.NewGuid(); Version = 1; Key = key; Value = value; } } }
领域通知处理程序 DomainNotificationHandler
该处理程序,可以理解成,就像一个类的管理工具,在每次对象生命周期内 ,对领域通知进行实例化,获取值,手动回收,这样保证了每次访问的都是当前实例的数据。
还是在文件夹 Notifications 下,新建处理程序 DomainNotificationHandler.cs:
namespace Christ3D.Domain.Core.Notifications { /// <summary> /// 领域通知处理程序,把所有的通知信息放到事件总线中 /// 继承 INotificationHandler<T> /// </summary> public class DomainNotificationHandler : INotificationHandler<DomainNotification> { // 通知信息列表 private List<DomainNotification> _notifications; // 每次访问该处理程序的时候,实例化一个空集合 public DomainNotificationHandler() { _notifications = new List<DomainNotification>(); } // 处理方法,把全部的通知信息,添加到内存里 public Task Handle(DomainNotification message, CancellationToken cancellationToken) { _notifications.Add(message); return Task.CompletedTask; } // 获取当前生命周期内的全部通知信息 public virtual List<DomainNotification> GetNotifications() { return _notifications; } // 判断在当前总线对象周期中,是否存在通知信息 public virtual bool HasNotifications() { return GetNotifications().Any(); } // 手动回收(清空通知) public void Dispose() { _notifications = new List<DomainNotification>(); } } }
到了目前为止,我们的DDD领域驱动设计中的核心领域层部分,已经基本完成了(还剩下下一篇的事件源、事件回溯):
在命令处理程序中发布通知
我们定义好了领域通知的处理程序,我们就可以像上边的发布事件一样,来发布我们的通知信息了。这里用一个栗子来试试:
在学习命令处理程序 StudentCommandHandler.cs 中的 RegisterStudentCommand 处理方法中,完善:
// 判断邮箱是否存在 // 这些业务逻辑,当然要在领域层中(领域命令处理程序中)进行处理 if (_studentRepository.GetByEmail(customer.Email) != null) { ////这里对错误信息进行发布,目前采用缓存形式 //List<string> errorInfo = new List<string>() { "该邮箱已经被使用!" }; //Cache.Set("ErrorData", errorInfo); //引发错误事件 Bus.RaiseEvent(new DomainNotification("", "该邮箱已经被使用!")); return Task.FromResult(new Unit()); }
这个时候,我们把错误通知信息在事件总线中发布出去,剩下的就是需要在别的任何地方订阅即可,还记得哪里么,没错就是我们的自定义视图组件中,我们需要订阅通知信息,展示在页面里。
注意:我们还要修改一下之前我们的命令处理程序基类 CommandHandler.cs 的验证信息收集方法,因为之前是用缓存来实现的,我们这里也用发布事件来实现:
//将领域命令中的验证错误信息收集 //目前用的是缓存方法(以后通过领域通知替换) protected void NotifyValidationErrors(Command message) { List<string> errorInfo = new List<string>(); foreach (var error in message.ValidationResult.Errors) { //errorInfo.Add(error.ErrorMessage); //将错误信息提交到事件总线,派发出去 _bus.RaiseEvent(new DomainNotification("", error.ErrorMessage)); } //将错误信息收集一:缓存方法(错误示范) //_cache.Set("ErrorData", errorInfo); }
在视图组件中获取通知信息
这个很简单,之前我们用的是注入 IMemory 的方式,在缓存中获取,现在我们通过注入领域通知处理程序来实现,在视图组件 AlertsViewComponent.cs 中:
public class AlertsViewComponent : ViewComponent { // 缓存注入,为了收录信息(错误方法,以后会用通知,通过领域事件来替换) // private IMemoryCache _cache; // 领域通知处理程序 private readonly DomainNotificationHandler _notifications; // 构造函数注入 public AlertsViewComponent(INotificationHandler<DomainNotification> notifications) { _notifications = (DomainNotificationHandler)notifications; } /// <summary> /// Alerts 视图组件 /// 可以异步,也可以同步,注意方法名称,同步的时候是Invoke /// 我写异步是为了为以后做准备 /// </summary> /// <returns></returns> public async Task<IViewComponentResult> InvokeAsync() { // 从通知处理程序中,获取全部通知信息,并返回给前台 var notificacoes = await Task.FromResult((_notifications.GetNotifications())); notificacoes.ForEach(c => ViewData.ModelState.AddModelError(string.Empty, c.Value)); return View(); } }
StudentController 判断是否有通知信息
通过注入的方式,把 INotificationHandler<DomainNotification> 注入控制器,然后因为这个接口可以实例化多个对象,那我们就强类型转换成 DomainNotificationHandler:
这里要说明下,记得要对事件处理程序注入,才能使用:
// 将事件模型和事件处理程序匹配注入
services.AddScoped<INotificationHandler<DomainNotification>, DomainNotificationHandler>();
事件溯源
事件到底如何影响一个领域对象的状态的呢?很简单,当我们在触发某个领域对象的某个行为时,该领域对象会先产生一个事件,然后该对象自己响应该事件并更新其自己的状态,同时我们还会持久化在该对象上所发生的每一个事件;这样当我们要重新得到该对象的最新状态时,只要先创建一个空的对象,然后将和该对象相关的所有事件按照事件发生先后顺序从先到后再全部应用一遍即可还原得到该对象的最新状态,这个过程就是所谓的事件溯源。
大家请注意,下边的这一个流程,就和我们平时开发的顺序是一样的,比如先建立模型,然后仓储层,然后应用服务层,最后是调用的过程,东西虽然很多,但是很简单,慢慢看都能看懂。
同时也复习下我们DDD领域驱动设计是如何搭建环境的,正好在最后一篇和第一篇遥相呼应。
创建事件存储模型 StoredEvent : Event
那既然说到了事件溯源,我们就需要首先把事件存储下来,那存下来之前,首先要进行建模:
在核心应用层 Christ3D.Domain.Core 的 Events文件夹下,新建 Message.cs 用来获取我们事件请求的类型:
namespace Christ3D.Domain.Core.Events { /// <summary> /// 抽象类Message,用来获取我们事件执行过程中的类名 /// 然后并且添加聚合根 /// </summary> public abstract class Message : IRequest { public string MessageType { get; protected set; } public Guid AggregateId { get; protected set; } protected Message() { MessageType = GetType().Name; } } }
同时在该文件夹下,新建 存储事件 模型StoredEvent.cs
public class StoredEvent : Event { /// <summary> /// 构造方式实例化 /// </summary> /// <param name="theEvent"></param> /// <param name="data"></param> /// <param name="user"></param> public StoredEvent(Event theEvent, string data, string user) { Id = Guid.NewGuid(); AggregateId = theEvent.AggregateId; MessageType = theEvent.MessageType; Data = data; User = user; } // 为了EFCore能正确CodeFirst protected StoredEvent() { } // 事件存储Id public Guid Id { get; private set; } // 存储的数据 public string Data { get; private set; } // 用户信息 public string User { get; private set; } }
定义事件存储上下文 EventStoreSQLContext
定义好了模型,那我们接下来就是要建立数据库上下文了:
1、首先在基础设施数据层 Christ3D.Infrastruct.Data 下的 Mappings文件夹下,建立事件存储Map模型 StoredEventMap.cs
namespace Christ3D.Infra.Data.Mappings { /// <summary> /// 事件存储模型Map /// </summary> public class StoredEventMap : IEntityTypeConfiguration<StoredEvent> { public void Configure(EntityTypeBuilder<StoredEvent> builder) { builder.Property(c => c.Timestamp) .HasColumnName("CreationDate"); builder.Property(c => c.MessageType) .HasColumnName("Action") .HasColumnType("varchar(100)"); } } }
2、然后再上下文文件夹 Context 下,新建事件存储Sql上下文 EventStoreSQLContext.cs
namespace Christ3D.Infra.Data.Context { /// <summary> /// 事件存储数据库上下文,继承 DbContext /// /// </summary> public class EventStoreSQLContext : DbContext { // 事件存储模型 public DbSet<StoredEvent> StoredEvent { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ApplyConfiguration(new StoredEventMap()); base.OnModelCreating(modelBuilder); } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { // 获取链接字符串 var config = new ConfigurationBuilder() .SetBasePath(Directory.GetCurrentDirectory()) .AddJsonFile("appsettings.json") .Build(); // 使用默认的sql数据库连接 optionsBuilder.UseSqlServer(config.GetConnectionString("DefaultConnection")); } } }
这里要说明下,因为已经创建了两个上下文,以后迁移的时候,就要加上 上下文名称 了:
持久化事件仓储 EventStoreSQLRepository : IEventStoreRepository
上边咱们定义了用于持久化事件模型的上下文,那么现在我们就需要设计仓储操作类了
1、在 基础设施数据层中的 Repository 文件夹下,定义事件存储仓储接口 IEventStoreRepository.cs
namespace Christ3D.Infra.Data.Repository.EventSourcing { /// <summary> /// 事件存储仓储接口 /// 继承IDisposable ,可手动回收 /// </summary> public interface IEventStoreRepository : IDisposable { void Store(StoredEvent theEvent); IList<StoredEvent> All(Guid aggregateId); } }
2、然后对上边的接口进行实现
namespace Christ3D.Infra.Data.Repository.EventSourcing { /// <summary> /// 事件仓储数据库仓储实现类 /// </summary> public class EventStoreSQLRepository : IEventStoreRepository { // 注入事件存储数据库上下文 private readonly EventStoreSQLContext _context; public EventStoreSQLRepository(EventStoreSQLContext context) { _context = context; } /// <summary> /// 根据聚合id 获取全部的事件 /// 这个聚合是指领域模型的聚合根模型 /// </summary> /// <param name="aggregateId"> 聚合根id 比如:订单模型id</param> /// <returns></returns> public IList<StoredEvent> All(Guid aggregateId) { return (from e in _context.StoredEvent where e.AggregateId == aggregateId select e).ToList(); } /// <summary> /// 将命令事件持久化 /// </summary> /// <param name="theEvent"></param> public void Store(StoredEvent theEvent) { _context.StoredEvent.Add(theEvent); _context.SaveChanges(); } /// <summary> /// 手动回收 /// </summary> public void Dispose() { _context.Dispose(); } } }
这个时候,我们的事件存储模型、上下文和仓储层已经建立好了,也就是说我们可以对我们的事件模型进行持久化了,接下来就是在建立服务了,用来调用仓储的服务,就好像我们的应用服务层的概念。
建立事件存储服务 SqlEventStoreService: IEventStoreService
建完了基础设施层,那我们接下来就需要建立服务层了,并对其进行调用: 1、还是在核心领域层中的Events文件夹下,建立接口
namespace Christ3D.Domain.Core.Events { /// <summary> /// 领域存储服务接口 /// </summary> public interface IEventStoreService { /// <summary> /// 将命令模型进行保存 /// </summary> /// <typeparam name="T"> 泛型:Event命令模型</typeparam> /// <param name="theEvent"></param> void Save<T>(T theEvent) where T : Event; } }
2、然后再来实现该接口
在应用层 Christ3D.Application 中,新建 EventSourcing 文件夹,用来对我们的事件存储进行溯源,然后新建 事件存储服务类 SqlEventStoreService.cs
namespace Christ3D.Infra.Data.EventSourcing { /// <summary> /// 事件存储服务类 /// </summary> public class SqlEventStoreService : IEventStoreService { // 注入我们的仓储接口 private readonly IEventStoreRepository _eventStoreRepository; public SqlEventStoreService(IEventStoreRepository eventStoreRepository) { _eventStoreRepository = eventStoreRepository; } /// <summary> /// 保存事件模型统一方法 /// </summary> /// <typeparam name="T"></typeparam> /// <param name="theEvent"></param> public void Save<T>(T theEvent) where T : Event { // 对事件模型序列化 var serializedData = JsonConvert.SerializeObject(theEvent); var storedEvent = new StoredEvent( theEvent, serializedData, "Laozhang"); _eventStoreRepository.Store(storedEvent); } } }
这个时候你会问了,那我们现在都写好了,在哪里使用呢,欸?!聪明,既然是事件存储,那就是在事件保存的时候,进行存储,请往下看。
在总线中发布事件的同时,对事件保存 Task RaiseEvent<T>
/// <summary> /// 引发事件的实现方法 /// </summary> /// <typeparam name="T">泛型 继承 Event:INotification</typeparam> /// <param name="event">事件模型,比如StudentRegisteredEvent</param> /// <returns></returns> public Task RaiseEvent<T>(T @event) where T : Event { // 除了领域通知以外的事件都保存下来 if (!@event.MessageType.Equals("DomainNotification")) _eventStoreService?.Save(@event); // MediatR中介者模式中的第二种方法,发布/订阅模式 return _mediator.Publish(@event); }
付费内容,请联系本人QQ:1002453261
本文来自博客园,作者:明志德道,转载请注明原文链接:https://www.cnblogs.com/for-easy-fast/p/13493925.html