Проверка контекста в предметно-ориентированном проектировании
Опубликовано: 2022-03-11Проектирование на основе предметной области (сокращенно DDD) — это не технология или методология. DDD обеспечивает структуру методов и терминологии для принятия проектных решений, которые фокусируют и ускоряют программные проекты, связанные со сложными областями. Как описано Эриком Эвансом и Мартином Фаулером, объекты домена — это место для размещения правил проверки и бизнес-логики.
Эрик Эванс:
Уровень домена (или уровень модели): отвечает за представление концепций бизнеса, информации о бизнес-ситуации и бизнес-правил. Здесь контролируется и используется состояние, отражающее бизнес-ситуацию, хотя технические детали его хранения делегированы инфраструктуре. Этот уровень является сердцем программного обеспечения для бизнеса.
Мартин Фаулер:
Логика, которая должна быть в объекте предметной области, — это логика предметной области — проверки, расчеты, бизнес-правила — называйте это как угодно.
Размещение всей проверки в объектах домена приводит к тому, что с ними приходится работать огромными и сложными объектами домена. Лично я предпочитаю идею разделения проверки домена на отдельные компоненты проверки, которые можно повторно использовать в любое время и которые будут основаны на контексте и действиях пользователя.
Как написал Мартин Фаулер в отличной статье ContextualValidation.
Одна обычная вещь, которую я вижу, это разработка процедур проверки для объектов. Эти подпрограммы существуют по-разному, они могут быть объектными или внешними, они могут возвращать логическое значение или выдавать исключение, указывающее на сбой. Я думаю, что одна вещь, которая постоянно сбивает людей с толку, — это когда они думают о достоверности объекта независимо от контекста, как это подразумевает метод isValid. […] Я думаю, что гораздо полезнее думать о проверке как о чем-то, что связано с контекстом, обычно это действие, которое вы хотите выполнить. Например, спросить, действителен ли этот заказ для выполнения или этот клиент действителен для регистрации в отеле. Таким образом, вместо таких методов, как isValid, используйте такие методы, как isValidForCheckIn.
Предложение по подтверждению действия
В этой статье мы реализуем простой интерфейс ItemValidator, для которого вам нужно реализовать метод проверки с возвращаемым типом ValidationResult . ValidationResult — это объект, содержащий проверенный элемент, а также объект Messages . Последний содержит набор ошибок, предупреждений и состояний проверки информации (сообщений), зависящих от контекста выполнения.
Валидаторы — это несвязанные компоненты, которые можно легко повторно использовать везде, где они необходимы. При таком подходе все зависимости, необходимые для проверки, могут быть легко внедрены. Например, чтобы проверить в базе данных, есть ли пользователь с заданным адресом электронной почты, используется только UserDomainService.
Разделение валидаторов будет осуществляться по контексту (действию). Таким образом, если действие UserCreate и действие UserUpdate будут иметь несвязанные компоненты или любое другое действие (UserActivate, UserDelete, AdCampaignLaunch и т. д.), проверка может быстро увеличиться.
Каждый валидатор действий должен иметь соответствующую модель действий, в которой будут только разрешенные поля действий. Для создания пользователей необходимы следующие поля:
UserCreateModel:
{ "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" }
Проверки целостности полей могут быть общими, максимальная длина firstName всегда составляет 255 символов.
При валидации желательно получить не только первую возникшую ошибку, но и список всех возникших проблем. Например, следующие 3 проблемы могут возникать одновременно, и о них можно сообщить во время выполнения:
- неверный формат адреса [ОШИБКА]
- электронная почта должна быть уникальной среди пользователей [ОШИБКА]
- пароль слишком короткий [ОШИБКА]
Для достижения такой проверки требуется что-то вроде построителя состояния проверки, и для этой цели вводятся сообщения . Сообщения — это концепция, которую я услышал от своего великого наставника много лет назад, когда он представил ее для поддержки проверки, а также для различных других вещей, которые можно с ее помощью делать, поскольку сообщения предназначены не только для проверки.
Обратите внимание, что в следующих разделах мы будем использовать Scala для иллюстрации реализации. На всякий случай, если вы не являетесь экспертом в области Scala, не бойтесь, поскольку, тем не менее, вам должно быть легко следовать этому курсу.
Сообщения в контексте проверки
Messages — это объект, представляющий построитель состояния проверки. Он обеспечивает простой способ сбора ошибок, предупреждений и информационных сообщений во время проверки. Каждый объект 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.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 в предопределенных случаях. Вы можете проверить реализацию ValidateUtils в коде Github.
Во время проверки электронной почты сначала проверяется, является ли электронная почта действительной, вызывая ValidateUtils.validateEmail(… , а также проверяется, имеет ли электронная почта допустимую длину, вызывая ValidateUtils.validateLengthIsLessThanOrEqual(… . После выполнения этих двух проверок проверяется, является ли электронная почта уже назначено какому-либо пользователю, выполняется только в том случае, если выполняются условия предыдущей проверки электронной почты, и это делается с помощью if(!localMessages.hasErrors()) { … . Таким образом можно избежать дорогостоящих вызовов базы данных. Это только часть UserCreateValidator , Полный исходный код можно найти здесь.
Обратите внимание, что один из параметров проверки выделяется: UserCreateEntity.EMAIL_FORM_ID . Этот параметр связывает состояние проверки с определенным входным идентификатором.

В предыдущих примерах решение о следующем действии принимается на основании того факта, что объект Messages имеет ошибки (с использованием метода hasErrors). Можно легко проверить, есть ли какие-либо сообщения «ПРЕДУПРЕЖДЕНИЕ», и при необходимости повторить попытку.
Одна вещь, которую можно заметить, это способ использования localMessages . Локальные сообщения — это сообщения, созданные так же, как и любое сообщение, но с родительскими сообщениями. При этом цель состоит в том, чтобы иметь ссылку только на текущее состояние проверки (в этом примере emailValidation), поэтому можно вызвать localMessages.hasErrors , где он проверяется только в том случае, если в контексте emailValidation есть ошибки. Кроме того, когда сообщение добавляется в localMessages, оно также добавляется в parentMessages, поэтому все сообщения проверки существуют в более высоком контексте UserCreateValidation.
Теперь, когда мы увидели сообщения в действии, в следующей главе мы сосредоточимся на 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 = Some(this.validatedItem), messages = Some(messages) ) } }
Когда ItemValidator, такие как UserCreateValidator, реализуются как компоненты внедрения зависимостей, объекты ItemValidator можно внедрять и повторно использовать в любом объекте, который требует проверки действия UserCreate.
После выполнения проверки проверяется, прошла ли проверка успешно. Если это так, то пользовательские данные сохраняются в базе данных, но если нет, возвращается ответ API, содержащий ошибки проверки.
В следующем разделе мы увидим, как мы можем отображать ошибки проверки в ответе RESTful API, а также как общаться с потребителями API о состояниях выполнения действий.
Единый ответ API — простое взаимодействие с пользователем
После того, как действие пользователя было успешно проверено, в нашем случае создание пользователя, результаты действия проверки необходимо отобразить потребителю RESTful API. Лучший способ — иметь унифицированный ответ API, в котором будет переключаться только контекст (с точки зрения JSON, значение «данные»). Благодаря унифицированным ответам ошибки могут быть легко представлены пользователям RESTful API.
Единая структура ответа:
{ "messages" : { "global" : { "info": [], "warnings": [], "errors": [] }, "local" : [] }, "data":{} }
Унифицированный ответ имеет два уровня сообщений: глобальный и локальный. Локальные сообщения — это сообщения, связанные с определенными входными данными. Например, «имя пользователя слишком длинное, разрешено не более 80 символов»_. Глобальные сообщения — это сообщения, отражающие состояние всех данных на странице, например, «пользователь не будет активен, пока не будет одобрен». Локальные и глобальные сообщения имеют три уровня — ошибка, предупреждение и информация. Значение «данные» зависит от контекста. При создании пользователей поле данных будет содержать пользовательские данные, но при получении списка пользователей поле данных будет массивом пользователей.
При таком структурированном ответе можно легко создать обработчик пользовательского интерфейса клиента, который будет отвечать за отображение ошибок, предупреждений и информационных сообщений. Глобальные сообщения будут отображаться в верхней части страницы, поскольку они связаны с глобальным состоянием действия 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]" } }
Клиентское приложение теперь может отображать сообщение более заметно:
В приведенных выше примерах видно, как можно обрабатывать унифицированную структуру ответа для любого запроса с помощью одного и того же обработчика.
Заключение
Применение проверки в больших проектах может привести к путанице, а правила проверки можно найти повсюду в коде проекта. Поддержание последовательной и хорошо структурированной проверки делает вещи проще и повторно используемыми.
Вы можете найти эти идеи, реализованные в двух разных версиях шаблонов ниже:
- Standard Play 2.3, Scala 2.11.1, Slick 2.1, Postgres 9.1, Spring Dependency Injection
- Реактивный неблокирующий Play 2.4, Scala 2.11.7, Slick 3.0, Postgres 9.4, Guice Dependency Injection
В этой статье я представил свои предложения о том, как поддерживать глубокую компонуемую проверку контекста, которую можно легко представить пользователю. Я надеюсь, что это поможет вам решить проблемы правильной проверки и обработки ошибок раз и навсегда. Пожалуйста, не стесняйтесь оставлять свои комментарии и делиться своими мыслями ниже.