域驱动设计中的上下文验证

本文概述

  • 行动验证提案
  • 上下文验证中的消息
  • ItemValidator-可重用的验证组件
  • 统一的API响应-简单的用户交互
  • 总结
域驱动设计(简称DDD)不是技术或方法论。 DDD提供了一种实践和术语结构, 可用于制定设计决策, 以集中精力并加快处理复杂领域的软件项目。如Eric Evans和Martin Fowler所述, Domain对象是放置验证规则和业务逻辑的地方。
埃里克·埃文斯(Eric Evans):
域层(或模型层):负责表示业务概念, 有关业务状况的信息和业务规则。即使存储技术的详细信息委托给基础结构, 也可以控制和使用反映业务情况的状态。该层是业务软件的核心。
马丁·福勒:
域对象中应该包含的逻辑是域逻辑-验证, 计算, 业务规则-随便你如何称呼它。
域驱动设计中的上下文验证

文章图片
将所有验证放在Domain对象中会导致使用庞大而复杂的领域对象。我个人更喜欢将域验证分离为单独的验证器组件的想法, 这些组件可以随时重用, 并且将基于上下文和用户操作。
正如马丁·福勒(Martin Fowler)在一篇出色的文章中写道:ContextualValidation。
我看到人们做的一件事是为对象开发验证例程。这些例程以各种方式出现, 它们可能在对象中或在外部, 它们可能返回布尔值或引发异常以指示失败。我认为不断使人绊倒的一件事是, 当他们以上下文无关的方式想到对象有效性时, 例如isValid方法暗示。 […]我认为将验证视为绑定到上下文(通常是你要执行的操作)的功能要有用得多。例如询问此订单是否有效, 或该客户是否有效以签到酒店。因此, 不要像isValid这样的方法, 而要像isValidForCheckIn这样的方法。
行动验证提案 在本文中, 我们将实现一个简单的接口ItemValidator, 你需要为其实现带有返回类型ValidationResult的validate方法。 ValidationResult是一个对象, 其中包含已验证的项目以及Messages对象。后者包含取决于执行上下文的错误, 警告和信息验证状态(消息)的累积。
验证器是分离的组件, 可以在需要的任何地方轻松重用。通过这种方法, 可以轻松注入验证检查所需的所有依赖项。例如, 要检查数据库中是否存在具有给定电子邮件的用户, 仅使用UserDomainService。
验证器解耦将根据上下文(操作)进行。因此, 如果UserCreate操作和UserUpdate操作将具有分离的组件或任何其他操作(UserActivate, UserDelete, AdCampaignLaunch等), 则验证会迅速增长。
每个动作验证器应具有一个相应的动作模型, 该模型仅具有允许的动作字段。要创建用户, 需要以下字段:
UserCreateModel:
{ "firstName": "John", "lastName": "Doe", "email": "[email  protected]", "password": "MTIzNDU=" }

为了更新用户, 可以使用以下命令externalID, firstName和lastName。 externalId用于用户标识, 并且仅允许更改firstName和lastName。
UserUpdateModel:
{ "externalId": "a55ccd60-9d82-11e5-9f52-0002a5d5c51b", "firstName": "John Updated", "lastName": "Doe Updated" }

可以共享字段完整性验证, firstName的最大长度始终为255个字符。
在验证过程中, 不仅希望获得所发生的第一个错误, 而且还要列出所有遇到的问题。例如, 以下三个问题可能同时发生, 并且可以在执行期间相应地进行报告:
  • 地址格式无效[ERROR]
  • 电子邮件在用户中必须唯一[ERROR]
  • 密码太短[ERROR]
为了实现这种验证, 需要使用诸如验证状态构建器之类的工具, 并且为此目的引入了消息。消息是我几年前伟大导师提出的一个概念, 当时他将其引入来支持验证以及其他可以完成的事情, 因为消息不仅用于验证。
请注意, 在以下各节中, 我们将使用Scala来说明实现。万一你不是Scala专家, 请不要担心, 因为尽管如此, 它还是很容易跟随的。
上下文验证中的消息 消息是代表验证状态构建器的对象。它提供了一种在验证期间收集错误, 警告和信息消息的简便方法。每个Messages对象都有一个Message对象的内部集合, 也可以具有对parentMessages对象的引用。
域驱动设计中的上下文验证

