Walidacja danych wejsciowych requesta

0

W jaki sposób podchodzicie do walidacji danych wejściowych?
Możemy założyć, że używamy springa i mamy api restowe.

Powiedzmy, że mamy prosty request do rejestracji użytkownika zawierający nick, email, password.
Chcielibyśmy na tym requescie odpalić zestaw jakichś walidacji np:

  • nick nie może być nullem i nie może być pusty
  • email nie może być nullem, nie może być pusty i musi być poprawnym emailem
  • password musi spełniać odpowiednie kryteria np. znaki specjalne, duże i małe litery itd.
  • nick nie może być zajęty
  • nie można 2x zarejestrować się na ten sam email

Możemy rozpocząć od zdefiniowania DTO:

public class UserRegistrationRequest {

    @NotBlank
    public final String nick;

    @NotBlank
    public final String email;

    @Email
    public final String password;
    
    @JsonCreator
    public UserRegistrationRequest(@JsonProperty("nick") String nick, 
                                   @JsonProperty("email") String email,
                                   @JsonProperty("password") String password) {
        this.nick = nick;
        this.email = email;
        this.password = password;
    }

}

Jak widać dodałem podstawową walidację jednak i tak nie spełnia ona wszystkich
kryteriów walidacji, które wymieniłem na początku więc muszę zwalidować dalej
w serwisie w większych szczegółach. Tu przy okazji czy lubicie tego typu walidację
przez adnotacje? Nie ma ona zbyt dużych możliwości chociaż z tego co wiem można
tworzyć customowe walidatory.

Idąc dalej mamy serwis i walidujemy dalej. I tutaj mamy wiele możliwości jak to walidować.
Możemy po prostu wstrzyknąć jakiś walidator, który nam zwaliduje request przychodzący,
który ma wstrzyknięte jakieś repository/dao (konstruktory pominięte dla czytelności).

public class UserRegistrationService {
    
    private final UserRegistrationRequestValidator requestValidator;
    
    public Either<AppError, UserRegistrationResponse> registerUser(UserRegistrationRequest request) {
         return requestValidator.validate(request).flatMap(this::register);
    }

    private Either<AppError, UserRegistrationResponse> register(UserRegistrationRequest request) {
        ...
    }
}

public class UserRegistrationRequestValidator {
    
    private final UserDao userDao;
    
    public Either<AppError, UserRegistrationRequest> validate(UserRegistrationRequest request) {
        return Either.right(request)
                     .flatMap(this::validateNick)
                     .flatMap(this::validateEmail)
                     .flatMap(this::validatePassword)
                     .flatMap(this::validateNickUniqueness)
                     .flatMap(this::validateEmailUniqueness);
    }
    
    //...

}

Kolejnym sposobem może być wykonanie tej walidacji w jakimś konwerterze albo factory,
który konwertuje / tworzy nam obiekt domenowy User:

public class UserRegistrationRequestConverter {
    
    public Either<AppError, User> convert(UserRegistrationRequest request) {
           return Either.right(request)
                     .flatMap(this::validateNick)
                     .flatMap(this::validateEmail)
                     .flatMap(this::validatePassword)
                     .flatMap(this::validateNickUniqueness)
                     .flatMap(this::validateEmailUniqueness)
                     .map(this::createUser);
    }
    //...
}

Z drugiej strony możemy też wstrzyknąć tamten walidator do tego konwertera
i napisać konwersję / factory walidując z wykorzystaniem tamtego walidatora.
Tym razem posłużę się factory:

public class UserFactory {
    
    private final UserRegistrationRequestValidator requestValidator;

    public Either<AppError, User> createUser(UserRegistrationRequest request) {
        return requestValidator.validate(request).flatMap(this::create);
    }    
    //...
}

Załóżmy jeszcze, że User jest value objectem więc mapujemy tak naprawdę na coś takiego:


public class User {

    private final Nick nick;
    private final Email email;
    private final Password password;
    
    public User(String nick, String email, String password) {
        this.nick = new Nick(nick);
        this.email = new Email(email);
        this.password = new Password(password);
    }
}

class Nick {
    private final String nick;

    public Nick(String nick) {
        if (!isValid(nick) {
            throw new IllegalArgumentException("Nick is invalid");
        }
        this.nick = nick;
    }
}

class Email {
    private final String email;

    public Email(String email) {
        if (!isValid(email) {
            throw new IllegalArgumentException("Email is invalid");
        }
        this.email = email;
    }
}

class Password {
    private final String password;

