Backend

Walidacja danych w Javie na dwa sposoby: JSR-380 i Vavr

Jako programiści, tworzymy systemy pracujące z danymi. Oczywiście chcemy, aby były tworzone w odpowiedniej formie i spełniały określone predefiniowane kryteria. Jednym z takich kryteriów jest walidacja danych, na których operujemy, czyli zapewnienie, by były poprawne. Podczas implementacji tego mechanizmu w świecie Javy mamy do dyspozycji wiele narzędzi i opcji.


Michał Chmielarz. Starszy programista w SoftwareMill. Z programowaniem związany od wielu lat (20+). Siedzi głównie w JVM (Java, Scala, Groovy). Zwolennik metodyk zwinnych. Pasjonat czystego kodu. Miłośnik otwartego oprogramowania. Prywatnie mąż, ojciec, mol książkowy i ogrodnik.


Możemy spojrzeć na ten problem z dwóch różnych perspektyw. Pierwsza, to użycie zdefiniowanego standardu jakim jest JSR-380. Drugi sposób jest niestandardowy, oparty na bibliotece zewnętrznej — Vavr.

Domena

Zanim zaczniemy, zdefiniujmy sobie naszą domenę i kształt danych. Powiedzmy, że poruszamy się w finansach i piszemy serwis, który ma za zadanie zapisywać informacje o kontach bankowych. Oto nasze dane na wejściu do tego serwisu: numer IBAN, BIC oraz numer konta bankowego. Przed zapisaniem ich w naszej bazie danych, chcemy się upewnić, że wszystkie pola są prawidłowe. iban4j to biblioteka, która zapewnia wszystkie mechanizmy wymagane w naszym przykładzie.

Droga standardami usłana

JSR-380, znany również jako Bean Validation 2.0, jest standardem zdefiniowanym w procesie JCP. Daje nam już pewne wstępnie zdefiniowane reguły (np. @NotNull, @Email, itp.).

W naszym przykładzie musimy utworzyć jednak niestandardową weryfikację, aby sprawdzić poprawność numerów IBAN i BIC. Jak to zrobić? Szczegółową instrukcję można znaleźć w dokumentacji Hibernate, natomiast tutaj chcę skupić się na podstawach.

Do zaimplementowania mamy w sumie tylko dwie rzeczy:

  • adnotację, aby zastosować ją do pola lub metody,
  • oraz mechanizm walidacji w celu sprawdzania poprawności danych.

Mój własny walidator JSR

Najpierw adnotacja – stwórzmy ją dla pola IBAN. Oto jak może wyglądać:

@Target({ ElementType.METHOD, ElementType.FIELD })
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = IbanValidator.class)
public @interface Iban {
  String message() default "{iban.constraint}";
  Class<?>[] groups() default {};
  Class<? extends Payload>[] payload() default {};
}

Co my tu mamy? Przede wszystkim możemy zastosować adnotację do pola lub metody. Dalej definiujemy, że pole oznaczone adnotacją @Iban jest sprawdzane za pomocą klasy IbanValidator. Ponadto, przynajmniej dla każdej adnotacji zgodnej z JSR-380, możemy skonfigurować:

  • wiadomość o błędzie (w przykładzie podałem odniesienie do wiadomości, zawartej w pliku ValidationMessages.properties),
  • do jakiej grupy walidacji należy nasza adnotacja za pomocą pola groups (zdefiniowane walidacje mogą być grupowane w zbiorach, przez co można aplikować dany zestaw walidacji do poszczególnych obiektów),
  • oraz dane dodatkowe (definiuje dodatkowe dane używane przez walidatory).

Czas na wspomniany wyżej walidator:

@Slf4j
public class IbanValidator implements ConstraintValidator<Iban, String> {

  @Override
  public boolean isValid(String value, ConstraintValidatorContext context) {
   if (value == null) {
     return false;
   }

  return Try.of(() -> {
    if (value.contains(" ")) {
      IbanUtil.validate(value, IbanFormat.Default);
    } else {
      IbanUtil.validate(value);
    }
    return true;
  })
    .onFailure(exc -> log.error("Invalid IBAN provided: {}", value, exc))
    .getOrElseGet(exc -> false);
  }
}

Implementuje on interfejs ConstraintValidator. Pole value zwraca poprawność bądź niepoprawność danych (false lub true).

