從壹開始微服務 [ DDD ] 之九 ║從軍事故事中,明白領域命令驗證(上)
烽煙
哈嘍大家週二好呀,咱們又見面了,上週末掐指一算,距離 聖誕節 只有 5 周的時間了(如果你還不知道為啥我要提聖誕節這個時間點,可以看看我的第二系列開篇《 ofollow,noindex" target="_blank">之一 ║ D3模式設計初探 與 我的計劃書 》),然後我簡單的思考了下這個DDD領域驅動設計還剩下的知識點,現在已經進入了第二部分,就是領域命令和領域驅動這一塊,第三部分包括Identity驗證和.net core api等設計點,大概就是剩了這麼多,預計應該能在聖誕節前完成。還有一個就是,之前的八篇文章,已經比較完整的實現了普通框架的整體搭建,我也單獨的新建了一個 Git分支 —— Framework8 ,如果你不想用領域命令、領域事件、事件回溯這些東西,僅僅就想要一個空的框架,一個包括 EFCore+Dtos+Automapper+IoC+Repository 的空框架(就比如我的第一個系列,就是一個普通的框架,請不要再說是這是一個普通三層了,拜託:joy:),你就可以直接用這個 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、基於命令模型,建立各種動作指令
上邊的模型創造出來了,咱們需要用它來實現各種動作命令了,比如 URD 操作,肯定是沒有 C 查詢的。這裡就重點說一下建立吧,剩下兩個都一樣。
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 錯誤資訊是從哪裡得到的?
如果這兩個看懂了,給自己一個攢:+1:吧。這個時候,我們就需要把資訊拋給前臺了,怎麼進行展示呢,這裡我用的是自定義檢視元件,如果你會可以快速看一遍,如果沒有用過,請仔細看看。
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撥離出來呢,請聽下回分解~