烽煙
哈嘍大家周二好呀,咱們又見面了,上周末掐指一算,距離 聖誕節 只有 5 周的時間了(如果你還不知道為啥我要提聖誕節這個時間點,可以看看我的第二系列開篇《之一 ║ D3模式設計初探 與 我的計划書》),然后我簡單的思考了下這個DDD領域驅動設計還剩下的知識點,現在已經進入了第二部分,就是領域命令和領域驅動這一塊,第三部分包括Identity驗證和.net core api等設計點,大概就是剩了這么多,預計應該能在聖誕節前完成。還有一個就是,之前的八篇文章,已經比較完整的實現了普通框架的整體搭建,我也單獨的新建了一個 Git分支—— Framework8 ,如果你不想用領域命令、領域事件、事件回溯這些東西,僅僅就想要一個空的框架,一個包括 EFCore+Dtos+Automapper+IoC+Repository 的空框架(就比如我的第一個系列,就是一個普通的框架,請不要再說是這是一個普通三層了,拜托😂),你就可以直接用這個Framework8 分支即可。
言歸正傳,上次咱們說到了創建新student的時候,提出來一個問題,不知道大家是否還記得,這里再給大家說明一下,還是每篇一問,希望能好好思考下,或者是看看自己是如何設計的:
問題1:平時是如何進行表單驗證的(包括:判空、字符類型有效、業務驗證:成人不能小於18歲、金額不能小於0等)?
問題2:如果后來驗證變化了改怎么辦?(比如:手機號要支持全球,或者座機;亦或者退休年齡從60歲變成65歲;)
1、JavaScript前端驗證即可,后端從來不進行驗證?(問題2:修改js)
2、后端驗證:直接在Controller中,通過寫很多判斷邏輯,比如 If Else等,而且CURD還需要寫很多重復的判斷方法?(問題2:每一個地方都需要仔細修改,額。)
3、后端驗證:寫一個統一的驗證類,或者驗證機制,比如一個公共類?甚至更高級的AOP切面驗證?(問題2:好像還是無法滿足每個領域特例)
4、后端驗證:在DTO基礎上,基於領域命令,通過中介者Bus分發?(當然這個就是以后我要寫的)
其實說實話,前三種我都用過,甚至現在偶爾也還是會用,畢竟很平常的用法,但是現在我感覺第四種真的很整潔,真正的把整體項目放到了領域中,一切以領域為核心了 。這里我先把第四種的應用層 Service 方法簡單寫下,你就知道多么簡潔了,具體的會在下面兩篇文章中說到:
/// <summary> /// StudentAppService 添加新 Student /// </summary> public void Register(StudentViewModel studentViewModel) { //講視圖模型,轉換成命名模型 var registerCommand = _mapper.Map<RegisterStudentCommand>(studentViewModel); //通過Mediator處理程序分發命令 //執行順序:驗證 -> 通知 -> 注冊 Bus.SendCommand(registerCommand); }
老張說:這兩天我在研究,啃書的時候,發現了這個DDD領域驅動的整體流程,從前台數據傳遞視圖模型 ,到Dto的命令模型,然后對其校驗的命令驗證模式,最后還有總線分發,然后就是異常通知等等,就像是一場軍事戰斗中的過程:
這里說的命令是動作的意思,是用戶發出的一個請求(從前台向后端),當然你也可以理解是改領域模型下的命令動作(從內到外),還記得我們說到的讀寫分離CQRS么,就是Command。
每一個個小的戰役(領域模型),都會有自己戰場的一些信息和動作數據(視圖模型),當然這里有正常的消息,也有惡性攻擊或者不當的操作,每一個動作執行都是一個前鋒部隊(領域命令模型),先鋒部隊把這些數據打包,加上時間戳等標識,生成命令標簽,這個時候通過總線指揮官(中介者),交給參謀來處理數據命令(領域驗證),進行安全甄別,將正常的、正確的往下傳遞,傳給司令部(持久化),如果是惡性的錯誤信息,則通過通訊兵打包給前線(通知),每次前線執行操作,只需要看看是否有通訊兵是否有錯誤異常提醒,如果沒有則證明執行成功。
當然還有事件回溯和事件源,我會在以后文章說明,不知道這個栗子是否合理,如果大家看不懂也沒關系,或者請下邊留言,我們一起討論討論。
更新
有的小伙伴,可能看本文或者其他的概念的時候,比較懵懂,我這里根據自己的理解,簡單畫了個草圖,當然等系列結束的時候,還是有完整的,這里先來一個簡單的:
零、今天實現棕色的部分
一、領域命令Commands —— 領域模型的先鋒官
說到這個領域命令,大家肯定不會陌生,或者說應該是在哪里見過,沒錯!就是我們在上上一篇《七 ║項目第一次實現 & CQRS初探》中,說到的讀寫分離 CQRS 中的C —— Commend命令,這里我簡單說下,為什么叫先鋒官,我們把整個項目比作一個戰場的化,前端一直和后端進行交互 —— 表單提交,這個時候,肯定就離不開查詢和命令,查詢這里暫時先不說,就說一下這個命令,前端的任何一個動作其實都是一個事件。
大家肯定知道從前台DTO拿到的實體模型數據,肯定不能直接操作領域模型(當然現在我們是直接這么操作的,直接用的是視圖模型和領域模型進行交互操作,這個時候領域模型就起到了一個沖鋒陷陣的作用了,其實這種設計不符合DDD領域設計的思想,因為領域模型是一切的核心,它應該是一個個司令部,不能參與到前線,他會下發出一個個的命令模型去執行),這個時候我們的命令模型就出現了,他充當着從前台到后台的先鋒官的作用,執行一個個的命令指令,完成從視圖模型到領域模型的操作和數據的過度作用。
然后再通過中介者模式,通過事件總線,通過領域命令一一分發出去,然后通過驗證,最后是實現(比如持久化等),然后將中間產生的錯誤信息,或者通知信息,再扔給了前台,所以說,領域命令就是一個先鋒官,這里你也看到了,他是一個個先鋒官,他的作用是起到引導的作用,是下達命令的作用,他是不負責具體的邏輯實現的,具體是為什么呢,先按下不表。咱們先看看如何定義一個領域命令。
希望上邊的三段話大家可以幫忙想一想,如果想通了,但是和我寫的不一樣,請一定要留言!
1、創建命令抽象基類
在 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();
2、定義 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; } } }
希望這個時候你已經明白了上邊的兩個問題了,如果不是很明白,請再好好思考下,如果已經明白了,請繼續往下走。
3、基於命令模型,創建各種動作指令
上邊的模型創造出來了,咱們需要用它來實現各種動作命令了,比如 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 了,這里就不多說了。
二、領域驗證Validations —— 領域模型的安保官
這里為啥要說是安保官(就是起的名字,要是不貼切可以留言)呢,因為這是從前台 視圖模型 到 領域模型 的一個屏障,這個就不用解釋了,因為他就是一個驗證的作用,當一個個命令執行的時候,需要對數據進行處理,就好像前線先鋒部隊執行一個個命令的時候,需要對一個個事件或者數據進行判斷,有些錯誤的,假的數據是不能傳達到領域模型中的,而我們的先鋒官是不會處理這些的,他們只負責一個個命令的執行,驗證工作就交給了Validations,而且是每一條命令都需要進行驗證,這是肯定的。那如何創建基於命令的驗證Validations呢,請往下看。
1、基於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 的使用,這里就不多說了,大家可以自己使用,基本的也就是這么多了,當然大家也可以自己寫一些復雜的運算,這里要說的重點是,大家應該也已經發現了,每一個驗證方法都是獨立的,互不影響,就算是有一個出現錯誤(當然不是編譯錯誤),也不會影響當前整個領域命令,也就等同於不影響當前事件操作,是不是和以前相比,不僅方便而且安全性更高了。
這個時候我們定義了這個抽象的學生驗證基類,剩下的就是需要針對不同的,每一個領域命令,設計領域驗證了。
2、實現各個領域命令模型的驗證操作
這里就簡單說一個添加學生的命令驗證,我們實現 StudentValidation<RegisterStudentCommand> ,並初始化相應的命令,這里可以看到,我們可以很自由針對某一個命令,隨心隨意的設計不同的驗證,而且很好的進行管控,比如以后我們不要對名字控制了,我們只需要去掉這個方法。亦或者我們以后不僅支持手機號,還支持座機,這里就可以簡單的增加一個即可。
namespace Christ3D.Domain.Validations { /// <summary> /// 添加學生命令模型驗證 /// 繼承 StudentValidation 基類 /// </summary> public class RegisterStudentCommandValidation : StudentValidation<RegisterStudentCommand> { public RegisterStudentCommandValidation() { ValidateName();//驗證姓名 ValidateBirthDate();//驗證年齡 ValidateEmail();//驗證郵箱 ValidatePhone();//驗證手機號 //可以自定義增加新的驗證 } } }
說到了這里,相信你應該也命令了領域驅動的第一個小部分了,就是我們的每一個操作是如何生成命令並進行驗證的,那聰明的你一定會問了,我們如何操作這些領域命令呢,總得有一個驅動程序吧,它們自己肯定是不會運行的,不錯!請繼續往下看。
三、運籌命令模型 —— 誰會是指揮官?
上邊也說到了視圖模型轉成命令模型,然后在命令模型中進行驗證,現在問題來了,到底是誰在運籌着這些命令,說人話就是,是誰在調用着這些命令,如果你能看懂我說到,那就恭喜你,如果不是很懂,也沒關系,今天咱們先不說這個指揮官,今天先說說,我們平時是怎么玩兒的。
1、在 Action 中調用我們的領域命令
[HttpPost] [ValidateAntiForgeryToken] public ActionResult Create(StudentViewModel studentViewModel) { try { ViewBag.ErrorData = null; // 視圖模型驗證 if (!ModelState.IsValid) return View(studentViewModel); //添加命令驗證,采用構造函數方法實例 RegisterStudentCommand registerStudentCommand = new RegisterStudentCommand(studentViewModel.Name, studentViewModel.Email, studentViewModel.BirthDate, studentViewModel.Phone); //如果命令無效,證明有錯誤 if (!registerStudentCommand.IsValid()) { List<string> errorInfo = new List<string>(); //獲取到錯誤,請思考這個Result從哪里來的 foreach (var error in registerStudentCommand.ValidationResult.Errors) { errorInfo.Add(error.ErrorMessage); } //對錯誤進行記錄,還需要拋給前台 ViewBag.ErrorData = errorInfo; return View(studentViewModel); } // 執行添加方法 _studentAppService.Register(studentViewModel); ViewBag.Sucesso = "Student Registered!"; return View(studentViewModel); } catch (Exception e) { return View(e.Message); } }
這個很好理解,就是普通的調用,這里有兩個問題,可以有助於大家是否真正理解:
1、new RegisterStudentCommand() 為什么是構造函數實例?
2、ValidationResult.Errors 錯誤信息是從哪里得到的?
如果這兩個看懂了,給自己一個攢👍吧。這個時候,我們就需要把信息拋給前台了,怎么進行展示呢,這里我用的是自定義視圖組件,如果你會可以快速看一遍,如果沒有用過,請仔細看看。
2、自定義局部視圖頁面
添加一個視圖組件類
在 Web 根目錄下新建文件夾 ViewComponents,然后添加視圖組件類 AlertsViewComponent.cs
namespace Christ3D.UI.Web.ViewComponents { public class AlertsViewComponent : ViewComponent { /// <summary> /// Alerts 視圖組件 /// 可以異步,也可以同步,注意方法名稱,同步的時候是Invoke /// 我寫異步是為了為以后做准備 /// </summary> /// <returns></returns> public async Task<IViewComponentResult> InvokeAsync() { var notificacoes = await Task.Run(() => (List<string>)ViewBag.ErrorData); //遍歷錯誤信息,賦值給 ViewData.ModelState notificacoes?.ForEach(c => ViewData.ModelState.AddModelError(string.Empty, c)); return View(); } } }
每一個視圖組件一個類,固定寫法,這個其實就像mvc的controller。那我們還需要配置 view,如何配置呢,請往下看。
設計視圖頁面
這里我是手動創建,不知道有沒有快捷鍵,有知道的請留言哈
在 Views -> Shared 文件夾下,新建 Components\alerts\Default.cshtml 文件
@if (ViewData.ModelState.ErrorCount > 0) { <div class="alert alert-danger"> <button type="button" class="close" data-dismiss="alert">×</button> <h3 id="msgRetorno">Alert! Something went wrong:</h3> <div asp-validation-summary="ModelOnly" class="text-danger"></div> </div> } @if (!string.IsNullOrEmpty(ViewBag.Sucesso)) { <div class="alert alert-success"> <button type="button" class="close" data-dismiss="alert">×</button> <h3 id="msgRetorno">@ViewBag.Sucesso</h3> </div> }
在主頁面內調用
這里有兩種辦法:
@* 將經典驗證摘要替換為自定義視圖組件作為標記助手 *@ @*方式一(可用,但不推薦) @await Component.InvokeAsync("Alerts")*@ <!-- 如果使用這個方法,請記得在_ViewImports.cshtml 中,導入@addTagHelper "*, Christ3D.UI.Web" --> <vc:alerts />
我個人推薦使用第二種方法,注意 alerts,是我們的視圖名稱。
如果你想了解更多關於自定義視圖組件的知識,可以查看官網
1、https://docs.microsoft.com/zh-cn/aspnet/core/mvc/views/view-components?view=aspnetcore-2.1
3、瀏覽效果
這個時候,我們已經把視圖模型,命令模型,命令驗證等連接在一起,也實現了我們的目的,看似很正常,其實問題還有很多:
這個指揮官真的指揮的很好么?
為何contrller中還是會存在業務邏輯?
等等。。。
四、鳴金...
眼看時間已經很晚,今天就暫時寫到這里了。
這個時候你一定會發現,這種異常數據的寫法真的很不舒服,我們設計DDD領域驅動設計,目的就是為了要以領域為核心,把業務邏輯分離出去,這個雖然用到了領域命令,和命令驗證,咋看分離出去了,但是調用的時候,還是沒有把視圖模型和命令模型穿起來,而且細心的你應該也發現了,我們的Service方法中,還是使用的領域模型,這個是不對的。那我們如何才能把視圖模型,領域模型,驗證模型和命令模型穿起來呢,又是如何很好的把獲取錯誤信息從controller撥離出來呢,請聽下回分解~
五、GitHub & Gitee
https://github.com/anjoy8/ChristDDD
https://gitee.com/laozhangIsPhi/ChristDDD