Uruchamianie walidacji JSR

A jak taką walidację uruchomić? Możemy zrobić to zarówno automatycznie, jak i wywołać ją ręcznie.

Jeśli używamy w projekcie Spring Framework i chcemy zwalidować dane przychodzące do końcówki REST’owej, to możemy uruchomić walidację automatycznie, stosując adnotację @Valid na otrzymanych danych. Przy tak skonfigurowanej końcówce, Spring stosuje walidację dla każdego przychodzącego żądania.

Co się dzieje w momencie, kiedy otrzymane dane nie są poprawne? Zostanie rzucony wyjątek typu MethodArgumentNotValidException, zawierający informacje o problemach z walidacją. Możemy go przechwycić stosując mechanizm @ControllerAdvice albo @ExceptionHandler i przekształcić go w odpowiedź L45 o formacie zdefiniowanym przez nas samych. Alternatywnie możemy nawet nic nie robić i zostawić obsługę błędu Spring’owi.

Naszą walidację możemy uruchomić również ręcznie. Wygląda to następująco:

BankAccount bankAccount = newBankAccount("invalid_iban", "invalid_bic", null);
final Set<ConstraintViolation<BankAccount>> constraints = 
  Validation.buildDefaultValidatorFactory().getValidator().validate(bankAccount);

To, co mnie zaskoczyło po wywołaniu powyższego kodu, to rzucony wyjątek: javax.validation.ValidationException: HV000064: Unable to instantiate ConstraintValidator: com.softwaremill.jsr380.BankAccountValidator (…)

Okazuje się, że Spring i domyślnie używana przez JSR-380 fabryka ValidatorFactory inaczej tworzą walidatory. Pierwszy nie wymaga, aby walidatory były klasami publicznymi, podczas gdy drugi już tak. Po zmianie modyfikatora dostępu do walidatorów mogłem je bez dalszych niespodzianek wypróbować ręcznie. Oto przykład odpowiedzi na nieprawidłowe dane konta bankowego:

ConstraintViolationImpl{
  interpolatedMessage='{iban.constraint}', propertyPath=iban, 
  rootBeanClass=class com.softwaremill.jsr380.domain.BankAccount, messageTemplate='{iban.constraint}'
}
ConstraintViolationImpl{
  interpolatedMessage='{bic.constraint}', propertyPath=bic, 
  rootBeanClass=class com.softwaremill.jsr380.domain.BankAccount, messageTemplate='{bic.constraint}'
}
ConstraintViolationImpl{
  interpolatedMessage='{bank-account-number.constraint}', propertyPath=bankAccountNumber, 
  rootBeanClass=class com.softwaremill.jsr380.domain.BankAccount, messageTemplate='{bank-account-number.constraint}'
}

Ok, więc to działa, prawda? Oczywiście, że tak! Jednak nie jestem w pełni zadowolony z takiego podejścia do walidacji.

Co mi się tu nie podoba

W przykładzie ręcznego uruchamiania walidacji, jeśli chcę ukryć mechanizm walidacji danych w pakiecie domain po prostu nie mogę tego zrobić. Muszę upublicznić klasy walidatorów całemu światu, ponieważ muszą one być dostępne przy tworzeniu ich instancji przez fabrykę. Jeśli, tak jak ja, traktujesz walidację jako część domeny, może to być nieco frustrujące, ponieważ standard zmusza cię do upublicznienia rzeczy, które chcesz ukryć wewnątrz pakietu. Przy takim podejściu wychodzi na to, że sprawdzam dane specyficzne dla domeny poza pakietem domeny.

Co więcej, w przypadku Springa i sprawdzania poprawności danych tuż przed przejściem do metod kontrolerów, nie musimy ujawniać walidatorów. Jak widać, zachowanie zależy od implementacji fabryki i mamy pewną niespójność w standardzie.

Sposób niestandardowy

Zobaczmy teraz, w jaki sposób możemy zaimplementować mechanizm walidacji przy użyciu biblioteki Vavr.

Podstawy

W bibliotece otrzymujesz walidację opartą na interfejsie io.vavr.control.Validation. Ma on dwie implementacje: Valid i Invalid, reprezentujące — jak sugerują ich nazwy — odpowiednio dane prawidłowe i błędne. Obie dostarczają wartość — pierwsza zawiera prawidłowe dane, a druga reprezentuje błąd. Wartości mogą być instancjami dowolnych klas.

