Kontextvalidierung im domänengesteuerten Design
Veröffentlicht: 2022-03-11Domain-Driven Design (kurz DDD) ist keine Technologie oder Methodik. DDD bietet eine Struktur von Praktiken und Terminologie zum Treffen von Entwurfsentscheidungen, die Softwareprojekte, die sich mit komplizierten Domänen befassen, fokussieren und beschleunigen. Wie von Eric Evans und Martin Fowler beschrieben, sind Domänenobjekte ein Ort, an dem Validierungsregeln und Geschäftslogik abgelegt werden.
Eric Evans:
Domänenschicht (oder Modellschicht): Verantwortlich für die Darstellung von Geschäftskonzepten, Informationen über die Geschäftssituation und Geschäftsregeln. Der Zustand, der die Geschäftslage widerspiegelt, wird hier kontrolliert und verwendet, obwohl die technischen Details der Speicherung an die Infrastruktur delegiert werden. Diese Schicht ist das Herzstück der Unternehmenssoftware.
Martin Fowler:
Die Logik, die in einem Domänenobjekt enthalten sein sollte, ist Domänenlogik – Validierungen, Berechnungen, Geschäftsregeln – wie auch immer Sie es nennen möchten.
Die gesamte Validierung in Domänenobjekte zu stecken, führt zu riesigen und komplexen Domänenobjekten, mit denen gearbeitet werden muss. Persönlich bevorzuge ich die Idee, Domänenvalidierungen in separate Validierungskomponenten zu entkoppeln, die jederzeit wiederverwendet werden können und die auf Kontext und Benutzeraktion basieren.
Wie Martin Fowler in einem großartigen Artikel schrieb: ContextualValidation.
Eine häufige Sache, die ich sehe, ist die Entwicklung von Validierungsroutinen für Objekte. Diese Routinen kommen auf verschiedene Arten, sie können im Objekt oder extern sein, sie können einen booleschen Wert zurückgeben oder eine Ausnahme auslösen, um einen Fehler anzuzeigen. Eine Sache, von der ich denke, dass sie Leute ständig stolpert, ist, wenn sie kontextunabhängig an Objektvalidität denken, wie es eine isValid-Methode impliziert. […] Ich denke, es ist viel nützlicher, Validierung als etwas zu betrachten, das an einen Kontext gebunden ist, typischerweise eine Aktion, die Sie ausführen möchten. Fragen Sie beispielsweise, ob diese Bestellung gültig ist, um ausgeführt zu werden, oder ob dieser Kunde gültig ist, um im Hotel einzuchecken. Anstelle von Methoden wie isValid sollten Sie also Methoden wie isValidForCheckIn verwenden.
Aktionsvalidierungsvorschlag
In diesem Artikel implementieren wir eine einfache Schnittstelle ItemValidator, für die Sie eine Validierungsmethode mit dem Rückgabetyp ValidationResult implementieren müssen. ValidationResult ist ein Objekt, das das validierte Element sowie das Messages -Objekt enthält. Letzteres enthält abhängig vom Ausführungskontext eine Anhäufung von Fehlern, Warnungen und Informationsvalidierungszuständen (Meldungen).
Validatoren sind entkoppelte Komponenten, die einfach überall dort wiederverwendet werden können, wo sie benötigt werden. Mit diesem Ansatz können alle Abhängigkeiten, die für Validierungsprüfungen benötigt werden, einfach injiziert werden. Um beispielsweise in der Datenbank zu überprüfen, ob es einen Benutzer mit einer bestimmten E-Mail-Adresse gibt, wird nur UserDomainService verwendet.
Die Entkopplung der Validatoren erfolgt pro Kontext (Aktion). Wenn also die UserCreate-Aktion und die UserUpdate-Aktion entkoppelte Komponenten oder andere Aktionen (UserActivate, UserDelete, AdCampaignLaunch usw.) haben, kann die Validierung schnell zunehmen.
Jeder Aktionsvalidierer sollte ein entsprechendes Aktionsmodell haben, das nur die zulässigen Aktionsfelder hat. Zum Anlegen von Benutzern werden folgende Felder benötigt:
UserCreateModel:
{ "firstName": "John", "lastName": "Doe", "email": "[email protected]", "password": "MTIzNDU=" }
Und um den Benutzer zu aktualisieren, sind externalId , firstName und lastName zulässig . externalId wird zur Benutzeridentifikation verwendet und nur das Ändern von firstName und lastName ist erlaubt.
UserUpdateModel:
{ "externalId": "a55ccd60-9d82-11e5-9f52-0002a5d5c51b", "firstName": "John Updated", "lastName": "Doe Updated" }
Feldintegritätsvalidierungen können geteilt werden, die maximale Länge von firstName beträgt immer 255 Zeichen.
Während der Validierung ist es wünschenswert, nicht nur den ersten aufgetretenen Fehler zu erhalten, sondern eine Liste aller aufgetretenen Probleme. Beispielsweise können die folgenden 3 Probleme gleichzeitig auftreten und während der Ausführung entsprechend gemeldet werden:
- ungültiges Adressformat [FEHLER]
- E-Mail muss unter Benutzern eindeutig sein [FEHLER]
- Passwort zu kurz [FEHLER]
Um diese Art von Validierung zu erreichen, wird so etwas wie ein Validation State Builder benötigt, und zu diesem Zweck wird Messages eingeführt. Messages ist ein Konzept, das ich vor Jahren von meinem großartigen Mentor gehört habe, als er es zur Unterstützung der Validierung und auch für verschiedene andere Dinge, die damit gemacht werden können, einführte, da Messages nicht nur zur Validierung da sind.
Beachten Sie, dass wir in den folgenden Abschnitten Scala verwenden, um die Implementierung zu veranschaulichen. Nur für den Fall, dass Sie kein Scala-Experte sind, fürchten Sie sich nicht, da es trotzdem einfach sein sollte, ihm zu folgen.
Nachrichten in der Kontextvalidierung
Messages ist ein Objekt, das den Validation State Builder darstellt. Es bietet eine einfache Möglichkeit, Fehler, Warnungen und Informationsmeldungen während der Validierung zu sammeln. Jedes Messages -Objekt hat eine innere Sammlung von Message -Objekten und kann auch einen Verweis auf das parentMessages- Objekt haben.
Ein Message-Objekt ist ein Objekt, das type , messageText , key (was optional ist und verwendet wird, um die Validierung bestimmter Eingaben zu unterstützen, die durch Bezeichner identifiziert werden) und schließlich childMessages haben kann , was eine großartige Möglichkeit zum Erstellen zusammensetzbarer Nachrichtenbäume bietet.
Eine Nachricht kann einen der folgenden Typen haben:
- Information
- Warnung
- Fehler
Nachrichten, die so strukturiert sind, ermöglichen es uns, sie iterativ zu erstellen und Entscheidungen über die nächsten Aktionen basierend auf dem Status früherer Nachrichten zu treffen. Führen Sie beispielsweise eine Validierung während der Benutzererstellung durch:
@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 ) } }
Wenn Sie sich diesen Code ansehen, können Sie die Verwendung von ValidateUtils erkennen. Diese Hilfsfunktionen werden verwendet, um das Messages-Objekt in vordefinierten Fällen zu füllen. Sie können sich die Implementierung von ValidateUtils im Github-Code ansehen.
Während der E-Mail-Validierung wird zuerst überprüft, ob die E-Mail gültig ist, indem ValidateUtils.validateEmail(… aufgerufen wird, und es wird auch überprüft, ob die E-Mail eine gültige Länge hat, indem ValidateUtils.validateLengthIsLessThanOrEqual(… aufgerufen wird. Sobald diese beiden Validierungen abgeschlossen sind, wird überprüft, ob die E-Mail bereits einem Benutzer zugewiesen ist, wird nur ausgeführt, wenn die vorherigen E-Mail-Validierungsbedingungen erfüllt sind, und das geschieht mit if(!localMessages.hasErrors()) { … . Auf diese Weise können teure Datenbankaufrufe vermieden werden. Dies ist nur ein Teil von UserCreateValidator Den vollständigen Quellcode finden Sie hier.
Beachten Sie, dass einer der Validierungsparameter hervorsticht: UserCreateEntity.EMAIL_FORM_ID . Dieser Parameter verbindet den Validierungsstatus mit einer bestimmten Eingabe-ID.

In den vorherigen Beispielen wird die nächste Aktion basierend auf der Tatsache entschieden, ob das Messages -Objekt Fehler aufweist (unter Verwendung der hasErrors-Methode). Man kann leicht überprüfen, ob „WARNING“-Meldungen vorhanden sind, und es bei Bedarf erneut versuchen.
Eine Sache, die bemerkt werden kann, ist die Art und Weise, wie localMessages verwendet wird. Lokale Nachrichten sind Nachrichten, die wie jede andere Nachricht erstellt wurden, jedoch mit parentMessages. Vor diesem Hintergrund besteht das Ziel darin, nur einen Verweis auf den aktuellen Validierungsstatus zu haben (in diesem Beispiel emailValidation), sodass localMessages.hasErrors aufgerufen werden kann, wo nur überprüft wird, ob der emailValidation-Kontext hasErrors ist. Auch wenn eine Nachricht zu localMessages hinzugefügt wird, wird sie auch zu parentMessages hinzugefügt, sodass alle Validierungsnachrichten im höheren Kontext von UserCreateValidation vorhanden sind.
Nachdem wir Messages in Aktion gesehen haben, konzentrieren wir uns im nächsten Kapitel auf ItemValidator.
ItemValidator - Wiederverwendbare Validierungskomponente
ItemValidator ist eine einfache Eigenschaft (Schnittstelle), die Entwickler dazu zwingt, die Methode validate zu implementieren, die ValidationResult zurückgeben muss.
ItemValidator:
trait ItemValidator[T] { def validate(item:T) : ValidationResult[T] }
Validierungsergebnis:
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) ) } }
Wenn ItemValidators wie UserCreateValidator als Abhängigkeitsinjektionskomponenten implementiert werden, können ItemValidator-Objekte injiziert und in jedem Objekt wiederverwendet werden, das eine UserCreate-Aktionsvalidierung benötigt.
Nach der Validierung wird geprüft, ob die Validierung erfolgreich war. Wenn dies der Fall ist, werden Benutzerdaten in der Datenbank gespeichert, aber wenn nicht, wird die API-Antwort mit Validierungsfehlern zurückgegeben.
Im nächsten Abschnitt werden wir sehen, wie wir Validierungsfehler in der RESTful-API-Antwort darstellen können und wie wir mit API-Konsumenten über Ausführungsaktionszustände kommunizieren können.
Einheitliche API-Antwort - Einfache Benutzerinteraktion
Nachdem die Benutzeraktion erfolgreich validiert wurde, in unserem Fall die Benutzererstellung, müssen die Ergebnisse der Validierungsaktion dem RESTful-API-Consumer angezeigt werden. Der beste Weg ist eine einheitliche API-Antwort, bei der nur der Kontext gewechselt wird (in Bezug auf JSON, Wert von „Daten“). Mit einheitlichen Antworten können RESTful-API-Konsumenten Fehler einfach angezeigt bekommen.
Einheitliche Antwortstruktur:
{ "messages" : { "global" : { "info": [], "warnings": [], "errors": [] }, "local" : [] }, "data":{} }
Unified Response ist so strukturiert, dass es zwei Ebenen von Nachrichten gibt, global und lokal. Lokale Nachrichten sind Nachrichten, die an bestimmte Eingänge gekoppelt sind. Zum Beispiel „Benutzername ist zu lang, maximal 80 Zeichen sind erlaubt“_. Globale Meldungen sind Meldungen, die den Status der gesamten Daten auf der Seite widerspiegeln, wie z. B. „Benutzer wird nicht aktiv sein, bis er genehmigt wurde“. Lokale und globale Meldungen haben drei Ebenen – Fehler, Warnung und Information. Der Wert von „Daten“ ist kontextspezifisch. Beim Erstellen von Benutzern enthält das Datenfeld Benutzerdaten, aber beim Abrufen einer Benutzerliste ist das Datenfeld ein Array von Benutzern.
Mit dieser Art von strukturierter Antwort kann der Client-UI-Handler einfach erstellt werden, der für die Anzeige von Fehlern, Warnungen und Informationsmeldungen verantwortlich ist. Globale Nachrichten werden oben auf der Seite angezeigt, da sie sich auf den globalen API-Aktionsstatus beziehen, und lokale Nachrichten können in der Nähe der angegebenen Eingabe (Feld) angezeigt werden, da sie direkt mit dem Wert des Felds zusammenhängen. Fehlermeldungen können in rot, Warnmeldungen in gelb und Informationen in blau dargestellt werden.
Beispielsweise können wir in einer AngularJS-basierten Client-App zwei Direktiven haben, die für die Verarbeitung lokaler und globaler Antwortnachrichten verantwortlich sind, sodass nur diese beiden Handler alle Antworten auf konsistente Weise verarbeiten können.
Die Direktive für die lokale Nachricht muss auf ein Element angewendet werden, das dem eigentlichen Element, das alle Nachrichten enthält, übergeordnet ist.
localmessages.direcitive.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); }); }); } } } } } } })();
Die Anweisung für globale Nachrichten wird in das Root-Layout-Dokument (index.html) aufgenommen und für ein Ereignis zur Behandlung aller globalen Nachrichten registriert.
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); }); } } } } } } })();
Betrachten wir für ein vollständigeres Beispiel die folgende Antwort, die eine lokale Nachricht enthält:
{ "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=" } }
Die obige Antwort kann zu folgendem führen:
Andererseits mit einer globalen Nachricht als Antwort:
{ "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]" } }
Die Client-App kann die Nachricht jetzt deutlicher anzeigen:
In den obigen Beispielen ist ersichtlich, wie eine einheitliche Antwortstruktur für jede Anfrage mit demselben Handler gehandhabt werden kann.
Fazit
Das Anwenden der Validierung auf große Projekte kann verwirrend werden, und Validierungsregeln können überall im Projektcode gefunden werden. Eine konsistente und gut strukturierte Validierung macht die Dinge einfacher und wiederverwendbar.
Nachfolgend finden Sie diese Ideen in zwei verschiedenen Versionen von Boilerplates umgesetzt:
- Standard Play 2.3, Scala 2.11.1, Slick 2.1, Postgres 9.1, Spring Dependency Injection
- Reaktives, nicht blockierendes Play 2.4, Scala 2.11.7, Slick 3.0, Postgres 9.4, Guice-Abhängigkeitsinjektion
In diesem Artikel habe ich meine Vorschläge präsentiert, wie eine tiefe, zusammensetzbare Kontextvalidierung unterstützt werden kann, die einem Benutzer leicht präsentiert werden kann. Ich hoffe, dies wird Ihnen helfen, die Herausforderungen der ordnungsgemäßen Validierung und Fehlerbehandlung ein für alle Mal zu lösen. Bitte hinterlassen Sie Ihre Kommentare und teilen Sie Ihre Gedanken unten mit.