    public Password(String password) {
        if (!isValid(password) {
            throw new IllegalArgumentException("Password is invalid");
        }
        this.password = password;
    }
}

Czyli mamy jakiś obiekt domenowy User, który też ma jakąś walidację
powtórzoną (bo wcześniej już zwalidowaliśmy te dane używając requestValidatora)
natomiast tutaj już rzucamy wyjątkami żeby było pewne, że obiekty mają spójny
stan i zakładamy, że jeżeli przeszło wcześniejszą walidację tzn., że tutaj już dane muszą
być poprawne a jeżeli nie są to jest to błąd programisty dlatego też rzucamy wyjątkiem (a nie jakiś either).
Pytanie czy ma to sens bo walidacja tych pól jest wykonywana 2x raz
w requestValidator a raz w tych obiektach co nie jest zbyt optymalne?

Kolejna rzecz jaką możemy zrobić to statyczne metody fabrykujące w tych obiektach i Either:


public class User {

    public static Either<AppError, User> create(String nick, String email, String password) {
        var nickEither = Nick.create(nick);
        if (nickEither.isLeft()) {
            return Either.left(nickEither.getLeft());
        }

        var emailEither = Email.create(email);
        if (emailEither.isLeft()) {
            return Either.left(emailEither.getLeft());
        }

        var passwordEither = Password.create(password);
        if (passwordEither.isLeft()) {
            return Either.left(passwordEither.getLeft());
        }
        
        return Either.right(
            new User(nickEither.get(), emailEither.get(), passwordEither.get())
        );
    }

    private final Nick nick;
    private final Email email;
    private final Password password;
    
    private User(Nick nick, Email email, Password password) {
        this.nick = nick;
        this.email = email;
        this.password = password;
    }
}

class Nick {
    private final String nick;

    public static Either<AppError, Nick> create(String nick) {
        return isValid(nick)
                ? Either.right(new Nick(nick))
                : Either.left(AppError.nickNotValid(nick));
    }

    private Nick(String nick) {
        this.nick = nick;
    }

    private static boolean isValid(String nick) {
        //...
    }
}

class Email {
    private final String email;

    public static Either<String, Email> create(String email) {
        return isValid(email)
                ? Either.right(new Email(email))
                : Either.left(AppError.emailNotValid(email));
    }

    private Email(String email) {
        this.email = email;
    }

    private static boolean isValid(String email) {
        //...
    }
}

class Password {
    private final String password;
    
    public static Either<String, Password> create(String password) {
        return isValid(password)
                ? Either.right(new Password(password))
                : Either.left(AppError.passwordNotValid(password));
    }    

    public Password(String password) {
        this.password = password;
    }

    private static boolean isValid(String password) {
        //...
    }
}

I wtedy factory z walidatorem mogłoby wyglądać tak:

public class UserFactory {
    
    private final UserValidator userValidator;

    public Either<AppError, User> createUser(UserRegistrationRequest request) {
        return User.create(request.nick, request.email, request.password)
                   .flatMap(userValidator::validate);
    }
    
    //...
}

public class UserValidator {
    
    private final UserDao userDao;
    
    public Either<AppError, User> validate(User user) {
        return Either.right(user)
                     .flatMap(this::validateNickUniqueness)
                     .flatMap(this::validateEmailUniqueness);
    }
    
