概念
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); }