Mechanizm ten oparty jest na koncepcji funktora aplikacyjnego. W skrócie, łączy on wyniki walidacji poszczególnych wartości w jedną całość. W przypadku wystąpienia błędów otrzymujemy ich listę, którą możemy dalej przetwarzać. Dla dobrego wyjaśnienia mechaniki takiej konstrukcji, sprawdź, co pisał o tym Krzysztof Ciesielski.

Aby sprawdzić poprawność swoich danych, musisz wywołać Validation.combine (...), który akceptuje do 8 walidacji. Są one wszystkie łączone w jeden wynik za pomocą klasy Validation.Builder i jej metody ap (fn). Wynikiem tego jest instancja klasy Valid lub `Invalid` — dokładny typ zależy oczywiście od wyników walidacji.

W braku jakichkolwiek błędów, metoda ap (fn) odwzorowuje poszczególne wyniki do pojedynczej wartości za pomocą funkcji, którą przyjmuje jako argument. Istnieją pewne wymagania dotyczące tej funkcji. Musi ona przyjąć taką samą liczbę parametrów jak liczba walidacji przekazanych do metody combine(...). Następnie, typy parametrów muszą być takie same jak typy wartości zawarte w instancjach Validation. Jak są one dopasowane? Kolejność argumentów przekazywanych do funkcji odpowiada kolejności wyników walidacji.

Jeśli co najmniej jeden z wyników sprawdzania zawiera niepoprawne dane, to metoda ap (fn) zwraca instancję Invalid, zawierającą listę wszystkich błędów, które wystąpiły. Podobnie jak w przypadku poprawnego wyniku możemy odwzorować wszystkie błędy na inne klasy, wywołując metodę Validation.mapError (fn). Funkcja dostarczona jako parametr jest odpowiedzialna za odwzorowanie listy na coś innego.

A gdzie kod?

Do tej pory omówiliśmy kilka ogólnych założeń mechanizmu walidacji Vavr. Teraz przejdźmy do kodu.

Dla przypomnienia mamy do czynienia z klasą rachunku bankowego posiadającą trzy pola: IBAN, BIC i numer konta bankowego.

Jak sprawdzić te dane? Może to wyglądać następująco:

Validation<String, ValidBankAccount> validate(BankAccount account) {
   return combine(
       validateIban(account.getIban()),
       validateBic(account.getBic()),
       validateAccountNumber(account.getNumber())
)
       .ap(ValidBankAccount::new)
       .mapError(this::errorsAsJson);
}

Zacznijmy od metody combine (...), importowanej statycznie dla lepszej czytelności. Przyjmuje ona trzy argumenty, które są wynikiem wywołań trzech metod walidacji. Każda metoda pobiera pojedyncze pole klasy wejściowej, wykonuje pewne obliczenia i zwraca instancję klasy Valid lub Invalid (oba implementują interfejs Validation). Oto wspomniane metody:

private Validation<String, Iban> validateIban(String iban) {
    return Try
        .of(() -> Iban.valueOf(iban))
        .onFailure(exc -> log.error("Invalid IBAN received", exc))
        .toValidation()
        .mapError(exc -> "Invalid IBAN provided: " + exc.getMessage());
}

private Validation<String, Bic> validateBic(String bic) {
    return Try
        .of(() -> Bic.valueOf("DEUTDEFF"))
       .onFailure(exc -> log.error("Invalid BIC received", exc))
       .toValidation()
       .mapError(exc -> "Invalid BIC provided: " + exc.getMessage());
}

private Validation<String, String> validateAccountNumber(String bankAccountNumber) {
    return bankAccountNumber == null ? 
        Invalid("Bank account number cannot be null") : 
        Valid(bankAccountNumber);
}

Każda metoda zawiera logikę walidacji odpowiedzialną za walidację tylko danego parametru. Na przykład logika w validateIban (...) sprawdza, czy jest on pusty, czy ma odpowiedni kod kraju i tak dalej. Jeśli wszystkie kryteria są spełnione, zwracamy instancję klasy Valid, zawierającą instancję klasy Iban jako jej wartość. W przypadku niepowodzenia, na wyjściu dostajemy instancję Invalid z komunikatem informującym o błędzie naruszenia poprawności danych. To samo dotyczy pozostałych dwóch metod walidacji.

