Validarea contextului în proiectarea bazată pe domeniu
Publicat: 2022-03-11Designul bazat pe domenii (DDD, pe scurt) nu este o tehnologie sau o metodologie. DDD oferă o structură de practici și terminologie pentru luarea deciziilor de proiectare care concentrează și accelerează proiectele software care se ocupă de domenii complicate. După cum sunt descrise de Eric Evans și Martin Fowler, obiectele de domeniu sunt un loc pentru a pune reguli de validare și logica de afaceri.
Eric Evans:
Stratul de domeniu (sau Stratul de model): Responsabil pentru reprezentarea conceptelor afacerii, a informațiilor despre situația afacerii și a regulilor de afaceri. Statul care reflectă situația afacerii este controlat și utilizat aici, chiar dacă detaliile tehnice de stocare sunt delegate infrastructurii. Acest strat este inima software-ului de afaceri.
Martin Fowler:
Logica care ar trebui să fie într-un obiect de domeniu este logica de domeniu - validări, calcule, reguli de afaceri - oricum doriți să o numiți.
Punerea tuturor validării în obiectele de domeniu are ca rezultat obiecte de domeniu uriașe și complexe cu care să lucrați. Personal, prefer cu mult ideea de a decupla validările de domeniu în componente de validare separate care pot fi reutilizate oricând și care se vor baza pe context și pe acțiunea utilizatorului.
După cum a scris Martin Fowler într-un articol grozav: ContextualValidation.
Un lucru comun pe care văd că oamenii fac este să dezvolte rutine de validare pentru obiecte. Aceste rutine vin în diferite moduri, pot fi în obiect sau externe, pot returna un boolean sau pot arunca o excepție pentru a indica eșecul. Un lucru care cred că îi provoacă constant pe oameni este atunci când se gândesc la validitatea obiectului într-un mod independent de context, cum ar fi o metodă isValid. […] Cred că este mult mai util să te gândești la validare ca la ceva legat de un context, de obicei o acțiune pe care vrei să o faci. Cum ar fi întrebarea dacă această comandă este valabilă pentru a fi completată sau dacă acest client este valabil pentru a face check-in la hotel. Deci, în loc să aveți metode precum isValid, aveți metode precum isValidForCheckIn.
Propunere de validare a acțiunii
În acest articol vom implementa o interfață simplă ItemValidator pentru care trebuie să implementați o metodă de validare cu tipul de returnare ValidationResult . ValidationResult este un obiect care conține elementul care a fost validat și, de asemenea, obiectul Messages . Acesta din urmă conține o acumulare de erori, avertismente și stări de validare a informațiilor (mesaje) dependente de contextul de execuție.
Validatoarele sunt componente decuplate care pot fi reutilizate cu ușurință oriunde sunt necesare. Cu această abordare, toate dependențele, care sunt necesare pentru verificările de validare, pot fi injectate cu ușurință. De exemplu, pentru a verifica în baza de date dacă există un utilizator cu e-mailul dat, este utilizat numai UserDomainService.
Decuplarea validatorilor se va face pe context (acțiune). Deci, dacă acțiunea UserCreate și acțiunea UserUpdate vor avea componente decuplate sau orice altă acțiune (UserActivate, UserDelete, AdCampaignLaunch etc.), validarea poate crește rapid.
Fiecare validator de acțiuni ar trebui să aibă un model de acțiune corespunzător care va avea doar câmpurile de acțiune permise. Pentru crearea utilizatorilor, sunt necesare următoarele câmpuri:
UserCreateModel:
{ "firstName": "John", "lastName": "Doe", "email": "[email protected]", "password": "MTIzNDU=" }
Și pentru a actualiza utilizatorul sunt permise următoarele externalId , firstName și lastName . externalId este folosit pentru identificarea utilizatorului și este permisă doar schimbarea numelui și a familiei .
UserUpdateModel:
{ "externalId": "a55ccd60-9d82-11e5-9f52-0002a5d5c51b", "firstName": "John Updated", "lastName": "Doe Updated" }
Validarea integrității câmpului poate fi partajată, lungimea maximă a firstName este întotdeauna de 255 de caractere.
În timpul validării, este de dorit să obțineți nu numai prima eroare care apare, ci și o listă cu toate problemele întâlnite. De exemplu, următoarele 3 probleme pot apărea în același timp și pot fi raportate în consecință în timpul execuției:
- format de adresă nevalid [EROARE]
- e-mailul trebuie să fie unic printre utilizatori [EROARE]
- parola prea scurtă [EROARE]
Pentru a realiza acest tip de validare, este nevoie de ceva de genul generatorului de stări de validare, iar în acest scop este introdus mesajele . Mesaje este un concept pe care l-am auzit de la marele meu mentor cu ani în urmă, când l-a introdus pentru a sprijini validarea și, de asemenea, pentru diverse alte lucruri care se pot face cu el, deoarece Mesajele nu sunt doar pentru validare.
Rețineți că în următoarele secțiuni vom folosi Scala pentru a ilustra implementarea. În cazul în care nu sunteți un expert Scala, nu vă temeți, deoarece ar trebui să fie ușor de urmat.
Validarea mesajelor în context
Messages este un obiect care reprezintă generatorul de stare de validare. Oferă o modalitate ușoară de a colecta erori, avertismente și mesaje de informații în timpul validării. Fiecare obiect Messages are o colecție interioară de obiecte Message și, de asemenea, poate avea o referință la obiectul parentMessages .
Un obiect Message este un obiect care poate avea type , messageText , cheie (care este opțională și este utilizată pentru a sprijini validarea unor intrări specifice care sunt identificate prin identificator) și, în sfârșit, childMessages care oferă o modalitate excelentă de a construi arbori de mesaje componabile.
Un mesaj poate fi de unul dintre următoarele tipuri:
- informație
- Avertizare
- Eroare
Mesajele astfel structurate ne permit să le construim în mod iterativ și, de asemenea, ne permit să luăm decizii cu privire la următoarele acțiuni pe baza stării anterioare a mesajelor. De exemplu, efectuarea validării în timpul creării utilizatorului:
@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 ) } }
Privind acest cod, puteți vedea utilizarea ValidateUtils. Aceste funcții utilitare sunt utilizate pentru a popula obiectul Mesaje în cazuri predefinite. Puteți verifica implementarea ValidateUtils pe codul Github.
În timpul validării e-mailului, mai întâi se verifică dacă e-mailul este valid apelând ValidateUtils.validateEmail(… , și se verifică, de asemenea, dacă e-mailul are o lungime validă apelând ValidateUtils.validateLengthIsLessThanOrEqual(… . Odată efectuate aceste două validări, verificând dacă e-mailul). este deja atribuit unui utilizator este efectuat, numai dacă sunt îndeplinite condițiile anterioare de validare a e-mailului și asta se face cu if(!localMessages.hasErrors()) { … . În acest fel pot fi evitate apelurile costisitoare la baza de date. Aceasta este doar o parte a UserCreateValidator Codul sursă complet poate fi găsit aici.
Observați că unul dintre parametrii de validare iese în evidență: UserCreateEntity.EMAIL_FORM_ID . Acest parametru conectează starea de validare la un ID de intrare specific.

În exemplele anterioare, următoarea acțiune este decisă pe baza faptului dacă obiectul Messages are erori (folosind metoda hasErrors). Se poate verifica cu ușurință dacă există mesaje „AVERTISMENT” și reîncerca dacă este necesar.
Un lucru care poate fi observat este modul în care localMessages este folosit. Mesajele locale sunt mesaje care au fost create la fel ca orice mesaj, dar cu parentMessages. Acestea fiind spuse, scopul este de a avea o referință numai la starea curentă de validare (în acest exemplu emailValidation), astfel încât localMessages.hasErrors poate fi apelat, unde este verificat numai dacă contextul emailValidation areErrors. De asemenea, atunci când un mesaj este adăugat la localMessages, acesta este adăugat și la parentMessages și astfel toate mesajele de validare există în contextul superior al UserCreateValidation.
Acum că am văzut Mesaje în acțiune, în capitolul următor ne vom concentra pe ItemValidator.
ItemValidator - Componentă de validare reutilizabilă
ItemValidator este o trăsătură simplă (interfață) care obligă dezvoltatorii să implementeze metoda validate , care trebuie să returneze ValidationResult.
ElementValidator:
trait ItemValidator[T] { def validate(item:T) : ValidationResult[T] }
ValidationResult:
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) ) } }
Când ItemValidators, cum ar fi UserCreateValidator, sunt implementați pentru a fi componente de injectare a dependenței, atunci obiectele ItemValidator pot fi injectate și reutilizate în orice obiect care necesită validarea acțiunii UserCreate.
După ce validarea este executată, se verifică dacă validarea a avut succes. Dacă este, atunci datele utilizatorului sunt păstrate în baza de date, dar dacă nu este returnat răspunsul API care conține erori de validare.
În secțiunea următoare vom vedea cum putem prezenta erori de validare în răspunsul API RESTful și, de asemenea, cum să comunicăm cu consumatorii API despre stările acțiunii de execuție.
Răspuns API unificat - Interacțiune simplă cu utilizatorul
După ce acțiunea utilizatorului a fost validată cu succes, în cazul creării utilizatorului nostru, rezultatele acțiunii de validare trebuie să fie afișate pentru consumatorul API RESTful. Cel mai bun mod este de a avea un răspuns API unificat în care doar contextul va fi schimbat (în termeni de JSON, valoarea „date”). Cu răspunsuri unificate, erorile pot fi prezentate cu ușurință consumatorilor API RESTful.
Structura de răspuns unificată:
{ "messages" : { "global" : { "info": [], "warnings": [], "errors": [] }, "local" : [] }, "data":{} }
Răspunsul unificat este structurat pentru a avea două niveluri de mesaje, global și local. Mesajele locale sunt mesaje care sunt cuplate la anumite intrări. De exemplu, „numele de utilizator este prea lung, sunt permise cel mult 80 de caractere”_. Mesajele globale sunt mesaje care reflectă starea întregii date de pe pagină, cum ar fi „utilizatorul nu va fi activ până la aprobare”. Mesajele locale și globale au trei niveluri - eroare, avertizare și informare. Valoarea „datelor” este specifică contextului. Când creați utilizatori, câmpul de date va conține date despre utilizatori, dar când obțineți o listă de utilizatori, câmpul de date va fi o serie de utilizatori.
Cu acest tip de răspuns structurat, gestionarea interfeței clientului poate fi creat cu ușurință, care va fi responsabil pentru afișarea erorilor, avertismentelor și mesajelor informative. Mesajele globale vor fi afișate în partea de sus a paginii, deoarece sunt legate de starea globală a acțiunii API, iar mesajele locale pot fi afișate lângă intrarea (câmpul) specificată, deoarece sunt direct legate de valoarea câmpului. Mesajele de eroare pot fi prezentate în roșu, mesajele de avertizare în galben și informațiile în albastru.
De exemplu, într-o aplicație client bazată pe AngularJS putem avea două directive responsabile pentru gestionarea mesajelor de răspuns locale și globale, astfel încât numai acești doi handleri să poată trata toate răspunsurile într-o manieră consecventă.
Directiva pentru mesajul local va trebui aplicată unui element părinte la elementul real care deține toate mesajele.
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); }); }); } } } } } } })();
Directiva pentru mesajele globale va fi inclusă în documentul de aspect rădăcină (index.html) și se va înregistra la un eveniment pentru gestionarea tuturor mesajelor globale.
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); }); } } } } } } })();
Pentru un exemplu mai complet, să luăm în considerare următorul răspuns care conține un mesaj local:
{ "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=" } }
Răspunsul de mai sus poate duce la ceva după cum urmează:
Pe de altă parte, cu un mesaj global ca răspuns:
{ "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]" } }
Aplicația client poate afișa acum mesajul cu mai multă importanță:
În exemplele de mai sus se poate vedea cum o structură de răspuns unificată poate fi gestionată pentru orice cerere cu același handler.
Concluzie
Aplicarea validării pe proiecte mari poate deveni confuză, iar regulile de validare pot fi găsite peste tot în codul proiectului. Menținerea validării consistente și bine structurate face lucrurile mai ușoare și reutilizabile.
Puteți găsi aceste idei implementate în două versiuni diferite de boilerplate mai jos:
- Standard Play 2.3, Scala 2.11.1, Slick 2.1, Postgres 9.1, Spring Dependency Injection
- Reactive non-blocking Play 2.4, Scala 2.11.7, Slick 3.0, Postgres 9.4, Guice Dependency Injection
În acest articol am prezentat sugestiile mele despre cum să susțin validarea contextului profund, composabil, care poate fi prezentată cu ușurință unui utilizator. Sper că acest lucru vă va ajuta să rezolvați odată pentru totdeauna provocările validării adecvate și gestionării erorilor. Vă rugăm să nu ezitați să lăsați comentariile dvs. și să vă împărtășiți gândurile mai jos.