從壹開始微服務 [ DDD ] 之四 ║讓你明白DDD的小故事 & EFCore初探
緣起
哈嘍大家好喲,今天又到了老張的週二四放送時間了,當然中間還有不定期的更新(因為個人看papi醬看多了),這個主要是針對小夥伴提出的問題和優秀解決方案而寫的,經過上週兩篇DDD領域驅動設計的試水,我發現一個問題,這個DDD的水是真的深啊~或者來說就是這個思想的轉變是不舒服的,好多小夥伴就說有點兒轉不過來,當然我也是,一直站在原地追著影子跑,當然這個系列我會一直堅持下去的,大家如果感覺我寫的沒有誤人子弟或者感覺看著還有點兒意思,請不要著急,多多評論,我雖然沒有更新,但是也一直線上,提出來的問題可以一起討論,週末的時候,我又和“李大爺”一起從非專業的角度,從領域專家的角度思考了下DDD領域驅動設計的思想,感覺還有點兒領悟的,這裡給大家分享下,如果你現在還對為什麼使用DDD,或者還有DDD就像是一個三層架構或者MVC架構的想法的話,看完這一篇應該就能稍微的明白了。
很開森的是上週的問題大家評論很好,也上了24小時評論榜單,希望大家都可以多評論評論:grinning:,真的很精彩,大家可以再去看看《 ofollow,noindex" target="_blank">二 ║ DDD入門 & 專案結構粗搭建 》,評論席的內容的含金量,甚至都超過了我的正文內容,而且也能滿足老張小小的虛榮感,今天呢,我就先在本文的上半篇重點說一下大家最最最熱心的兩個問題,然後再繼續推進咱們的專案程式碼,就主要從以下三個大塊鋪開來說:
1、DDD的意義到底在哪裡?為什麼很難理解? 2、為什麼要使用倉儲,EFCore不就是一個倉儲麼? 3、限界上下文如何定義呢?包含了平時遇到的哪些東西?
悄悄說:經過週末的討論,我發現上次咱們新建的那個關於 Customer 領域物件不好舉例子,問答程式說起來也不是很順口,所以我已經修改成了 Student 模型,然後我也想到了一個領域——教務系統,這個大家一定是熟悉的不能再熟悉了,每個小夥伴都是經過上學的噩夢裡過來的(哈哈學霸就另說了),以後咱們就用這個教務領域來展開說明,大家也能都在一條思路上,而且也不會花心思去考慮問答系統這個不熟悉的領域。
零、今天要完成 紫色 的部分
一、舉一個DDD的小栗子 —— 大意義
關於DDD的使用,網上已經有很多的栗子了,無論是各種貼上複製的教科書,還是自我的一些心得,基本已經說完了,不過我每次讀的時候,心裡都是有點兒抗拒,一直都沒辦法看懂,今天我就決定用另一個辦法,來和大家好好說一下這個DDD領域驅動設計的意義到底在哪裡。這個時候請你自己先想一想,如果使用DDD會有哪些好處,如果說看完了我寫的,感覺有共鳴,那很不錯,要是感覺我寫的認為不對,歡迎評論席留下你的意見喲,開源嘛,不能讓我自己發表看法的,也讓我的部落格可以多在大家的面前展現下哈哈。
故事就從這裡開始:咱們有一個學校,就叫從壹大學(我瞎起的名字哈哈),我們從壹大學要開發一套教務系統,這個系統涵蓋了學校的方方面面,從德智體美勞都有,其中就有一個管理後臺,任何人都可以登陸進去,學習檢視自己的資訊和成績等,老師可以選擇課程或者修改自己班級的學生的個人資訊的,現在就說其中的一個小栗子 —— 班主任更改學生的手機號。我們就用普通的寫法,就是我們平時在寫或者現在在用的流程來設計這個小方法。
請注意 :當前系統就是一個 領域 ,裡邊會有很多 子領域 ,這個大家應該都能動。
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和事件驅動架構來搭建專案了,它所解決的就是我們在上邊的小故事中提到的隨著業務的發展,困難值呈現指數增長的趨勢了。
二、一個安靜的資料管理員 —— 倉儲
這裡就簡單的說兩句為什麼一直要使用倉儲,而不直接接通到 EFCore 上:
1、我們驅動設計的核心是什麼,就是最大化的解決專案中出現的痛點,上邊的小故事就是一個栗子,隨著技術的更新,面向介面開發同時也變的特別重要,無論是方便重構,還是方便IoC,依賴注入等等,都需要一個倉儲介面來實現這個目的。
2、倉儲還有一個重要的特徵就是分為倉儲定義部分和倉儲實現部分,在領域模型中我們定義倉儲的介面,而在基礎設施層實現具體的倉儲。
這樣做的原因是:由於倉儲背後的實現都是在和資料庫打交道,但是我們又不希望客戶(如應用層)把重點放在如何從資料庫獲取資料的問題上,因為這樣做會導致客戶(應用層)程式碼很混亂,很可能會因此而忽略了領域模型的存在。所以我們需要提供一個簡單明瞭的介面,供客戶使用,確保客戶能以最簡單的方式獲取領域物件,從而可以讓它專心的不會被什麼資料訪問程式碼打擾的情況下協調領域物件完成業務邏輯。這種通過介面來隔離封裝變化的做法其實很常見,我們需要什麼資料直接拿就行了,而不去管具體的操作邏輯。
3、由於客戶面對的是抽象的介面並不是具體的實現,所以我們可以隨時替換倉儲的真實實現,這很有助於我們做單元測試。
總結:現在隨著開發,越來越發現介面的好處,不僅僅是一個持久化層需要一層介面,小到一個快取類,或者日誌類,我們都需要一個介面的實現,就比如現在我就很喜歡用依賴注入的方式來開發,這樣可以極大的減少依賴,還有增大程式碼的可讀性。
三、建立我們第一個限界上下文
限界上下文已經說的很明白了,是從戰術技術上來解釋說明戰略中的領域概念,你想一下,我們如何在程式碼中直接體現領域的概念?當然沒辦法,領域是一個通過語言,領域專家和技術人員都能看懂的一套邏輯,而程式碼中的上下文才是實實在在的通過技術來實現。
大家可以在回頭看看上邊的那個故事栗子,下邊都一個“請注意”三個字,裡邊就是我們上下文中所包含的部分內容,其實限界上下文並沒有想象中的那麼複雜,我們只需要理解成是一個虛擬的邊界,把不屬於這個子領域的內容踢出去,對外解耦,但是內部通過聚合的。
0、在基礎設施層下新建一個 appsetting.json 配置檔案
用於我們的特定的資料庫連線,當然我們可以公用 api 層的配置檔案,這裡單獨拿出來,用於配合著下邊的EFCore,進行註冊。
{ "ConnectionStrings": { "DefaultConnection": "server=.;uid=sa;pwd=123;database=EDU" }, "Logging": { "IncludeScopes": false, "LogLevel": { "Default": "Debug", "System": "Information", "Microsoft": "Information" } } }
1、新建系統核心上下文
在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 ,還有以後說到的聚合,領域事件(上文中的修改手機號)等。
2、引入我們的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>
3、新增我們的實體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(); } }
4、用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); } }
5、完善實現應用層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() { return (_StudentRepository.GetAll()).ProjectTo<StudentViewModel>(); } 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); } } }
6、思考:這樣就是DDD領域驅動設計了麼
好啦,其實這個時候,我們的介面已經可以使用了,可能還有些注入呀,沒有實現,但是基本的邏輯就這麼施行了,你一定看著很熟悉,無論是DTO還是IOC/">IOC,無論是EFCore還是倉儲,一切都那麼熟悉,但是這就是DDD領域驅動設計麼,你一定要帶著這個問題好好想想。答案當然是否定的。
到這裡,我們的核心學習子領域的上下文的建立已經完成,請注意,這是上下文的定義建立完成,裡邊的核心內容還沒有說到。
當然,我們在完成應用層的呼叫後,直接就可以用了,這個時候的你可能會發現,到目前為止,咱們還是一個普通的寫法,和我們上個系列是一樣的,沒有體現出哪裡使用了領域驅動設計的思想,無非就是引用了EFCore和定義了一個上下文。
沒錯,你說的是對的,目前為止還沒有實現領域設計的核心,但是至少我們已經把領域給劃分出來了,而且你如何看明白了上邊的我說的內容,也應該有一定的想法了,明天咱們就重點說說 領域事件 和 聚合 的相關概念。
四、結語
今天重點重申了下DDD的意義,簡單說明了下倉儲的設計思想,然後也將我們的專案引入EFCore,並實現了介面等。這裡我要說明三點,看看大家讀完這篇文章的心情屬於哪一種:
1、入門:如果你看到我上邊的小故事,還對為什麼使用DDD而疑惑,那就請再仔細看看,好好想想。不要往下看,就看第一部分。
2、瞭解:如果你看懂了我說的第一部分的意思,並瞭解了使用領域驅動設計的意義,但是看下邊第三部分的程式碼結構又好像和平時的多層設計很像,而又去和多層對比,那麻煩請結合我的Git程式碼看看。
3、優秀:如果你明白了DDD的意義,並且很想了解我的架構到底是如何進行領域驅動的,恭喜你,已經成功了,剩下的時間我就會帶你去深入瞭解 中介者模式下的事件驅動——CQRS 。