    //...

}

Jak widać w dalszym ciągu potrzebujemy walidatora
do sprawdzenia unikalności nick/email natomiast sama
walidacja wartości tych pól jest zamknięta w value objectach
i nie powtarza się 2x tak jak wcześniej.

Jestem ciekawy co myślicie o tych podejściach do walidacji
i jakie jest wasze preferowane podejście?

1

Moim zdaniem najlepsze podejście wygląda tak, że użytkownik wysyła request (jakieś dto), potem ten request trafia do handlera, w którym jest obsługiwany prze wywoływanie odpowiednych method z obiektów domenowych i teraz uwaga - które mają podane jako argument odpowiednie polityki (np. polityki walidacji czyli de facto walidatory). Trzeba też odróżnić logikę domenową od logiki zapisu danych. Dlatego najpierw powinno się sprawdzić czy dane mają logiczny sens a dopiero potem czy są np. unikalne w obrębie danej bazdy danych. Np. User1 może być poprawnyn nickiem stworzonym na podstawie reguł domenowych, ale potem jeszcze trzeba oddzielnie sprawdzić jego unikalność. W podanym przez ciebie przypadku wyglądało by to mniej więcej tak: RegisterUserDto -> w handlerze
User.create(parametry z dto, polityki walidacji (np. UserNameLengthValidator itd.)
.map(createdUser -> {
//sprawdzenie unikalności user name i emaila.
Error albo zapis do repo.
}
Czyli mniej więcej ostatnie podejście, które podałeś. Dobrze, że używasz DDD i Eitherów zamiast wyjątków. O logice w annotacjach lepiej zapomnieć na zawsze.

0

@Sampeteq:

Pytanie po co przekazywać do User.create jakieś polityki?
To obiekty Nick,Email,Password wiedzą jak walidować swoją 'domene'
i obiekt User chyba nie musi wiedzieć on tylko wywołuje statyczne metody
obiektów, z których się składa. Jeżeli dobrze zrozumiałem to ten UserNameLengthValidator
byłby komponentem/serwisem przekazywanym do tej statycznej metody? Bo jeśli
nie to równie dobrze te obiekty mogłyby sobie tworzyć te polityki za pomocą new.

0

@lookacode1: Po to, żeby korzystały z nich właśnie te obiekty Nick, Email itd, które wiedzą jak walidować swoją domene właśnie na podstawie tych polityk, które oczywiście są interfejsami, bo wymagania mogą się zmieniać.
Tak dobrze, zrozumiałeś.

0

@Sampeteq: Czy warto się tak rozdrabniać na np. UserNameLengthValidator? Nie lepiej po prostu NickValidator albo PasswordValidator?
Kolejna rzecz to dlaczego robić z tych walidatorów interfejsy? Przecież w większości przypadków raczej będę miał jedną implementację
walidatora np. PasswordValidator więc w obiekcie Password mógłbym go stworzyć przez new PasswordValidator() a jakby coś się
zmieniło w wymaganiach to do zmiany byłaby tylko klasa PasswordValidator.

0

@lookacode1: No tak. W sumie nawet można mieć nawet jeden interface Validator z metodą Either<AppError,T> validate(T t).
Już ci napisałem. Dlatego, że reguły walidacji mogą się zmienić. Np. to czy hasło jest poprawne może oznacząc np., że ma określoną długość (wtedy wystarczy Validator<String> passwordLengthValidator), ale też może dojść wymaganie, że ma mieć jakiś określony format, np 1 wielka litera i 1 znak specjalny i wtedy byłby potrzebny kolejny walidator. Do tego dochodzi oczywiście przestrzeganie SOLID, w tym przypadku odwrócenia zależości i otwarte-zamknięte.

0

Czyli od jednego walidatora, który waliduje nam cały request poszliśmy w stronę wielu walidatorów
per pole np. dla nicka oddzielnie, hasła oddzielnie. A teraz chcemy znowu to dzielić tak, że np. dla
hasła byłoby wiele walidatorów. Powstaje nam trochę eksplozja klas. Czy to nie jest przesada i overengineering?
Np. dla hasła możemy stworzyć jeden walidator:

public class PasswordValidator {
    
    public Either<AppError, String> validate(String password) {
        return Either.right(password)
              .flatMap(this::validateLength)
              .flatMap(this::validateSpecialCharacters);
    }

    private Either<AppError, String> validateLength(String password) {...}

    private Either<AppError, String> validateSpecialCharacters(String password) {...}
}
0

@lookacode1: Nie wiadomo dokładnie co ten PasswordValidator z metodą validate robi. Wygląda jak interface. Trzeba się wczytywać w implementację co nie jest dobre. Jeszcze raz powtarzam, że walidacja może oznacząć bardzo różne rzeczy. Nazwa klasy i jej publiczne api powinni jasno informować do czego służy.
Do tego trochę dziwnie i niezbyt przejrzyście zapisałeś to za pomocą Eitherów. Ja bym napisałał to raczej tak:

public Either<AppError,String> validate(String password) {
retrun validateLength
.flatMap(passwordWithCorrectLength -> validateSpecialCharacters(passwordWithCorrectLength))
}
1

Rekomendowane jest raczej korzystanie z referencji na metody czyli przerabiając kod co przesłałeś:

public Either<AppError,String> validate(String password) {
    retrun validateLength.flatMap(this::validateSpecialCharacters);
}
0

Ktoś jeszcze podzieli się swoimi spostrzeżeniami jakiś spec od Javy? @Shalom @somekind @jarekr000000 @KamilAdam

1

Sprawdź od 22:10

1
lookacode1 napisał(a):

Jestem ciekawy co myślicie o tych podejściach do walidacji
i jakie jest wasze preferowane podejście?

Skoro już zostałem tu wywołany przez pomyłkę, to się wypowiem.
Walidacja danych wejściowych to jedno, a zapewnienie spójności modelu domenowego to drugie. Do tego należy pamiętać o fail-fast. Jeśli do systemu wpadły jakieś błędne dane, to nie ma sensu zmapować je na cztery obiekty domenowe, aby podczas mapowania na piąty dowiedzieć się, że coś było nie tak i rzucić wyjątkiem. Zmarnowaliśmy tylko czas i pamięć.
Dlatego rzucanie wyjątków w konstruktorach obiektów domenowych być musi, ale musi też zachodzić pełna walidacja danych wejściowych.
Ja do swojego DTO UserRequest mam zawsze UserRequestValidator ze zdefiniowanymi regułami dotyczącymi pól requestu, a który odpala się automatycznie zanim request dotrze do UserRequestHandler organizującego właściwą logikę przetwarzania requestu. No, ale to jest konstrukcja zmontowana z bibliotek, które ja używam, nie wiem czy się da i jak osiągnąć coś takiego w Javie.

1

Zacząłem się zastanawiać nad modelem mentalnym jak podejść do problemu walidacji :p trzeba jak zwykle zadać sobie dobre pytania - zdefiniować problem i zgodzić się co do celu.

  1. Po co robię walidację? Np. aby nie popsuć spójności danych, aby nie tworzyć obiektów domenowych na marne - warto to spriorytetyzować, a raczej uświadomić sobie, bo kolejność raczej oczywista
  2. Jakiego rodzaju walidacje mogę przeprowadzić, aby zrealizować te cele? Np. sprawdzenie poprawności na poziomie agregatu albo sprawdzenie unikalności jakiegoś pola across cała baza danych albo sprawdzenie czy przesłane pole składa się z samych cyfr. Uwaga - można nie robić walidacji, bo np. check na bazie danych zapewni realizacje celu (a wychodzimy od celu).
  3. Jaki minimalny zestaw informacji potrzebuje, aby przeprowadzić walidacje (effort)? Np. wystarczy walidowane pole albo trzeba sprawdzić wszystkie maile z bazy.
  4. **I dopiero: **Jak to zaimplementować wpisując się w obecne rozwiązanie architektoniczne. Np. zrobię walidacje wewnątrz bogatych obiektów domenowych, zrobię statyczne walidatory, zrobię adnotacje na polach, a co jeśli inputem są eventy itd.
0

@somekind:

Dlatego rzucanie wyjątków w konstruktorach obiektów domenowych być musi, ale musi też zachodzić pełna walidacja danych wejściowych.

Czyli walidacja jest powtórzona tak? Np. walidujemy email 2x raz w tym requestValidator a raz w obiekcie domenowym rzucając wyjątek jak coś
jest nie tak zgodnie z fail-fast.

Ja do swojego DTO UserRequest mam zawsze UserRequestValidator ze zdefiniowanymi regułami dotyczącymi pól requestu

W UserRequestValidator masz również walidację spójności np., że nie można się zarejestrować na dany email 2x czy tylko
podstawową walidację struktury, że "komunikat" jest poprawny?

3
lookacode1 napisał(a):

Czyli walidacja jest powtórzona tak? Np. walidujemy email 2x raz w tym requestValidator a raz w obiekcie domenowym rzucając wyjątek jak coś
jest nie tak zgodnie z fail-fast.

W moim rozumieniu walidacja oznacza sprawdzenie danych, które weszły do systemu, i danie jakiejś informacji zwrotnej osobie, która te dane dostarczyła.
A wyjątki z konstruktorów podczas tworzenia obiektów domenowych, to nie jest walidacja, tylko po prostu dbanie o to, aby utworzone obiekty były zawsze w spójnym stanie. To nie ma związku z tym, czy dane pochodzą z zewnątrz systemu, bo taki obiekt w ogóle nie musi mieć związku z danymi z zewnątrz, i tak go trzeba zabezpieczyć.

W UserRequestValidator masz również walidację spójności np., że nie można się zarejestrować na dany email 2x czy tylko
podstawową walidację struktury, że "komunikat" jest poprawny?

Walidację struktury, bo chcę aby moje walidatory były proste, pozbawione zewnętrznych zależności i skomplikowanej logiki.
Problemy związane ze stanem systemu sprawdzam w innych warstwach, tam gdzie dana rzecz jest wykonywana. Bo z mojego punktu widzenia, to zduplikowany email to jest po prostu błąd w jakimś procesie biznesowym, zapewne jeden z wielu możliwych, nie widzę sensu w traktowaniu tego inaczej, ani też w różnym traktowaniu błędów w walidacji i błędów w innej logice.
Ale słyszałem o podejściach, w których cała walidacja jest robiona na początku przetwarzania requestu, i w ramach niej sprawdza się także np. bazę danych. Wierzę, że to się może czasem sprawdzać, ale mnie takie międzywarstwowe walidatory się nie podobają, wydają się raczej niezgodne z clean architecture. Za to przy takim podejściu kod biznesowy jest nieco czytelniejszy, bo zawiera samą logikę bez żadnego upewniania się, czy poprzednie kroki przeszły prawidłowo.

1 użytkowników online, w tym zalogowanych: 0, gości: 1