文章图片
Message对象是一个对象, 它可以具有类型, messageText, 键(它是可选的, 用于支持对由标识符标识的特定输入的验证), 最后是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.ofvalidateFirstName (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 = http://www.srcmini.com/item.firstNameValidateUtils.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.lastNameValidateUtils.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.emailValidateUtils.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 = http://www.srcmini.com/item.usernameValidateUtils.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 = http://www.srcmini.com/item.passwordValidateUtils.validateLengthIsLessThanOrEqual( fieldValue, MAX_ALLOWED_LENGTH, localMessages, UserCreateEntity.PASSWORD_FORM_ID.value, MAX_ALLOWED_CHARACTER_ERROR ) } }

查看此代码, 你可以看到ValidateUtils的使用。这些实用程序功能用于在预定义的情况下填充Messages对象。你可以在Github代码上检查ValidateUtils的实现。
在电子邮件验证期间, 首先通过调用ValidateUtils.validateEmail(… 检查电子邮件是否有效, 并且还通过调用ValidateUtils.validateLengthIsLessThanOrEqual(… 来检查电子邮件是否有效长度。一旦完成这两个验证, 请检查电子邮件是否有效仅在通过了先前的电子邮件验证条件并且使用if(!localMessages.hasErrors()){…来完成时, 才执行已分配给某些User的操作, 这种方式可以避免昂贵的数据库调用, 这只是UserCreateValidator的一部分完整的源代码可以在这里找到。
请注意, 其中一个验证参数很突出:UserCreateEntity.EMAIL_FORM_ID。此参数将验证状态连接到特定的输入ID。
在前面的示例中, 下一步是根据Messages对象是否有错误(使用hasErrors方法)这一事实决定的。可以轻松检查是否有任何” 警告” 消息, 并在必要时重试。
可以注意到的一件事是localMessages的使用方式。本地消息是与任何消息一样创建的消息, 但带有parentMessages。话虽如此, 目标是仅引用当前验证状态(在此示例中为emailValidation), 因此可以调用localMessages.hasErrors, 仅在emailValidation上下文具有Errors的情况下进行检查。同样, 当消息添加到localMessages时, 它也添加到parentMessages中, 因此所有验证消息都存在于UserCreateValidation的更高上下文中。
既然我们已经看到了Messages in action, 在下一章中, 我们将重点介绍ItemValidator。
ItemValidator-可重用的验证组件 ItemValidator是一个简单的特征(接口), 它强制开发人员实现方法validate, 该方法需要返回ValidationResult。
ItemValidator:
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= http://www.srcmini.com/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.direcitive.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('[id="' + localMsg.inputId + '"]').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-alertalert 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]" } }

客户端应用现在可以更加突出地显示消息:
域驱动设计中的上下文验证

文章图片
在以上示例中, 可以看到如何使用同一处理程序为任何请求处理统一的响应结构。
总结 将验证应用于大型项目可能会造成混乱, 并且验证规则可以在整个项目代码中的任何地方找到。保持验证的一致性和结构合理性, 使事情变得更容易且可重复使用。
你可以在以下两个不同版本的样板中找到实现这些想法的方法:
  • 标准播放2.3, Scala 2.11.1, Slick 2.1, Postgres 9.1, Spring依赖注入
  • 反应性非阻塞播放2.4, Scala 2.11.7, Slick 3.0, Postgres 9.4, Guice依赖注入
【域驱动设计中的上下文验证】在本文中, 我提出了有关如何支持可轻松呈现给用户的深度, 可组合上下文验证的建议。我希望这将帮助你一劳永逸地解决正确验证和错误处理的挑战。请随时发表你的评论并在下面分享你的想法。

    推荐阅读