领域驱动设计中的上下文验证
已发表: 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 依赖注入
在本文中,我就如何支持可以轻松呈现给用户的深度、可组合的上下文验证提出了我的建议。 我希望这将帮助您一劳永逸地解决正确验证和错误处理的挑战。 请随时留下您的评论并在下面分享您的想法。