도메인 주도 설계의 컨텍스트 유효성 검사

게시 됨: 2022-03-11

도메인 주도 설계(DDD)는 기술이나 방법론이 아닙니다. DDD는 복잡한 도메인을 다루는 소프트웨어 프로젝트에 초점을 맞추고 가속화하는 설계 결정을 내리기 위한 관행 및 용어 구조를 제공합니다. Eric Evans와 Martin Fowler가 설명한 것처럼 도메인 개체는 유효성 검사 규칙과 비즈니스 논리를 넣는 곳입니다.

에릭 에반스:

도메인 계층(또는 모델 계층): 비즈니스 개념, 비즈니스 상황에 대한 정보 및 비즈니스 규칙을 나타내는 책임이 있습니다. 여기서 비즈니스 상황을 반영하는 상태를 제어하여 사용하지만, 저장에 대한 기술적 세부 사항은 인프라에 위임합니다. 이 계층은 비즈니스 소프트웨어의 핵심입니다.

마틴 파울러:

도메인 개체에 있어야 하는 논리는 도메인 논리(검증, 계산, 비즈니스 규칙)입니다.

도메인 주도 설계의 컨텍스트 유효성 검사

도메인 개체에 모든 유효성 검사를 적용하면 작업할 거대하고 복잡한 도메인 개체가 생성됩니다. 개인적으로 나는 도메인 유효성 검사를 언제든지 재사용할 수 있고 컨텍스트 및 사용자 작업을 기반으로 하는 별도의 유효성 검사기 구성 요소로 분리하는 아이디어를 훨씬 선호합니다.

Martin Fowler는 ContextualValidation이라는 훌륭한 기사에서 썼습니다.

사람들이 흔히 하는 일 중 하나는 개체에 대한 유효성 검사 루틴을 개발하는 것입니다. 이러한 루틴은 다양한 방식으로 제공되며 개체 또는 외부에 있을 수 있으며 부울을 반환하거나 실패를 나타내기 위해 예외를 throw할 수 있습니다. 내가 생각하는 한 가지 문제는 isValid 메소드가 암시하는 것처럼 컨텍스트 독립적인 방식으로 객체 유효성을 생각할 때입니다. [...] 유효성 검사를 컨텍스트에 바인딩된 것으로 생각하는 것이 훨씬 더 유용하다고 생각합니다. 일반적으로 수행하려는 작업입니다. 예를 들어 이 주문이 유효한지 또는 이 고객이 호텔에 체크인하는 데 유효한지 묻는 것입니다. 따라서 isValid와 같은 메소드 대신 isValidForCheckIn과 같은 메소드를 사용하십시오.

행동 검증 제안

이 기사에서는 반환 유형이 ValidationResult유효성 검사 메서드를 구현해야 하는 간단한 인터페이스 ItemValidator를 구현합니다. ValidationResult는 유효성이 검사된 항목과 Messages 개체를 포함하는 개체입니다. 후자는 실행 컨텍스트에 따라 오류, 경고 및 정보 유효성 검사 상태(메시지)의 누적을 포함합니다.

유효성 검사기는 필요한 곳 ​​어디에서나 쉽게 재사용할 수 있는 분리된 구성 요소입니다. 이 접근 방식을 사용하면 유효성 검사에 필요한 모든 종속성을 쉽게 주입할 수 있습니다. 예를 들어, 주어진 이메일을 가진 사용자가 있는지 데이터베이스를 확인하려면 UserDomainService만 사용됩니다.

유효성 검사기 분리는 컨텍스트(작업)별로 수행됩니다. 따라서 UserCreate 작업과 UserUpdate 작업에 분리된 구성 요소나 다른 작업(UserActivate, UserDelete, AdCampaignLaunch 등)이 있는 경우 유효성 검사가 빠르게 증가할 수 있습니다.

각 작업 유효성 검사기에는 허용된 작업 필드만 있는 해당 작업 모델이 있어야 합니다. 사용자를 생성하려면 다음 필드가 필요합니다.

사용자 생성 모델:

 { "firstName": "John", "lastName": "Doe", "email": "[email protected]", "password": "MTIzNDU=" }

그리고 사용자를 업데이트하기 위해 다음이 허용됩니다. externalId , firstNamelastName . externalId 는 사용자 식별을 위해 사용되며 firstNamelastName 만 변경할 수 있습니다.

사용자 업데이트 모델:

 { "externalId": "a55ccd60-9d82-11e5-9f52-0002a5d5c51b", "firstName": "John Updated", "lastName": "Doe Updated" }

필드 무결성 검증을 공유할 수 있으며, firstName의 최대 길이는 항상 255자입니다.

검증하는 동안 발생한 첫 번째 오류뿐만 아니라 발생한 모든 문제의 목록을 얻는 것이 바람직합니다. 예를 들어 다음 3가지 문제가 동시에 발생할 수 있으며 실행 중에 이에 따라 보고할 수 있습니다.

  • 잘못된 주소 형식 [ERROR]
  • 이메일은 사용자 간에 고유해야 합니다. [ERROR]
  • 비밀번호가 너무 짧습니다 [ERROR]

