Convalida del contesto nella progettazione basata sul dominio
Pubblicato: 2022-03-11La progettazione basata sul dominio (in breve DDD) non è una tecnologia o una metodologia. DDD fornisce una struttura di pratiche e terminologia per prendere decisioni di progettazione che concentrano e accelerano i progetti software che trattano domini complicati. Come descritto da Eric Evans e Martin Fowler, gli oggetti Domain sono un luogo in cui inserire regole di convalida e logica aziendale.
Eric Evans:
Livello di dominio (o livello di modello): responsabile della rappresentazione di concetti aziendali, informazioni sulla situazione aziendale e regole aziendali. Lo stato che riflette la situazione aziendale viene qui controllato e utilizzato, anche se i dettagli tecnici per l'archiviazione sono delegati all'infrastruttura. Questo livello è il cuore del software aziendale.
Martin Fowler:
La logica che dovrebbe trovarsi in un oggetto di dominio è la logica di dominio - convalide, calcoli, regole aziendali - qualunque cosa tu voglia chiamarla.
L'inserimento di tutta la convalida negli oggetti di dominio si traduce in oggetti di dominio enormi e complessi con cui lavorare. Personalmente preferisco di gran lunga l'idea di disaccoppiare le convalide dei domini in componenti di convalida separati che possono essere riutilizzati in qualsiasi momento e che saranno basati sul contesto e sull'azione dell'utente.
Come ha scritto Martin Fowler in un ottimo articolo: ContextualValidation.
Una cosa comune che vedo fare è sviluppare routine di convalida per gli oggetti. Queste routine sono disponibili in vari modi, possono essere nell'oggetto o esterne, possono restituire un valore booleano o generare un'eccezione per indicare un errore. Una cosa che penso faccia costantemente inciampare le persone è quando pensano alla validità dell'oggetto in un modo indipendente dal contesto, come implica un metodo isValid. […] Penso che sia molto più utile pensare alla convalida come qualcosa che è legato a un contesto, tipicamente un'azione che vuoi fare. Come chiedere se questo ordine è valido per essere compilato, o se questo cliente è valido per il check-in in hotel. Quindi, invece di avere metodi come isValid, usa metodi come isValidForCheckIn.
Proposta di convalida dell'azione
In questo articolo implementeremo una semplice interfaccia ItemValidator per la quale è necessario implementare un metodo di convalida con tipo restituito ValidationResult . ValidationResult è un oggetto contenente l'elemento che è stato convalidato e anche l'oggetto Messaggi . Quest'ultimo contiene un accumulo di errori, avvisi e stati di convalida delle informazioni (messaggi) dipendenti dal contesto di esecuzione.
I validatori sono componenti disaccoppiati che possono essere facilmente riutilizzati ovunque siano necessari. Con questo approccio tutte le dipendenze, necessarie per i controlli di convalida, possono essere facilmente iniettate. Ad esempio, per controllare nel database se è presente un utente con un'e-mail specificata, viene utilizzato solo UserDomainService.
Il disaccoppiamento dei validatori avverrà per contesto (azione). Pertanto, se l'azione UserCreate e l'azione UserUpdate avranno componenti disaccoppiati o qualsiasi altra azione (UserActivate, UserDelete, AdCampaignLaunch e così via), la convalida può aumentare rapidamente.
Ogni validatore di azione dovrebbe avere un modello di azione corrispondente che avrà solo i campi di azione consentiti. Per la creazione degli utenti sono necessari i seguenti campi:
UserCreateModel:
{ "firstName": "John", "lastName": "Doe", "email": "[email protected]", "password": "MTIzNDU=" }
E per aggiornare l'utente sono consentiti externalId , firstName e lastName . externalId viene utilizzato per l'identificazione dell'utente ed è consentita solo la modifica di firstName e lastName .
Modello di aggiornamento utente:
{ "externalId": "a55ccd60-9d82-11e5-9f52-0002a5d5c51b", "firstName": "John Updated", "lastName": "Doe Updated" }
Le convalide dell'integrità del campo possono essere condivise, la lunghezza massima di firstName è sempre di 255 caratteri.
Durante la convalida è auspicabile non solo ottenere il primo errore che si verifica, ma anche un elenco di tutti i problemi riscontrati. Ad esempio, i seguenti 3 problemi possono verificarsi contemporaneamente e possono essere segnalati di conseguenza durante l'esecuzione:
- formato indirizzo non valido [ERRORE]
- l'email deve essere univoca tra gli utenti [ERRORE]
- password troppo breve [ERRORE]
Per ottenere quel tipo di convalida, è necessario qualcosa come il generatore di stato di convalida e a tale scopo viene introdotto Messaggi . Messaggi è un concetto che ho sentito dal mio grande mentore anni fa, quando lo ha introdotto per supportare la convalida e anche per varie altre cose che possono essere fatte con esso, poiché i messaggi non servono solo per la convalida.
Si noti che nelle sezioni seguenti useremo Scala per illustrare l'implementazione. Nel caso in cui tu non sia un esperto di Scala, non temere perché dovrebbe essere comunque facile da seguire.
Messaggi nella convalida del contesto
Messaggi è un oggetto che rappresenta il generatore di stato di convalida. Fornisce un modo semplice per raccogliere errori, avvisi e messaggi informativi durante la convalida. Ogni oggetto Messaggi ha una raccolta interna di oggetti Messaggio e può anche avere un riferimento all'oggetto parentMessages .
Un oggetto Message è un oggetto che può avere type , messageText , key (che è facoltativo e viene utilizzato per supportare la convalida di input specifici identificati dall'identificatore) e infine childMessages che fornisce un ottimo modo per costruire alberi di messaggi componibili.
Un messaggio può essere di uno dei seguenti tipi:
- Informazione
- Avvertimento
- Errore
I messaggi strutturati in questo modo ci consentono di costruirli in modo iterativo e consentono anche di prendere decisioni sulle azioni successive in base allo stato dei messaggi precedenti. Ad esempio, eseguire la convalida durante la creazione dell'utente:
@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 ) } }
Esaminando questo codice, puoi vedere l'uso di ValidateUtils. Queste funzioni di utilità vengono utilizzate per popolare l'oggetto Messaggi in casi predefiniti. Puoi controllare l'implementazione di ValidateUtils sul codice Github.
Durante la convalida dell'e-mail, prima viene verificato se l'e-mail è valida chiamando ValidateUtils.validateEmail(… , e viene anche verificato se l'e-mail ha una lunghezza valida chiamando ValidateUtils.validateLengthIsLessThanOrEqual(… . Una volta terminate queste due convalide, controllando se l'e-mail è già assegnato a qualche Utente viene eseguito, solo se le condizioni di convalida dell'e-mail precedenti sono soddisfatte e ciò viene fatto con if(!localMessages.hasErrors()) { … . In questo modo è possibile evitare costose chiamate al database. Questa è solo una parte di UserCreateValidator Il codice sorgente completo può essere trovato qui.
Si noti che uno dei parametri di convalida spicca: UserCreateEntity.EMAIL_FORM_ID . Questo parametro collega lo stato di convalida a un ID di input specifico.

Negli esempi precedenti, l'azione successiva viene decisa in base al fatto se l'oggetto Messaggi ha errori (usando il metodo hasErrors). Si può facilmente verificare se ci sono messaggi di "AVVISO" e riprovare se necessario.
Una cosa che si può notare è il modo in cui viene utilizzato localMessages . I messaggi locali sono messaggi che sono stati creati come qualsiasi altro messaggio, ma con parentMessages. Detto questo, l'obiettivo è avere un riferimento solo allo stato di convalida corrente (in questo esempio emailValidation), quindi è possibile chiamare localMessages.hasErrors , dove viene verificato solo se il contesto emailValidation hasErrors. Inoltre, quando un messaggio viene aggiunto a localMessages, viene aggiunto anche a parentMessages e quindi tutti i messaggi di convalida esistono in un contesto superiore di UserCreateValidation.
Ora che abbiamo visto i messaggi in azione, nel prossimo capitolo ci concentreremo su ItemValidator.
ItemValidator - Componente di convalida riutilizzabile
ItemValidator è un tratto semplice (interfaccia) che obbliga gli sviluppatori a implementare il metodo validate , che deve restituire ValidationResult.
Convalida articolo:
trait ItemValidator[T] { def validate(item:T) : ValidationResult[T] }
Risultato di convalida:
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) ) } }
Quando ItemValidator come UserCreateValidator vengono implementati come componenti di inserimento delle dipendenze, gli oggetti ItemValidator possono essere inseriti e riutilizzati in qualsiasi oggetto che richiede la convalida dell'azione UserCreate.
Dopo l'esecuzione della convalida, viene verificato se la convalida ha avuto esito positivo. In tal caso, i dati utente vengono mantenuti nel database, ma in caso contrario viene restituita la risposta API contenente errori di convalida.
Nella prossima sezione vedremo come presentare errori di convalida nella risposta dell'API RESTful e anche come comunicare con i consumatori dell'API sugli stati dell'azione di esecuzione.
Risposta API unificata - Interazione utente semplice
Dopo che l'azione dell'utente è stata convalidata correttamente, nel nostro caso la creazione dell'utente, i risultati dell'azione di convalida devono essere visualizzati al consumatore dell'API RESTful. Il modo migliore è avere una risposta API unificata in cui verrà cambiato solo il contesto (in termini di JSON, valore di "dati"). Con le risposte unificate, gli errori possono essere presentati facilmente ai consumatori dell'API RESTful.
Struttura di risposta unificata:
{ "messages" : { "global" : { "info": [], "warnings": [], "errors": [] }, "local" : [] }, "data":{} }
La risposta unificata è strutturata per avere due livelli di messaggi, globale e locale. I messaggi locali sono messaggi accoppiati a input specifici. Ad esempio "il nome utente è troppo lungo, sono consentiti al massimo 80 caratteri"_. I messaggi globali sono messaggi che riflettono lo stato di tutti i dati sulla pagina, ad esempio "l'utente non sarà attivo fino all'approvazione". I messaggi locali e globali hanno tre livelli: errore, avviso e informazioni. Il valore dei "dati" è specifico del contesto. Quando si creano utenti, il campo dati conterrà dati utente, ma quando si ottiene un elenco di utenti il campo dati sarà un array di utenti.
Con questo tipo di risposta strutturata è possibile creare facilmente il gestore dell'interfaccia utente del client, che sarà responsabile della visualizzazione di errori, avvisi e messaggi informativi. I messaggi globali verranno visualizzati nella parte superiore della pagina, poiché sono correlati allo stato dell'azione API globale e i messaggi locali possono essere visualizzati vicino all'input (campo) specificato, poiché sono direttamente correlati al valore del campo. I messaggi di errore possono essere presentati in rosso, i messaggi di avviso in giallo e le informazioni in blu.
Ad esempio, in un'app client basata su AngularJS possiamo avere due direttive responsabili della gestione dei messaggi di risposta locali e globali, in modo che solo questi due gestori possano gestire tutte le risposte in modo coerente.
La direttiva per il messaggio locale dovrà essere applicata a un elemento padre dell'elemento effettivo che contiene tutti i messaggi.
localmessages.directitive.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); }); }); } } } } } } })();
La direttiva per i messaggi globali sarà inclusa nel documento di layout principale (index.html) e si registrerà in un evento per la gestione di tutti i messaggi globali.
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); }); } } } } } } })();
Per un esempio più completo, consideriamo la seguente risposta contenente un messaggio locale:
{ "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=" } }
La risposta di cui sopra può portare a qualcosa come segue:
D'altra parte, con un messaggio globale in risposta:
{ "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]" } }
L'app client ora può mostrare il messaggio con maggiore risalto:
Negli esempi precedenti si può vedere come una struttura di risposta unificata può essere gestita per qualsiasi richiesta con lo stesso gestore.
Conclusione
L'applicazione della convalida su progetti di grandi dimensioni può creare confusione e le regole di convalida possono essere trovate ovunque nel codice del progetto. Mantenere la convalida coerente e ben strutturata rende le cose più facili e riutilizzabili.
Puoi trovare queste idee implementate in due diverse versioni di boilerplate di seguito:
- Standard Play 2.3, Scala 2.11.1, Slick 2.1, Postgres 9.1, Spring Dependency Injection
- Reattivo non bloccante Play 2.4, Scala 2.11.7, Slick 3.0, Postgres 9.4, Guice Dependency Injection
In questo articolo ho presentato i miei suggerimenti su come supportare una convalida del contesto profonda e componibile che può essere facilmente presentata a un utente. Spero che questo ti aiuterà a risolvere le sfide di una corretta convalida e gestione degli errori una volta per tutte. Sentiti libero di lasciare i tuoi commenti e condividere i tuoi pensieri qui sotto.