Mamy więc obliczone konkretne wyniki. Co dalej? W przypadku, gdy wszystkie z nich są poprawne, tworzymy instancję klasy ValidBankAccount (poprzez odwołanie do jej konstruktora podanego w metodzie ap(fn)). Instancja zawiera zweryfikowane dane.

Jak wspomniałem powyżej, w przypadku niepowodzenia sprawdzania danych zwracamy instancję Invalid, zawierającą listę wszystkich błędów. W naszym przykładzie jest to lista komunikatów tekstowych, ponieważ wszystkie metody sprawdzania poprawności zwracają wartość String w przypadku błędu. W zależności od wymagań możemy zwrócić taki wynik lub zmapować listę na coś innego. W przedstawionym kodzie mapujemy błędy do formatu JSON.

I co dalej?

Co możemy zrobić z takim ostatecznym wynikiem procesu weryfikacji? Istnieje kilka możliwości. Pierwszym z nich może być wyodrębnienie wartości za pomocą instrukcji if-else i wywołań Validation.isValid()/get()/getError(). Inną opcją jest zmiana wyniku na typu io.vavr.control.Either i dalsza praca z takim wynikiem. Istnieje również metoda Validation.fold(invalidFn, validFn), która umożliwia skonsumowanie zarówno nieprawidłowych, jak i poprawnych wyników za pomocą przysłowiowego jednolinijkowca. Lista możliwości nie kończy się tutaj. Istnieje wiele sposobów wykorzystania wyników walidacji. Wybór zależy oczywiście od konkretnego przypadku, z którym się mierzysz.

Przemyślenia na koniec

Jeśli zamierzasz korzystać z biblioteki, frameworka lub standardu, prawdopodobnie będziesz musiał dostosować swój kod do wybranego przez siebie narzędzia. W przypadku Java Bean Validation nie jest inaczej. Pamiętaj, że użycie standardów nie zawsze podsunie Ci na tacy gotowe rozwiązanie wszystkich Twoich problemów. Co więcej, czasami może przysporzyć Cię o wiele więcej bólu głowy, niż można było się spodziewać. Więc… wybieraj mądrze.

Z drugiej strony, kiedy patrzę na możliwości i elastyczność mechanizmu walidacji dostarczonego w Vavr, jest tam w sumie wszystko, czego bym potrzebował. Na pewno nie jest to standard. To programista jest odpowiedzialny za napisanie wszystkich potrzebnych walidacji i ich uruchomienie oraz zaimplementowanie obsługi błędów (np. internacjonalizacja wiadomości). Możemy jednak zrobić to, co jest nam potrzebne w danym przypadku bez konieczności tworzenia rozwiązań, które nie są nam w danym momencie niezbędne. Możemy tworzyć nasze klasy w sposób, który chcemy i mamy pełną kontrolę nad tym, co się dzieje. Co więcej, z takim podejściem, mogę ukrywać klasy w domenie i publikować je tylko wtedy, jeśli istnieje rzeczywiste zapotrzebowanie (np. chciałbym je użyć ponownie w innym miejscu).

Dalej, w ramach standardu JSR-380, dość trudne okazuje się sprawdzenie jednego pola w kontekście wartości innego pola. Na przykład chciałbym mieć pewność, że podany numer konta bankowego jest zgodny z otrzymanym numerem IBAN. Wygląda na to, że można to zrobić, pisząc adnotację dla klasy zamiast konkretnego pola, jednak nie wygląda to tak schludnie, jakbym tego oczekiwał, lub mógłbym osiągnąć dzięki podejściu nie standardowemu.

Podsumowując, jestem raczej zwolennikiem ręcznego sprawdzania poprawności, niż wykorzystywania mechanizmów dostarczanych przez framework lub standard. I dodatkowo Bean Validation używa adnotacji — jeśli nie wiesz, to są one uważane za szkodliwe.

To tyle, jeśli chodzi o moje doświadczenia i przemyślenia na temat walidacji w Javie. A może i Ty chcesz podzielić się swoimi doświadczeniami w tym temacie? Zostaw swój komentarz poniżej.

najwięcej ofert html

Podobne artykuły

[wpdevart_facebook_comment curent_url="https://justjoin.it/blog/walidacja-danych-w-javie-na-dwa-sposoby-jsr-380-i-vavr" order_type="social" width="100%" count_of_comments="8" ]