Press "Enter" to skip to content

Przepisz swój kod na nowo! #3

0

Przyszedł czas na kolejne podsumowanie prac związanych z projektem aplikacji schroniska dla zwierząt. Na tą chwilę udało mi się zakończyć część backendową, jednak z pominięciem edycji danych danego zwierzaka. Jest to mój błąd, ponieważ najzupełniej w świecie o tym zapomniałem. Dokonałem wielu zmian nazewnictwa klas oraz podzieliłem moduły w taki sposób jaki uważam za zadowalający na ten moment. Dopisałem także testy dla kontrolera w celu weryfikacji poprawności mapowania odbieranych danych. Do rozważenia pozostawiam kwestię dodania testów jednostkowych oraz e2e. Przy pomocy Hibernate waliduję przychodzące dane, aby dać szybko informację zwrotną użytkownikowi o ich prawidłowości. Dodatkowo usunąłem interfejs serwisu, ponieważ nie jest mi potrzebne sztuczne rozdzielenie implementacji. Muszę jedynie mieć w pamięci, że Spring przy transakcjach potrzebuje interfejsu, aby założyć proxy.

Są to z grubsza wymienione wszystkie zmiany jakie udało mi się wykonać w ostatnim czasie. Poniżej zapraszam do zapoznania się ze szczegółami zmian, które zaszły w projekcie. Dodatkowo załączam jeszcze linki do wcześniejszych artykułów:

Główny moduł z domeną

moduł domain aplikacji AnimalShelter
Struktura klas w module 'domain'

W pakiecie 'animal' stworzyłem dwie klasy, których głównym celem życia jest transferowanie danych. Nie zastosowałem tutaj wzorca projektowego DTO (Data Transfer Object), ponieważ nie chciałem, aby mogły one ulec zmianie po utworzeniu. Z tego powodu w tych klasach użyłem adnotację Lomboka @Value, aby zapewnić ich niezmienność.

Dalej w pakiecie 'command' znajduje się klasa AddAnimalCommand, której odpowiedzialnością jest przeniesienie uzyskanych danych zwierzaka od użytkownika w celu przyjęcia go do schroniska. Następne w kolejności są klasy z pakietu 'model', które zostały zaimplementowane jako Value Objects. W ten sposób zapewniłem sobie, że po stworzeniu obiektów tych klas nie będą mogły one istnieć w niepoprawnym stanie. Czyli np. wiek pupila nie będzie mógł ujemny albo jego imię nie będzie mogło być puste.

package pl.csanecki.animalshelter.domain.model;

import lombok.Value;

@Value
public class AnimalAge {

    int animalAge;

    private AnimalAge(int animalAge) {
        if (animalAge < 0) {
            throw new IllegalArgumentException(
                    "Cannot use negative integer as number of ages"
            );
        }

        this.animalAge = animalAge;
    }

    public static AnimalAge of(int age) {
        return new AnimalAge(age);
    }
}

Spodobał mi się taki styl pisania kodu, aby to klasa decydowała o tym czy dane przekazane do niej są prawidłowe, aby utworzyć jej obiekt. Dzięki temu zabezpieczamy się przed tym, że gdy zawiodą zewnętrzne walidatory jak np. Hibernate to i tak nie otrzymamy obiektu znajdującego się w nieprawidłowym stanie.

Z racji tego, że aplikacja ma w tej chwili charakter CRUDowy to w serwisie ShelterService istnieje tylko wywołanie metod repozytorium oraz rzucanie wyjątków, gdy wywoływane operacje się nie powiodą. Oczywiście zastosowałem w tej klasie bibliotekę Vavr, która wydaje mi się świetnym narzędziem. Może nie wyciągnąłem z niej wszystkich możliwości, ale na pewno ma duży potencjał.

Moduł Springowy

Struktura klas w module 'webservice'

W głównym kontrolerze znajdują się cztery metody odpowiedzialne za obsługę żądań: dodania zwierzątka do schroniska, pobrania wszystkich pupili bądź tylko jednego po jego id oraz adoptowanie dowolnego z nich. Umieściłem dodatkowo też klasę pomocniczą AddAnimalRequest, aby zwalidować przychodzące dane o nowym zwierzaku.

Walidacja żądania dodawania zwierzaka

Sprawdzenie poprawności żądania odbywa się przy pomocy Hibernate Validator. W celu walidacji czy dane pole spełnia możliwe opcje enuma należy napisać własną adnotację. W moim kodzie wygląda ona następująco.

package pl.csanecki.animalshelter.webservice.web;

import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