이러한 종류의 유효성 검사를 수행하려면 유효성 검사 상태 빌더와 같은 것이 필요하며 이를 위해 Messages 가 도입되었습니다. 메시지 는 몇 년 전 훌륭한 멘토가 유효성 검사를 지원하고 이를 통해 수행할 수 있는 다양한 작업에 대해 소개했을 때 들었던 개념입니다. 메시지는 단지 유효성 검사를 위한 것이 아니기 때문입니다.

다음 섹션에서는 구현을 설명하기 위해 Scala를 사용할 것입니다. 스칼라 전문가가 아닌 경우에도 쉽게 따라할 수 있으므로 두려워하지 마십시오.

컨텍스트 유효성 검사의 메시지

메시지 는 유효성 검사 상태 작성기를 나타내는 개체입니다. 유효성 검사 중에 오류, 경고 및 정보 메시지를 쉽게 수집할 수 있는 방법을 제공합니다. 각 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의 사용을 볼 수 있습니다. 이러한 유틸리티 기능은 미리 정의된 경우에 메시지 개체를 채우는 데 사용됩니다. Github 코드에서 ValidateUtils의 구현을 확인할 수 있습니다.

이메일 유효성 검사 중에 먼저 ValidateUtils.validateEmail(… 을 호출하여 이메일이 유효한지 확인하고 ValidateUtils.validateLengthIsLessThanOrEqual(… . . 이 두 가지 유효성 검사가 완료되면 이메일이 유효한지 확인합니다. 이전 이메일 유효성 검사 조건이 통과되고 if(!localMessages.hasErrors()) { … 로 수행되는 경우에만 일부 사용자에게 이미 할당이 수행됩니다. 이렇게 하면 값비싼 데이터베이스 호출을 피할 수 있습니다. 완전한 소스 코드는 여기에서 찾을 수 있습니다.

유효성 검사 매개변수 중 하나인 UserCreateEntity.EMAIL_FORM_ID 가 눈에 띕니다. 이 매개변수는 유효성 검사 상태를 특정 입력 ID에 연결합니다.

이전 예에서 다음 작업은 Messages 객체에 오류가 있는지 여부에 따라 결정됩니다(hasErrors 메서드 사용). "WARNING" 메시지가 있는지 쉽게 확인하고 필요한 경우 다시 시도할 수 있습니다.

주목할만한 한 가지는 localMessages 가 사용되는 방식입니다. 로컬 메시지는 모든 메시지와 동일하지만 parentMessage를 사용하여 생성된 메시지입니다. 즉, 목표는 현재 유효성 검사 상태(이 예에서는 emailValidation)에 대한 참조만 갖는 것이므로 localMessages.hasErrors 를 호출할 수 있습니다. 여기서 emailValidation 컨텍스트가 오류가 있는 경우에만 검사됩니다. 또한 메시지가 localMessage에 추가되면 parentMessage에도 추가되므로 모든 유효성 검사 메시지는 UserCreateValidation의 상위 컨텍스트에 존재합니다.

이제 Messages in action을 보았으므로 다음 장에서는 ItemValidator에 초점을 맞출 것입니다.

ItemValidator - 재사용 가능한 유효성 검사 구성 요소

ItemValidator는 개발자가 ValidationResult를 반환해야 하는 validate 메서드를 구현하도록 하는 간단한 특성(인터페이스)입니다.

항목 유효성 검사기:

 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]" } }

이제 클라이언트 앱에서 메시지를 더 두드러지게 표시할 수 있습니다.

위의 예에서 동일한 핸들러를 사용하는 모든 요청에 ​​대해 통합 응답 구조를 처리하는 방법을 볼 수 있습니다.

결론

대규모 프로젝트에 유효성 검사를 적용하면 혼란스러울 수 있으며 프로젝트 코드 전체에서 유효성 검사 규칙을 찾을 수 있습니다. 유효성 검사를 일관되고 체계적으로 유지하면 일을 더 쉽고 재사용할 수 있습니다.

아래에서 두 가지 다른 버전의 상용구에서 구현된 아이디어를 찾을 수 있습니다.

  • Standard Play 2.3, Scala 2.11.1, Slick 2.1, Postgres 9.1, 스프링 종속성 주입
  • Reactive non-blocking Play 2.4, Scala 2.11.7, Slick 3.0, Postgres 9.4, Guice 의존성 주입

이 기사에서 나는 사용자에게 쉽게 제시할 수 있는 심층적이고 구성 가능한 컨텍스트 유효성 검사를 지원하는 방법에 대한 제안을 제시했습니다. 이것이 올바른 유효성 검사 및 오류 처리 문제를 완전히 해결하는 데 도움이 되기를 바랍니다. 아래에 자유롭게 의견을 남기고 생각을 공유해 주세요.