領域驅動設計中的上下文驗證
已發表: 2022-03-11領域驅動設計(簡稱 DDD)不是一種技術或方法。 DDD 提供了一種實踐結構和術語,用於製定設計決策,重點關注和加速處理複雜領域的軟件項目。 正如 Eric Evans 和 Martin Fowler 所描述的,域對像是放置驗證規則和業務邏輯的地方。
埃里克·埃文斯:
領域層(或模型層):負責表示業務概念、業務情況信息和業務規則。 反映業務情況的狀態在這裡被控制和使用,即使存儲它的技術細節被委託給基礎設施。 這一層是商業軟件的核心。
馬丁·福勒:
應該在域對像中的邏輯是域邏輯——驗證、計算、業務規則——無論你喜歡怎麼稱呼它。
將所有驗證放在域對像中會導致使用龐大而復雜的域對象。 就我個人而言,我更喜歡將域驗證解耦為單獨的驗證器組件的想法,這些組件可以隨時重複使用,並且將基於上下文和用戶操作。
正如 Martin Fowler 在一篇很棒的文章中所寫:ContextualValidation。
我看到人們做的一件常見的事情是為對像開發驗證例程。 這些例程以各種方式出現,它們可能在對像中或外部,它們可能返回布爾值或拋出異常以指示失敗。 我認為經常絆倒人們的一件事是當他們以獨立於上下文的方式考慮對像有效性時,例如 isValid 方法所暗示的。 [...] 我認為將驗證視為綁定到上下文的東西會更有用,通常是您想要執行的操作。 比如詢問這個訂單是否可以填寫,或者這個客戶是否可以入住酒店。 因此,與其使用 isValid 之類的方法,不如使用 isValidForCheckIn 之類的方法。
行動驗證提案
在本文中,我們將實現一個簡單的接口 ItemValidator ,您需要為它實現一個返回類型為ValidationResult的validate方法。 ValidationResult 是一個對象,其中包含已驗證的項目以及Messages對象。 後者包含依賴於執行上下文的錯誤、警告和信息驗證狀態(消息)的累積。
驗證器是解耦的組件,可以在任何需要的地方輕鬆重用。 使用這種方法,驗證檢查所需的所有依賴項都可以輕鬆注入。 例如,要檢查數據庫是否存在具有給定電子郵件的用戶,則僅使用 UserDomainService。
驗證器解耦將根據上下文(操作)進行。 因此,如果 UserCreate 操作和 UserUpdate 操作將具有解耦組件或任何其他操作(UserActivate、UserDelete、AdCampaignLaunch 等),則驗證可以快速增長。
每個動作驗證器都應該有一個相應的動作模型,該模型只包含允許的動作字段。 要創建用戶,需要以下字段:
用戶創建模型:
{ "firstName": "John", "lastName": "Doe", "email": "[email protected]", "password": "MTIzNDU=" }
並更新用戶以下是允許externalId , firstName和lastName 。 externalId用於用戶識別,只允許更改firstName和lastName 。
用戶更新模型:
{ "externalId": "a55ccd60-9d82-11e5-9f52-0002a5d5c51b", "firstName": "John Updated", "lastName": "Doe Updated" }
可以共享字段完整性驗證,名字的最大長度始終為 255 個字符。
在驗證過程中,不僅要獲取發生的第一個錯誤,還要獲取遇到的所有問題的列表。 例如以下3個問題可能同時發生,執行時可以相應上報:
- 地址格式無效 [錯誤]
- 電子郵件在用戶之間必須是唯一的[錯誤]
- 密碼太短[錯誤]
為了實現這種驗證,需要像驗證狀態構建器這樣的東西,為此引入了消息。 Messages是我多年前從我的偉大導師那裡聽到的一個概念,當時他介紹了它來支持驗證以及可以用它完成的各種其他事情,因為 Messages 不僅僅是用於驗證。
請注意,在以下部分中,我們將使用 Scala 來說明實現。 萬一你不是 Scala 專家,不要害怕,因為它應該很容易理解。
上下文驗證中的消息
Messages是一個表示驗證狀態構建器的對象。 它提供了一種在驗證期間收集錯誤、警告和信息消息的簡單方法。 每個Messages對像都有一個Message對象的內部集合,並且還可以具有對parentMessages對象的引用。
Message 對像是一個對象,它可以具有type 、 messageText 、 key (這是可選的,用於支持對由標識符標識的特定輸入的驗證),最後是childMessages ,它提供了構建可組合消息樹的好方法。
消息可以是以下類型之一:
- 信息
- 警告
- 錯誤
像這樣結構的消息允許我們迭代地構建它們,還允許根據先前的消息狀態來決定下一步的動作。 例如,在用戶創建期間執行驗證:
@Component class UserCreateValidator @Autowired (private val entityDomainService: UserDomainService) extends ItemValidator[UserCreateEntity] { Asserts.argumentIsNotNull(entityDomainService) private val MAX_ALLOWED_LENGTH = 80 private val MAX_ALLOWED_CHARACTER_ERROR = s"must be less than or equal to $MAX_ALLOWED_LENGTH character" override def validate(item: UserCreateEntity): ValidationResult[UserCreateEntity] = { Asserts.argumentIsNotNull(item) val validationMessages = Messages.of validateFirstName (item, validationMessages) validateLastName (item, validationMessages) validateEmail (item, validationMessages) validateUserName (item, validationMessages) validatePassword (item, validationMessages) ValidationResult( validatedItem = item, messages = validationMessages ) } private def validateFirstName(item: UserCreateEntity, validationMessages: Messages) { val localMessages = Messages.of(validationMessages) val fieldValue = item.firstName ValidateUtils.validateLengthIsLessThanOrEqual( fieldValue, MAX_ALLOWED_LENGTH, localMessages, UserCreateEntity.FIRST_NAME_FORM_ID.value, MAX_ALLOWED_CHARACTER_ERROR ) } private def validateLastName(item: UserCreateEntity, validationMessages: Messages) { val localMessages = Messages.of(validationMessages) val fieldValue = item.lastName ValidateUtils.validateLengthIsLessThanOrEqual( fieldValue, MAX_ALLOWED_LENGTH, localMessages, UserCreateEntity.LAST_NAME_FORM_ID.value, MAX_ALLOWED_CHARACTER_ERROR ) } private def validateEmail(item: UserCreateEntity, validationMessages: Messages) { val localMessages = Messages.of(validationMessages) val fieldValue = item.email ValidateUtils.validateEmail( fieldValue, UserCreateEntity.EMAIL_FORM_ID, localMessages ) ValidateUtils.validateLengthIsLessThanOrEqual( fieldValue, MAX_ALLOWED_LENGTH, localMessages, UserCreateEntity.EMAIL_FORM_ID.value, MAX_ALLOWED_CHARACTER_ERROR ) if(!localMessages.hasErrors()) { val doesExistWithEmail = this.entityDomainService.doesExistByByEmail(fieldValue) ValidateUtils.isFalse( doesExistWithEmail, localMessages, UserCreateEntity.EMAIL_FORM_ID.value, "User already exists with this email" ) } } private def validateUserName(item: UserCreateEntity, validationMessages: Messages) { val localMessages = Messages.of(validationMessages) val fieldValue = item.username ValidateUtils.validateLengthIsLessThanOrEqual( fieldValue, MAX_ALLOWED_LENGTH, localMessages, UserCreateEntity.USERNAME_FORM_ID.value, MAX_ALLOWED_CHARACTER_ERROR ) if(!localMessages.hasErrors()) { val doesExistWithUsername = this.entityDomainService.doesExistByUsername(fieldValue) ValidateUtils.isFalse( doesExistWithUsername, localMessages, UserCreateEntity.USERNAME_FORM_ID.value, "User already exists with this username" ) } } private def validatePassword(item: UserCreateEntity, validationMessages: Messages) { val localMessages = Messages.of(validationMessages) val fieldValue = item.password ValidateUtils.validateLengthIsLessThanOrEqual( fieldValue, MAX_ALLOWED_LENGTH, localMessages, UserCreateEntity.PASSWORD_FORM_ID.value, MAX_ALLOWED_CHARACTER_ERROR ) } }
查看這段代碼,您可以看到 ValidateUtils 的使用。 這些實用程序函數用於在預定義的情況下填充 Messages 對象。 你可以在 Github 代碼上查看 ValidateUtils 的實現。
在電子郵件驗證期間,首先通過調用 ValidateUtils.validateEmail(... 來檢查電子郵件是否有效,並通過調用ValidateUtils.validateLengthIsLessThanOrEqual(...來檢查電子郵件是否具有有效長度。完成這兩個驗證後,檢查電子郵件是否有效is already assigned to some User 僅當先前的電子郵件驗證條件通過並且使用if(!localMessages.hasErrors()) { ...完成時才執行。這樣可以避免昂貴的數據庫調用。這只是 UserCreateValidator 的一部分. 完整的源代碼可以在這裡找到。
請注意,其中一個驗證參數很突出: UserCreateEntity.EMAIL_FORM_ID 。 此參數將驗證狀態連接到特定的輸入 ID。
在前面的示例中,下一步操作是根據Messages對像是否有錯誤(使用 hasErrors 方法)這一事實決定的。 可以輕鬆檢查是否有任何“警告”消息,並在必要時重試。
可以注意到的一件事是localMessages的使用方式。 本地消息是與任何消息一樣創建的消息,但具有 parentMessages。 話雖如此,目標是僅引用當前驗證狀態(在此示例中為 emailValidation),因此可以調用localMessages.hasErrors ,僅當 emailValidation 上下文 hasErrors 時才對其進行檢查。 此外,當一條消息添加到 localMessages 時,它也會添加到 parentMessages 中,因此所有驗證消息都存在於 UserCreateValidation 的更高上下文中。

現在我們已經看到了 Messages 的作用,下一章我們將重點關注 ItemValidator。
ItemValidator - 可重用的驗證組件
ItemValidator 是一個簡單的 trait(接口),它強制開發人員實現方法validate ,該方法需要返回 ValidationResult。
項目驗證器:
trait ItemValidator[T] { def validate(item:T) : ValidationResult[T] }
驗證結果:
case class ValidationResult[T: Writes]( validatedItem : T, messages : Messages ) { Asserts.argumentIsNotNull(validatedItem) Asserts.argumentIsNotNull(messages) def isValid :Boolean = { !messages.hasErrors } def errorsRestResponse = { Asserts.argumentIsTrue(!this.isValid) ResponseTools.of( data = Some(this.validatedItem), messages = Some(messages) ) } }
當像 UserCreateValidator 這樣的 ItemValidator 被實現為依賴注入組件時,ItemValidator 對象可以被注入並重用在任何需要 UserCreate 操作驗證的對像中。
執行驗證後,檢查驗證是否成功。 如果是,則將用戶數據持久化到數據庫,但如果不是,則返回包含驗證錯誤的 API 響應。
在下一節中,我們將看到如何在 RESTful API 響應中呈現驗證錯誤,以及如何與 API 使用者就執行操作狀態進行通信。
統一 API 響應 - 簡單的用戶交互
成功驗證用戶操作後,在我們的用戶創建案例中,驗證操作結果需要顯示給 RESTful API 使用者。 最好的方法是有一個統一的 API 響應,其中只切換上下文(就 JSON 而言,“數據”的值)。 通過統一響應,可以輕鬆地將錯誤呈現給 RESTful API 消費者。
統一響應結構:
{ "messages" : { "global" : { "info": [], "warnings": [], "errors": [] }, "local" : [] }, "data":{} }
統一響應的結構具有兩層消息,全局和本地。 本地消息是耦合到特定輸入的消息。 比如“用戶名太長,最多允許80個字符”_。 全局消息是反映頁面上整個數據狀態的消息,例如“用戶在批准之前不會處於活動狀態”。 本地和全局消息具有三個級別 - 錯誤、警告和信息。 “數據”的價值是特定於上下文的。 創建用戶時,數據字段將包含用戶數據,但在獲取用戶列表時,數據字段將是用戶數組。
通過這種結構化響應,可以輕鬆創建客戶端 UI 處理程序,該處理程序將負責顯示錯誤、警告和信息消息。 全局消息將顯示在頁面頂部,因為它們與全局 API 操作狀態相關,而本地消息可以顯示在指定輸入(字段)附近,因為它們與字段的值直接相關。 錯誤信息可以紅色顯示,警告信息用黃色顯示,信息用藍色顯示。
例如,在基於 AngularJS 的客戶端應用程序中,我們可以有兩個指令負責處理本地和全局響應消息,這樣只有這兩個處理程序才能以一致的方式處理所有響應。
本地消息的指令將需要應用於包含所有消息的實際元素的父元素。
localmessages.directive.js :
(function() { 'use strict'; angular .module('reactiveClient') .directive('localMessagesValidationDirective', localMessagesValidationDirective); /** @ngInject */ function localMessagesValidationDirective(_) { return { restrict: 'AE', transclude: true, scope: { binder: '=' }, template: '<div ng-transclude></div>', link: function (scope, element) { var messagesWatchCleanUp = scope.$watch('binder', messagesBinderWatchCallback); scope.$on('$destroy', function() { messagesWatchCleanUp(); }); function messagesBinderWatchCallback (messagesResponse) { if (messagesResponse != undefined && messagesResponse.messages != undefined) { if (messagesResponse.messages.local.length > 0) { element.find('.alert').remove(); _.forEach(messagesResponse.messages.local, function (localMsg) { var selector = element.find('[]').parent(); _.forEach(localMsg.info, function (msg) { var infoMsg = '<div class="form-control validation-alert alert alert-info alert-dismissable"><button type="button" class="close" data-dismiss="alert" aria-hidden="true">×</button>' + msg + '</div>'; selector.after(infoMsg); }); _.forEach(localMsg.warnings, function (msg) { var warningMsg = '<div class="form-control validation-alert alert alert-warning alert-dismissable"><button type="button" class="close" data-dismiss="alert" aria-hidden="true">×</button>' + msg + '</div>'; selector.after(warningMsg); }); _.forEach(localMsg.errors, function (msg) { var errorMsg = '<div class="form-control validation-alert alert alert-danger alert-dismissable"><button type="button" class="close" data-dismiss="alert" aria-hidden="true">×</button>' + msg + '</div>'; selector.after(errorMsg); }); }); } } } } } } })();
全局消息指令將包含在根佈局文檔 (index.html) 中,並將註冊到一個事件以處理所有全局消息。
globalmessages.directive.js :
(function() { 'use strict'; angular .module('reactiveClient') .directive('globalMessagesValidationDirective', globalMessagesValidationDirective); /** @ngInject */ function globalMessagesValidationDirective(_, toastr, $rootScope, $log) { return { restrict: 'AE', link: function (scope) { var cleanUpListener = $rootScope.$on('globalMessages', globalMessagesWatchCallback); scope.$on('$destroy', function() { cleanUpListener(); }); function globalMessagesWatchCallback (event, messagesResponse) { $log.log('received rootScope event: ' + event); if (messagesResponse != undefined && messagesResponse.messages != undefined) { if (messagesResponse.messages.global != undefined) { _.forEach(messagesResponse.messages.global.info, function (msg) { toastr.info(msg); }); _.forEach(messagesResponse.messages.global.warnings, function (msg) { toastr.warning(msg); }); _.forEach(messagesResponse.messages.global.errors, function (msg) { toastr.error(msg); }); } } } } } } })();
對於更完整的示例,讓我們考慮以下包含本地消息的響應:
{ "messages" : { "global" : { "info": [], "warnings": [], "errors": [] }, "local" : [ { "inputId" : "email", "errors" : ["User already exists with this email"], "warnings" : [], "info" : [] } ] }, "data":{ "firstName": "John", "lastName": "Doe", "email": "[email protected]", "password": "MTIzNDU=" } }
上述響應可能導致以下結果:
另一方面,有一條全球消息作為回應:
{ "messages" : { "global" : { "info": ["User successfully created."], "warnings": ["User will not be available for login until is activated"], "errors": [] }, "local" : [] }, "data":{ "externalId": "a55ccd60-9d82-11e5-9f52-0002a5d5c51b", "firstName": "John", "lastName": "Doe", "email": "[email protected]" } }
客戶端應用程序現在可以更突出地顯示消息:
在上面的示例中,可以看到如何使用相同的處理程序為任何請求處理統一的響應結構。
結論
在大型項目上應用驗證可能會變得混亂,並且在整個項目代碼中隨處可見驗證規則。 保持驗證的一致性和良好的結構使事情變得更容易和可重用。
您可以在以下兩個不同版本的樣板中找到這些想法:
- 標準 Play 2.3、Scala 2.11.1、Slick 2.1、Postgres 9.1、Spring 依賴注入
- 反應式非阻塞 Play 2.4、Scala 2.11.7、Slick 3.0、Postgres 9.4、Guice 依賴注入
在本文中,我就如何支持可以輕鬆呈現給用戶的深度、可組合的上下文驗證提出了我的建議。 我希望這將幫助您一勞永逸地解決正確驗證和錯誤處理的挑戰。 請隨時留下您的評論並在下面分享您的想法。