@Target({ FIELD })
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = ValueOfAnimalKindValidator.class)
public @interface ValueOfAnimalKind {
    Class<? extends Enum<?>> enumClass();
    String message() default "{javax.validation.constraints.ValueOfAnimalKind.message}";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

Dodatkowo należy do niej dopisać klasę implementującą ConstraintValidator, która przyjmuje dwa argumenty. Jednym z nich musi być nasza adnotacja, natomiast drugim wartość podlegająca walidacji. Dołożyłem jeszcze stałą 'javax.validation.constraints.ValueOfAnimalKind.message' w 'ValidationMessages_pl.properties'. W ten sposób wyniosłem wiadomość o błędzie poza kod oraz w przyszłości będzie możliwa jej internacjonalizacja. Koncepcja walidacji dobrze została przedstawiona i wyjaśniona w tym artykule Marcina Pietraszka, zachęcam do zapoznania się z nim.

package pl.csanecki.animalshelter.webservice.web;

import io.vavr.collection.List;
import io.vavr.collection.Stream;

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;

public class ValueOfAnimalKindValidator implements
        ConstraintValidator<ValueOfAnimalKind, CharSequence> {

    private List<String> acceptedValues;
    private String message;

    @Override
    public void initialize(ValueOfAnimalKind constraintAnnotation) {
        acceptedValues = Stream.of(
                constraintAnnotation.enumClass().getEnumConstants()
        ).map(Enum::name).toList();

        message = String.format(
                "{javax.validation.constraints.ValueOfAnimalKind.message}: %s",
                acceptedValues.mkString(", ")
        );
    }

    @Override
    public boolean isValid(CharSequence value, ConstraintValidatorContext context) {
        if (value != null && acceptedValues.contains(value.toString())) {
            return true;
        }

        context.disableDefaultConstraintViolation();
        context.buildConstraintViolationWithTemplate(message).addConstraintViolation();

        return false;
    }
}

Przy okazji, aby ładniej prezentować użytkownikowi błędy wynikające z walidacji żądania stworzyłem klasę ErrorHandler rozszerzającą ResponseEntityExceptionHandler. Wynik działania tej implementacji można obejrzeć poniżej.

Wynik walidacji nieprawidłowego żądania dla dodania pupila do schroniska

Praca z repozytorium

Stworzyłem encję AnimalEntity, która odpowiada kolumną w tabeli 'animals'. Dwie metody zaimplementowano, aby w łatwy sposób mapować otrzymane wyniki na klasy służące do prezentacji na zewnątrz.

    AnimalDetails toAnimalDetails() {
        return new AnimalDetails(
                AnimalId.of(id), 
                AnimalName.of(name), 
                AnimalKind.of(kind), 
                AnimalAge.of(age), 
                admittedAt, 
                adoptedAt
        );
    }

    AnimalShortInfo toAnimalShortInfo() {
        return new AnimalShortInfo(
                AnimalId.of(id), 
                AnimalName.of(name), 
                AnimalKind.of(kind), 
                AnimalAge.of(age), 
                adoptedAt == null
        );
    }

Sama klasa repozytorium zawiera najbardziej podstawowe zapytania do bazy danych. Trzeba jednak zwrócić uwagę na sposób w jaki wyciąga się id rekordu po utworzeniu go w bazie danych przy pomocy JdbcTemplate. Taki efekt można osiągnąć używając klasy KeyHolder, sam pomysł zaczerpnął z artykułu Baeldung.

    @Override
    @Transactional
    public AnimalId registerAnimal(AddAnimalCommand command) {
        KeyHolder holder = new GeneratedKeyHolder();

        jdbcTemplate.update(connection -> {
            PreparedStatement preparedStatement = connection.prepareStatement(
                    "INSERT INTO animals(name, kind, age) VALUES(?, ?, ?)",
                    Statement.RETURN_GENERATED_KEYS
            );
            preparedStatement.setString(1, command.getAnimalName());
            preparedStatement.setString(2, command.getAnimalKind());
            preparedStatement.setInt(3, command.getAnimalAge());
            return preparedStatement;
        }, holder);

        return Option.of(holder.getKey())
                .map(key -> AnimalId.of(key.longValue()))
                .getOrElseThrow(() -> { 
                    throw new DatabaseRuntimeError("Cannot get id for admitted animal"); 
                });
    }

Obsługa żądań przez kontroler

Każda z metod zwraca klasę ResponseEntity, która przyjmuje dodatkowo parametr mówiący o tym jaki obiekt znajduje się w ciele odpowiedzi. Moim zdaniem najciekawsza jest odpowiedź żądania POST, która ma na celu dodanie pupila do schroniska. Przy budowaniu odpowiedzi zwracany jest status 201 oraz adres w sekcji Location, pod którym można znaleźć utworzony obiekt. Tutaj napotkałem problem w postaci braku zwracania całego URLa, głównie chodzi tutaj o część z localhost. Rozwiązaniem na to okazało się użycie metody statycznej 'ServletUriComponentsBuilder.fromCurrentRequest().path(„/{id}”)', która dodawała niezbędny prefiks do adresu.

