Walidacja kontekstu w projektowaniu opartym na domenie
Opublikowany: 2022-03-11Projektowanie oparte na domenie (w skrócie DDD) nie jest technologią ani metodologią. DDD zapewnia strukturę praktyk i terminologii do podejmowania decyzji projektowych, które koncentrują i przyspieszają projekty oprogramowania dotyczące skomplikowanych dziedzin. Jak opisali Eric Evans i Martin Fowler, obiekty domeny są miejscem, w którym można umieścić reguły walidacji i logikę biznesową.
Eric Evans:
Warstwa domeny (lub warstwa modelu): odpowiedzialna za reprezentowanie koncepcji biznesowych, informacji o sytuacji biznesowej i reguł biznesowych. Stan odzwierciedlający sytuację biznesową jest tutaj kontrolowany i wykorzystywany, mimo że szczegóły techniczne jego przechowywania są delegowane do infrastruktury. Ta warstwa jest sercem oprogramowania biznesowego.
Martina Fowlera:
Logika, która powinna znajdować się w obiekcie domeny, to logika domeny — walidacje, obliczenia, reguły biznesowe — jakkolwiek chcesz to nazwać.
Umieszczenie wszystkich walidacji w obiektach domeny powoduje powstanie ogromnych i złożonych obiektów domeny, z którymi można pracować. Osobiście zdecydowanie wolę pomysł rozdzielenia walidacji domen na oddzielne komponenty walidatora, które można ponownie wykorzystać w dowolnym momencie i które będą oparte na kontekście i działaniach użytkownika.
Jak napisał Martin Fowler w świetnym artykule: ContextualValidation.
Jedną z powszechnych rzeczy, które ludzie robią, jest opracowywanie procedur sprawdzania poprawności obiektów. Te procedury występują na różne sposoby, mogą znajdować się w obiekcie lub na zewnątrz, mogą zwracać wartość logiczną lub rzucać wyjątek wskazujący niepowodzenie. Myślę, że jedną rzeczą, o której myślę, że ciągle potykają ludzi, jest myślenie o ważności obiektu w sposób niezależny od kontekstu, jak sugeruje metoda isValid. […] Myślę, że o wiele bardziej przydatne jest myślenie o walidacji jako o czymś, co jest związane z kontekstem, zazwyczaj czynnością, którą chcesz wykonać. Na przykład pytanie, czy to zamówienie jest ważne do zrealizowania, lub czy ten klient ma prawo zameldować się w hotelu. Więc zamiast mieć metody takie jak isValid, używaj metod takich jak isValidForCheckIn.
Propozycja weryfikacji działania
W tym artykule zaimplementujemy prosty interfejs ItemValidator, dla którego należy zaimplementować metodę walidacji ze zwracanym typem ValidationResult . ValidationResult to obiekt zawierający element, który został zweryfikowany, a także obiekt Messages . Ten ostatni zawiera nagromadzenie błędów, ostrzeżeń i stanów (komunikatów) sprawdzania poprawności informacji, zależnych od kontekstu wykonania.
Walidatory to oddzielone komponenty, które można łatwo ponownie wykorzystać w dowolnym miejscu. Dzięki takiemu podejściu wszystkie zależności, które są potrzebne do sprawdzania poprawności, można łatwo wstrzyknąć. Na przykład, aby sprawdzić w bazie, czy istnieje użytkownik o podanym adresie e-mail, używany jest tylko UserDomainService.
Oddzielenie walidatorów będzie odbywać się w zależności od kontekstu (działania). Jeśli więc akcja UserCreate i akcja UserUpdate będą miały rozłączone komponenty lub jakiekolwiek inne działanie (UserActivate, UserDelete, AdCampaignLaunch itp.), walidacja może gwałtownie wzrosnąć.
Każdy walidator akcji powinien mieć odpowiadający model akcji, który będzie zawierał tylko dozwolone pola akcji. Do tworzenia użytkowników potrzebne są następujące pola:
UserCreateModel:
{ "firstName": "John", "lastName": "Doe", "email": "[email protected]", "password": "MTIzNDU=" }
Aby zaktualizować użytkownika, dozwolone są następujące opcje externalId , firstName i lastName . externalId służy do identyfikacji użytkownika i dozwolona jest tylko zmiana imienia i nazwiska .
Model aktualizacji użytkownika:
{ "externalId": "a55ccd60-9d82-11e5-9f52-0002a5d5c51b", "firstName": "John Updated", "lastName": "Doe Updated" }
Walidacje integralności pól mogą być udostępniane, maksymalna długość imienia to zawsze 255 znaków.
Podczas walidacji pożądane jest nie tylko uzyskanie pierwszego błędu, który wystąpi, ale także lista wszystkich napotkanych problemów. Na przykład następujące 3 problemy mogą wystąpić jednocześnie i można je odpowiednio zgłosić podczas realizacji:
- nieprawidłowy format adresu [BŁĄD]
- adres e-mail musi być unikalny wśród użytkowników [BŁĄD]
- hasło za krótkie [BŁĄD]
Aby osiągnąć ten rodzaj walidacji, potrzebne jest coś takiego jak konstruktor stanu walidacji, i w tym celu wprowadzane są Wiadomości . Przekazy to koncepcja, którą usłyszałem od mojego wielkiego mentora wiele lat temu, kiedy wprowadził ją, aby wspierać walidację, a także różne inne rzeczy, które można z nią zrobić, ponieważ Przekazy nie służą tylko do walidacji.
Zauważ, że w kolejnych sekcjach będziemy używać Scali do zilustrowania implementacji. Na wypadek, gdybyś nie był ekspertem Scala, nie obawiaj się, ponieważ powinno być łatwe do naśladowania.
Wiadomości w walidacji kontekstu
Messages to obiekt, który reprezentuje konstruktora stanu walidacji. Zapewnia łatwy sposób zbierania błędów, ostrzeżeń i komunikatów informacyjnych podczas walidacji. Każdy obiekt Messages ma wewnętrzną kolekcję obiektów Message , a także może mieć odwołanie do obiektu parentMessages .
Obiekt Message to obiekt, który może mieć type , messageText , key (który jest opcjonalny i jest używany do obsługi walidacji określonych danych wejściowych, które są identyfikowane przez identyfikator), a na koniec childMessages , który zapewnia świetny sposób na budowanie kompostowalnych drzew komunikatów.
Wiadomość może być jednego z następujących typów:
- Informacja
- Ostrzeżenie
- Błąd
Komunikaty o takiej strukturze pozwalają nam budować je iteracyjnie, a także umożliwiają podejmowanie decyzji o następnych działaniach w oparciu o poprzedni stan komunikatów. Na przykład przeprowadzanie walidacji podczas tworzenia użytkownika:
@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 ) } }
Zaglądając do tego kodu, możesz zobaczyć użycie ValidateUtils. Te funkcje narzędziowe są używane do wypełniania obiektu Messages we wstępnie zdefiniowanych przypadkach. Możesz sprawdzić implementację ValidateUtils na kodzie Github.
Podczas walidacji wiadomości e-mail najpierw sprawdza się, czy wiadomość e-mail jest poprawna, wywołując ValidateUtils.validateEmail(… , a także sprawdza się, czy e-mail ma prawidłową długość, wywołując ValidateUtils.validateLengthIsLessThanOrEqual(… . Po wykonaniu tych dwóch walidacji, sprawdzanie, czy wiadomość e-mail jest już przypisane do jakiegoś użytkownika, jest wykonywane tylko wtedy, gdy przechodzą poprzednie warunki walidacji poczty e-mail i odbywa się to za pomocą if(!localMessages.hasErrors()) { … .W ten sposób można uniknąć kosztownych wywołań bazy danych.Jest to tylko część UserCreateValidator Pełny kod źródłowy można znaleźć tutaj.
Zauważ, że wyróżnia się jeden z parametrów walidacji: UserCreateEntity.EMAIL_FORM_ID . Ten parametr łączy stan sprawdzania poprawności z określonym identyfikatorem wejścia.
W poprzednich przykładach kolejna akcja jest podejmowana na podstawie faktu, czy obiekt Messages zawiera błędy (przy użyciu metody hasErrors). Można łatwo sprawdzić, czy są jakieś komunikaty „OSTRZEŻENIE” i w razie potrzeby spróbować ponownie.

Jedną z rzeczy, które można zauważyć, jest sposób użycia localMessages . Wiadomości lokalne to wiadomości, które zostały utworzone tak samo, jak każda wiadomość, ale z parentMessages. Mając to na uwadze, celem jest posiadanie odniesienia tylko do bieżącego stanu walidacji (w tym przykładzie emailValidation), więc można wywołać localMessages.hasErrors , gdzie jest to sprawdzane tylko wtedy, gdy kontekst emailValidation ma błędy. Również gdy wiadomość jest dodawana do localMessages, jest ona również dodawana do parentMessages, więc wszystkie wiadomości weryfikacyjne istnieją w wyższym kontekście UserCreateValidation.
Teraz, gdy widzieliśmy, jak działają Wiadomości, w następnym rozdziale skupimy się na ItemValidatorze.
ItemValidator — składnik walidacji wielokrotnego użytku
ItemValidator to prosta cecha (interfejs), która zmusza programistów do zaimplementowania metody validate , która musi zwrócić ValidationResult.
Walidator pozycji:
trait ItemValidator[T] { def validate(item:T) : ValidationResult[T] }
Wynik walidacji:
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) ) } }
Gdy ItemValidators, takie jak UserCreateValidator, są zaimplementowane jako komponenty wstrzykiwania zależności, obiekty ItemValidator mogą być wstrzykiwane i ponownie używane w dowolnym obiekcie, który wymaga walidacji akcji UserCreate.
Po wykonaniu walidacji sprawdzane jest, czy walidacja się powiodła. Jeśli tak, dane użytkownika są utrwalane w bazie danych, ale jeśli nie, zwracana jest odpowiedź API zawierająca błędy walidacji.
W kolejnej sekcji zobaczymy, jak możemy przedstawić błędy walidacji w odpowiedzi RESTful API, a także jak komunikować się z konsumentami API o stanach akcji wykonania.
Ujednolicona odpowiedź API — prosta interakcja z użytkownikiem
Po pomyślnym zweryfikowaniu akcji użytkownika, w naszym przypadku utworzenia użytkownika, wyniki akcji walidacji muszą zostać wyświetlone konsumentowi RESTful API. Najlepszym sposobem jest posiadanie zunifikowanej odpowiedzi API, w której przełączany będzie tylko kontekst (w kategoriach JSON, wartość „data”). Dzięki ujednoliconym odpowiedziom błędy można łatwo przedstawić konsumentom interfejsu RESTful API.
Ujednolicona struktura odpowiedzi:
{ "messages" : { "global" : { "info": [], "warnings": [], "errors": [] }, "local" : [] }, "data":{} }
Ujednolicona odpowiedź jest skonstruowana tak, aby składać się z dwóch warstw komunikatów, globalnego i lokalnego. Komunikaty lokalne to komunikaty powiązane z określonymi wejściami. Na przykład „nazwa użytkownika jest za długa, dozwolone jest maksymalnie 80 znaków”_. Wiadomości globalne to wiadomości, które odzwierciedlają stan wszystkich danych na stronie, np. „użytkownik nie będzie aktywny do czasu zatwierdzenia”. Komunikaty lokalne i globalne mają trzy poziomy - błąd, ostrzeżenie i informacja. Wartość „danych” zależy od kontekstu. Podczas tworzenia użytkowników pole danych będzie zawierało dane użytkownika, ale podczas pobierania listy użytkowników pole danych będzie tablicą użytkowników.
Dzięki tego rodzaju ustrukturyzowanej odpowiedzi można łatwo utworzyć procedurę obsługi interfejsu użytkownika klienta, która będzie odpowiedzialna za wyświetlanie błędów, ostrzeżeń i komunikatów informacyjnych. Komunikaty globalne będą wyświetlane na górze strony, ponieważ są powiązane ze stanem akcji globalnego API, a komunikaty lokalne mogą być wyświetlane w pobliżu określonego wejścia (pola), ponieważ są bezpośrednio związane z wartością pola. Komunikaty o błędach mogą być prezentowane w kolorze czerwonym, komunikaty ostrzegawcze w kolorze żółtym, a informacje w kolorze niebieskim.
Na przykład w aplikacji klienckiej opartej na AngularJS możemy mieć dwie dyrektywy odpowiedzialne za obsługę lokalnych i globalnych komunikatów odpowiedzi, dzięki czemu tylko te dwie procedury obsługi mogą obsługiwać wszystkie odpowiedzi w spójny sposób.
Dyrektywa dla wiadomości lokalnej będzie musiała być zastosowana do elementu nadrzędnego do rzeczywistego elementu zawierającego wszystkie wiadomości.
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); }); }); } } } } } } })();
Dyrektywa dla komunikatów globalnych zostanie uwzględniona w głównym dokumencie układu (index.html) i zarejestruje się w zdarzeniu do obsługi wszystkich komunikatów globalnych.
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); }); } } } } } } })();
Aby uzyskać pełniejszy przykład, rozważmy następującą odpowiedź zawierającą wiadomość lokalną:
{ "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=" } }
Powyższa odpowiedź może prowadzić do czegoś w następujący sposób:
Z drugiej strony z globalnym przesłaniem w odpowiedzi:
{ "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]" } }
Aplikacja kliencka może teraz wyświetlać komunikat z większą widocznością:
W powyższych przykładach można zobaczyć, jak ujednolicona struktura odpowiedzi może być obsługiwana dla dowolnego żądania z tym samym programem obsługi.
Wniosek
Stosowanie walidacji w dużych projektach może być mylące, a reguły walidacji można znaleźć wszędzie w kodzie projektu. Utrzymanie spójnej i dobrze zorganizowanej walidacji sprawia, że rzeczy są łatwiejsze i nadają się do ponownego użycia.
Pomysły te zaimplementowane w dwóch różnych wersjach schematów można znaleźć poniżej:
- Standard Play 2.3, Scala 2.11.1, Slick 2.1, Postgres 9.1, Spring Dependency Injection
- Reaktywny nieblokujący Play 2.4, Scala 2.11.7, Slick 3.0, Postgres 9.4, Guice Dependency Injection
W tym artykule przedstawiłem swoje sugestie, jak wspierać głębokie, komponowalne sprawdzanie poprawności kontekstu, które można łatwo zaprezentować użytkownikowi. Mam nadzieję, że raz na zawsze pomoże to w rozwiązaniu problemów związanych z właściwą walidacją i obsługą błędów. Zachęcamy do pozostawienia komentarzy i podzielenia się swoimi przemyśleniami poniżej.