Kolekcja z weryfikacją, czyli bebechy Value Object
Dawno dawno temu pisałem dlaczego należy opakowywać kolekcje w obiekty domenowe i posługiwać się ich abstrakcją, a nie gołymi kolekcjami. Niedawno pobawiliśmy się też niepustymi kolekcjami. Wczoraj znowuż na 4P pojawiło się pytanie jak zrobić „statek”.
Motywacja
Nasz statek jest to value object opakowujący kolekcję, który ma narzuconą pewną specyficzną walidację na poziomie dodawania obiektów. Spróbujmy połączyć te kawałki wiedzy w coś sensownego. Przypomnijmy sobie przykład z niepustą kolekcją:
Listing 1. Użycie kolekcji nie pustej
public class ValidationPresenter {
public String showMessages(NotEmptyCollection<ConstraintViolation> errors) {
Predicate<ConstraintViolation> nn = cv -> cv != null;
Predicate<ConstraintViolation> ne = cv -> !cv.getMessage().trim().isEmpty();
String errorMessage = errors.stream()
.filter(nn.and(ne))
.map(cv -> cv.getMessage())
.reduce("", (l, r) -> String.format("%s\n%s", l, r));
Preconditions.checkArgument(!errorMessage.trim().isEmpty(), "Error message cannot be empty");
return errorMessage;
}
}
Z naszej nie nullowej (założenie i tego predykatu nie ma w kodzie) kolekcji wyciągamy kolejno elementy i odrzucamy te, które są null albo mają pustą wiadomość. Następnie składamy komunikat i wysyłamy go w świat. Dodatkowe sprawdzenie na końcu pozwala na wychwycenie sytuacji gdy wszystkie elementy w kolekcji odpadną na filtrowaniu.
Spróbujmy z tego kodu usunąć filtry w taki sposób by mieć pewność iż nasza kolekcja wejściowa zawsze zawiera poprawne elementy. To pozwoli nam na usunięcie też sprawdzenia na wyjściu ponieważ wiemy, że żaden element nie został odrzucony i jest co najmniej jeden z niepustą wiadomością zatem efektem będzie nie pusta wiadomość.
Co trzeba zmienić
Zmiany w klasie NotEmptyList są stosunkowo proste. W metodach dodających elementy do kolekcji oraz na poziomie konstruktora musimy sprawdzać czy dodawany element spełnia warunki. Same warunki będą sprawdzane z pomocą interfejsu Verifier
Listing 2. Interfejs Verifier
public interface Verifier<T> {
T verify(T t);
}
Potrzebna nam jest też klasa NotEmptyAbstractListWithVerifier, która będzie wyglądać w następujący sposób:
Listing 3. Klasa NotEmptyAbstractListWithVerifier
public abstract class NotEmptyAbstractListWithVerifier<T, D extends Verifier<? super T>> extends
NotEmptyAbstractList<T>
implements NotEmptyListWithVerifier<T, D> {
private final D verifier;
public NotEmptyAbstractListWithVerifier(D verifier, T element) {
super((T) verifier.verify(element));
this.verifier = verifier;
}
protected D getVerifier() {
return verifier;
}
@Override
public T set(int index, T element) {
verifier.verify(element);
return super.set(index, element);
}
@Override
public void add(int index, T element) {
verifier.verify(element);
super.add(index, element);
}
@Override
public boolean add(T t) {
verifier.verify(t);
return super.add(t);
}
@Override
public boolean addAll(Collection<? extends T> c) {
return c.stream().map(e -> this.add(e)).reduce(false, (l, r) -> l || r);
}
@Override
public boolean addAll(int index, Collection<? extends T> c) {
c.forEach(verifier::verify);
return super.addAll(index, c);
}
}
Weryfikator powinien sypnąć jakimś specyficznym błędem. Jakim to nie ważne, bo będzie zależało od konkretnej implementacji.
Zmiany w showMessages
Na koniec zestaw zmian w sygnaturze metody showMessages. Chcemy by jako parametr przyjęła ona nie pustą kolekcję, która ma specyficzny weryfikator na pokładzie.
Listing 4. Klasa ValidationPresenter z weryfikacją
public class ValidationPresenter {
public <V extends NotNullVerifier<ConstraintViolation> & NotEmptyVerifier<ConstraintViolation>>
String showMessages(NotEmptyCollectionWithVerifier<ConstraintViolation, V> errors) {
String errorMessage = errors.stream()
.map(cv -> cv.getMessage())
.reduce("", (l, r) -> String.format("%s\n%s", l, r));
return errorMessage;
}
}
Zapis jest paskudny, ale java już taka jest. Jako argumentu oczekujemy nie pustej kolekcji z weryfikatorem typu V, który jest jednocześnie NotNullVerifier i NotEmptyVerifier. Co to oznacza? Oznacza to iż na wejściu mamy pewność, że nasza kolekcja zawiera tylko poprawne elementy. Zatem nie ma potrzeby aplikowania dodatkowych filtrów czy walidacji wyjścia.
Weryfikatory
Ostatnią rzeczą, która nam została to przyjrzenie się weryfikatorom. Kod z listingu 4 podpowiada, że powinny to być interfejsy generyczne. Przynajmniej na poziomie pewnej abstrakcji.
Listing 5. Interfejs NotNullVerifier z weryfikacją
public interface NotNullVerifier<T> extends Verifier<T> {
@Override
default T verify(@NotNull T t) {
return Preconditions.checkNotNull(t);
}
}
Tu sprawa jest prosta. Każdy obiekt w Javie jest nullem w ten sam sposób. Możemy zatem zaszyć w nim regułę.
Listing 6. Interfejs NotEmptyVerifier z weryfikacją
public interface NotEmptyVerifier<T> extends Verifier<T> {
}
Co oznacza, że obiekt jest nie pusty? To zależy od konkretnej klasy. Zatem tu pozostawiamy wszystko bez zmian. Interfejs będzie pełnił tylko rolę znacznika.
I teraz to musimy spiąć w postaci klasy CVMixedVerifier.
Listing 7. Klasa CVMixedVerifier z weryfikacją
public class MixedCVVerifier implements NotEmptyVerifier<ConstraintViolation>, NotNullVerifier<ConstraintViolation> {
@Override
public ConstraintViolation verify(@NotNull ConstraintViolation o) {
NotNullVerifier.super.verify(o);
Preconditions.checkArgument(!o.getMessage().trim().isEmpty());
return o;
}
}
Najpierw sprawdzamy warunek z NotNullVerifier, a następnie dodajemy implementację z NotEmptyVerifier. W ten sposób można już wykonać nasz kod w bezpieczny sposób.
Podsumowanie
Stworzyliśmy kod, który jest bezpieczny z punktu widzenia biznesu. Gwarantując sobie za pomocą typów pewne właściwości elementów kolekcji możemy tworzyć kod, który będzie w dużej mierze „idiotoodporny”. Nie da się wywołać metody showMessages przekazując nieprawidłową, pustą, kolekcję ani też przekazując kolekcję zawierającą nieprawidłowe elementy. Oczywiście weryfikacja elementów nadal odbywa się w czasie działania programu i tu dotykamy trochę innego tematu.
Istnieje możliwość wykorzystania JSR-305, który ma za zadanie detekcję bugów w kodzie. Jednak status tej specyfikacji to Dormant co oznacza „zdechłem i nie nie ożywią”. Można też korzystać ze zmian wniesionych przez JSR-308, który definiuje nowe miejsca dla adnotacji. Lecz nadal wszystkie te elementy żyją na poziomie uruchomienia, a nie kompilacji.