    @PostMapping
    public ResponseEntity<Void> acceptIntoShelter(
            @Valid @RequestBody AddAnimalRequest addAnimalRequest
    ) {
        AnimalId animalId = shelterService.acceptIntoShelter(new AddAnimalCommand(
                AnimalName.of(addAnimalRequest.getName()),
                AnimalKind.of(addAnimalRequest.getKind()),
                AnimalAge.of(addAnimalRequest.getAge())
        ));

        return ResponseEntity.created(
                ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}")
                        .buildAndExpand(animalId.getAnimalId())
                        .toUri()
        ).build();
    }

Testy wejścia

W kwestii testów to aktualnie napisałem sprawdzenie endpointów kontrolera. Utworzyłem przy tym zamockowaną konfigurację beanów, aby niepotrzebnie nie odwoływać się do bazy danych. Dodatkowo utworzyłem plik JSON z danymi zwierzaka, który ma zostać przyjęty do schroniska. Całość odbywa się przy wykorzystaniu MockMvc, gdzie odpytuję dany endpoint i sprawdzam czy odpowiedź zawiera spodziewany wynik.

    @Test
    void should_return_animal_details_by_animal_id(
            @Autowired MockMvc mockMvc,
            @Autowired ShelterService shelterService
    ) throws Exception {

        AnimalDetails animalDetails = animalInShelter(65);

        given(shelterService.getAnimalDetails(animalId)).willReturn(animalDetails);

        mockMvc.perform(get("/shelter/animals/{id}", animalId.getAnimalId()))
                .andExpect(status().isOk())
                .andExpect(content().contentType(MediaType.APPLICATION_JSON))
                .andExpect(jsonPath("$.id", is(animalDetails.getId()), Long.class))
                .andExpect(jsonPath("$.name", is(animalDetails.getName())))
                .andExpect(jsonPath("$.kind", is(animalDetails.getKind())))
                .andExpect(jsonPath("$.age", is(animalDetails.getAge())))
                .andExpect(jsonPath("$.admittedAt").isNotEmpty())
                .andExpect(jsonPath("$.adoptedAt").value(nullValue()));
    }

Jest to jeden z przykładowych testów. Niezbędne elementy wstrzykuję za pośrednictwem argumentów metody testowej, aby mieć kontrolę nad tym czy nie jest potrzebne zbyt wiele zależności do wykonania testu. Każdy z nich opatruję adnotacją @Autowired.

Zmiany, błędy i wnioski w projekcie

W zamyśle miałem też dodanie ArchUnit, aby panować nad rozdzieleniem pakietów. Nawet istniała jedna czy dwie reguły, które pilnowały mi, aby klasy z modułu 'webservice' nie znalazły się w 'domain'. Jednak postanowiłem zrezygnować z tego rozwiązania z racji faktu, że Maven doskonale sobie z tym radzi. W plikach pom.xml należało tylko ustalić odpowiednie zależności i gotowe!

Doszedłem też do wniosku, że to warstwa kontrolera powinna być odpowiedzialna za mapowanie przychodzących danych na odpowiednie klasy domenowe. Jeżeli rozparsowujemy JSONa to chcemy się upewnić, że w dalszej części aplikacji te dane będą ze sobą spójne. Nie mówię tutaj o walidacji biznesowej, ale o wstępnej weryfikacji danych przysłanych do systemu. Dodatkowo zyskujemy zapewnienie, że nasza domena nie będzie potrzebowała żadnych zewnętrznych zależności.

Niestety przy okazji pracy nad aplikacją usunąłem przez przypadek obsługę nieznalezienia zwierzaka w schronisku. Zamiast zwracać status 404 użytkownik otrzymuje błąd wewnętrzny aplikacji, a w logach pojawia się informacja o wyjątku.

Co dalej?

W dalszych planach mam zamiar popracować nad frontem do aplikacji, który, tak jak zakładałem, chcę zrobić w Anglurze. Mam nadzieję, że będzie się dosyć lekko w nim pisało, bo miałem co do niego sporo obaw. Wydawał mi się skomplikowaną wersją Reacta. Dodatkowo muszę dorobić brakujący endpoint do edycji zwierzaka w schronisku co nie powinno być trudne posiadając aktualny stan aplikacji.

To by było na tyle w tej części serii, mam nadzieję, że przydadzą Ci się moje przemyślenia co do tworzenia własnego projektu. Zachęcam Cię również do pracy na swoją aplikacją oraz dzielenia się nią ze mną poprzez komentarze lub linki do własnego bloga.

Oczywiście zamieszczam link do GitHuba: AnimalShelterNew.

Powodzenia i cześć!