Ostatnie dni pracy nad AnimalShelter były naprawdę owocne. Udało się zakończyć dwa kamienie milowe z mojej listy projektowej. Być może wynika to z tego, że nie sprawiły one większych problemów programistycznych. Czas, więc podsumować po raz kolejny swoje działania. Tym razem obejdzie się bez większych rewolucji jakie miały miejsce w jednym z poprzednich wpisów.

Zestawienie aktualnych prac

Poniżej znajduje się lista z zakończonymi zadaniami znajdującymi się na GitHubie. Poza reaktywacją generowania plików raportowych zakodowałem też zdarzenie potwierdzające akceptację wniosku przez schronisko. Dodałem ADRa opisanego w poprzednim artykule, napisałem testy weryfikujące podejście architektoniczne wykorzystując do tego ArchUnit. Oznaczyłem klasy warstwy aplikacji adnotacjami obsługującymi transakcyjność oraz poprawiłem kilka błędów.

  • #55 Add transaction annotations
  • #56 Fix some typos and modifiers
  • #57 Add event to confirm acceptance by shelter
  • #58 Add difined ADRs
  • #59 Add ArchUnit
  • #60 Reactivate pdf generator from old project
  • #61 Reactivate csv generator from old project
  • #62 Add species and gender for reports

Kolejne dwa kamienie milowe odhaczone!
Kolejne dwa kamienie milowe odhaczone!

Ilość kamieni milowych jest jeszcze duża, ale nic tak nie motywuje jak dwa kolejne odhaczenia widoczne w Nozbe! Jest to prosta sztuczka, która daje porządnego kopa do dalszego działania. Taka widoczna gratyfikacja pokazująca małe kroki jakie wykonujemy do zrealizowania naszego celu.

Generator raportów PDF i CSV

Głównym zadaniem postawionym przede mną było właśnie uruchomienie generatora raportów plików PDF oraz CSV. Nie było to trudne z racji tego, że przecież miałem gotowe rozwiązanie napisane wcześniej. Jeśli jesteś nimi zainteresowany lub zainteresowana to zapraszam do tych dwóch linków: opis koncepcji generowania PDF i proste tworzenie CSV. Tutaj natomiast skupię się na podejściu architektonicznym.

Schemat generowania plików z wypełnioną treścią
Schemat generowania plików z wypełnioną treścią

Schemat generowania pliku prezentuje się tak jak na powyższej grafice. Opiszę teraz pokrótce algorytm działania tego procesu:

  1. Wykonujemy zapytanie HTTP o listę zwierząt w schronisku w formacie CSV
  2. Żądnie zostaje przekierowane do fasady
  3. Pobierana jest klasa implementująca interfejs DataFetcher dla żądanego typu kontentu
  4. Pobierana jest klasa implementująca interfejs FileGenerator dla żądanego formatu pliku
  5. Wyciągane są dane o zwierzętach w schronisku z zewnętrznego serwisu
  6. Na ich podstawie generowany jest plik, który wędruje do użytkownika końcowego
1
2
3
4
5
6
private DataFetcher findDataFetcherFor(ContentType contentType) {
  return dataFetchers.stream()
      .filter(fetcher -> fetcher.isApplicable(contentType))
      .findFirst()
      .orElseThrow(() -> new IllegalArgumentException("not found data fetch for: " + contentType));
}

Tak wygląda wyciąganie interesującej nas klasy implementującej DataFetch dla konkretnego typu raportu. Poszedłem nawet o krok dalej. Chciałem mieć pewność, że nie powstaną dwa rozwiązania pasującego do danego rodzaju problemu. Z tego powodu przy rejestrowaniu beana klasy ReportGeneratorFacade weryfikuję to założenie w następujący sposób.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
package pl.devcezz.shelter.generator;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import pl.devcezz.shelter.generator.csv.CsvFileGeneratorConfig;
import pl.devcezz.shelter.generator.dto.ContentType;
import pl.devcezz.shelter.generator.dto.FileType;
import pl.devcezz.shelter.generator.external.GeneratorExternalConfig;
import pl.devcezz.shelter.generator.pdf.PdfFileGeneratorConfig;

import java.util.Collection;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;

@Configuration
@Import({
    GeneratorExternalConfig.class,
    PdfFileGeneratorConfig.class,
    CsvFileGeneratorConfig.class})
public class ReportGeneratorConfig {

  @Bean
  ReportGeneratorFacade fileGeneratorFacade(
      Set<DataFetcher> dataFetchers,
      Set<FileGenerator> fileGenerators) {
    checkIfMoreFetchersForOneContentType(dataFetchers);
    checkIfMoreGeneratorsForOneFileType(fileGenerators);

    return new ReportGeneratorFacade(dataFetchers, fileGenerators);
  }

  private void checkIfMoreFetchersForOneContentType(Set<DataFetcher> dataFetchers) {
    Map<ContentType, Long> countedContentTypes = Stream.of(ContentType.values())
        .collect(Collectors.toMap(
            contentType -> contentType,
            contentType -> dataFetchers.stream()
                .filter(dataFetcher -> dataFetcher.isApplicable(contentType))
                .count()));

    if (moreThanOneCount(countedContentTypes.values())) {
      throw new IllegalStateException("too much fetchers for one content type");
    }
  }

  private void checkIfMoreGeneratorsForOneFileType(Set<FileGenerator> fileGenerators) {
    Map<FileType, Long> countedFileTypes = Stream.of(FileType.values())
        .collect(Collectors.toMap(
            fileType -> fileType,
            fileType -> fileGenerators.stream()
                .filter(fileGenerator -> fileGenerator.isApplicable(fileType))
                .count()));

    if (moreThanOneCount(countedFileTypes.values())) {
      throw new IllegalStateException("too much generators for one file type");
    }
  }

  private boolean moreThanOneCount(Collection<Long> counts) {
    return !counts.stream()
        .filter(count -> count > 1)
        .toList()
        .isEmpty();
  }
}

Na ten moment działa to jak najbardziej prawidło. Aplikacja nie wstanie jeśli będą istniały dwa beany rozwiązujące ten sam problem np. generujące plik PDF. To rozwiązanie ma swoje zalety, ale jeden wątek mnie w nim nurtuje. Interfejs DataFetcher ma następującą metodę do zaimplementowania - Object fetch();. Pozwala to jak najbardziej na generyczne rozwiązanie. Jednak następstwem tego jest to, że generatory plików muszą weryfikować jakie dane do nich przyszły, aby wiedzieć jak zmapować dla nich kontekst.

1
2
3
4
5
6
7
8
9
10
11
12
@Override
public byte[] generate(Object data) {
  HtmlContext htmlContext = Match(data).of(
      Case($(instanceOf(ShelterListData.class)), this::handleShelterList),
      Case($(), () -> {
        throw new IllegalArgumentException("unhandled kind of data for pdf generator");
      })
  );

  HtmlContent htmlContent = htmlContentGenerator.process(htmlContext);
  return pdfCreator.process(htmlContent);
}

Może i to nie jest też najgorszy pomysł, bo dodając nowe dane niekoniecznie musimy obsłużyć je w formacie CSV, a możemy tylko w PDF. Nie ma żadnego wymuszenia na danej klasie do obsługi danego typu danych. Daj znać w komentarzu co o tym sądzisz.

Flow z potwierdzeniem akceptacji wniosku

W momencie, gdy schronisko akceptuje dany wniosek to wysyłana jest informacja o tym wydarzeniu do modułu proposal. Tam musi nastąpić weryfikacja, że dany wniosek jest dalej dostępny. Jeśli tak to operacja się powodzi. Gorzej natomiast, gdy ktoś w międzyczasie zaakceptował interesujący nas wniosek. Wtedy wystąpi błąd, a schronisko może finalnie nie dowiedzieć się, że operacja się nie powiodła.

Z tego powodu wprowadziłem status Pending dla wniosku w schronisku. Gdy następuje prośba o akceptację danego wniosku wtedy w module shelter dany wpis staje się oczekującym. Dopiero, gdy uda się potwierdzić akceptację w module proposal to powstaje odpowiadające temu zdarzenie. Schronisko nasłuchuje na ten event i, gdy on nadejdzie zmienia status wniosku na Confirmed. Natomiast, gdy operacja się nie powiedzie to wpis z bazy schroniska jest usuwany. Warto byłoby przy okazji poinformować o tym rezultacie użytkownika np. poprzez email, ale to dopiero na kolejnym etapie dewelopmentu.

Operacja akceptacji wniosku powiodła się
Operacja akceptacji wniosku powiodła się

Operacja akceptacji wniosku nie powiodła się
Operacja akceptacji wniosku nie powiodła się

Według mnie za bardzo chciałem zautomatyzować ten proces. Moją następną koncepcją jest to, żeby administrator zarządzający modułem proposal potwierdzał przychodzące żądania o akceptację wniosku. W ten sposób kod znacznie się uprości, a właściciele procesu będą mieli większą władzę nad tym co się dzieje. Praktycznie sytuacja WIN-WIN. Dodatkowo jeśli będzie więcej schronisk zainteresowanych danym zwierzakiem to administrator będzie mógł zdecydować, które z nich będzie dla niego lepszym wyborem.

Błąd w testach integracyjnych i kolejny kontekst

Przy okazji powyższej funkcjonalności otrzymałem błąd w testach integracyjnych. Wszystko przez zbyt duży kontekst testowy. Gdy wysyłany jest event ProposalAccepted przez moduł schroniska to jego odbiorcą jest on sam oraz moduł proposal. Przy uruchomionej aplikacji wszystko działa prawidłowo. Natomiast w teście nie jest już tak różowo.

W tym przypadku testowym moduł proposal nie ma u siebie w bazie danego wniosku, więc wysyła zdarzenie o niepowodzeniu. Natomiast schronisko co wtedy robi? Usuwa je. Test natomiast weryfikuje czy dany wniosek został w nim zarejestrowany jako oczekujący. Skoro go nie ma to test nie przechodzi.

Rozwiązaniem było, więc utworzenie kolejnego kontekstu, który w swojej konfiguracji nic nie wie o module proposal. To podejście jest bliższe temu co chcę finalnie osiągnąć. Gdyby te moduły były mikroserwisami nie doszłoby do tej sytuacji. Ten krok pozwolił mi się, więc przybliżyć do głównego celu jakim jest uruchomienie tej aplikacji w architekturze mikroserwisowej.

Wersjonowanie kodu

Przy okazji tworzenia kamieni milowych chciałem, aby narzędzie do kontroli wersji odpowiadało temu podejściu. Stąd też próba tworzenia odpowiadających im branchy oraz kreowania gałęzi per zadanie. Jak na razie wydaje mi się, że ten cel również idzie zgodnie z planem. Poniżej pokazuję zestawienie tego podejścia - schemat i aktualny stan rzeczy.

Schemat wersjonowania kodu aplikacji AnimalShelter
Schemat wersjonowania kodu aplikacji AnimalShelter

Rzeczywisty obraz wersjonowania kodu aplikacji AnimalShelter
Rzeczywisty obraz wersjonowania kodu aplikacji AnimalShelter

Dodanie ArchUnit

Przy okazji tego projektu chciałem również użyć w nim ArchUnit. Jest to narzędzie pozwalające chronić przyjętą architekturę kodu. Dzięki płynnemu API możemy zdefiniować dowolną regułę pilnującą nas przed rozwaleniem tego co zostało ustalone. Jednym z przykładów może być to, że nie chcemy, aby klasy należące do Springa pałętały się po naszym kodzie z domeną. Możemy również chronić się przed mieszaniem się klas pomiędzy naszymi pakietami np. gdy nie chcemy, aby w domain były klasy z infrastruktury.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@AnalyzeClasses(packages = "pl.devcezz.shelter.adoption")
class AdoptionHexagonalArchitectureTest {

  @ArchTest
  public static final ArchRule model_should_not_depend_on_infrastructure =
      noClasses()
          .that()
          .resideInAPackage("..model..")
          .should()
          .dependOnClassesThat()
          .resideInAPackage("..infrastructure..")

  //...
}

Powyższy kod jest naprawdę czytelny. Wszystko widać na pierwszy rzut oka. Polecam Ci stosowanie tej biblioteki w swoim projekcie. Jedynym jej minusem jest fakt, że takie błędy zostaną wyłapane dopiero na poziomie testów. Możemy sobie radośnie kodować bez opamiętania dane zadanie, a dopiero na koniec dowiemy się, że połowę klas trzeba przenieść do odpowiednich miejsc zgodnie ze wcześniej przyjętą konwencją. Jednak na ten moment nie istnieje lepsze rozwiązanie tego problemu.

Własne adnotacje

Przy okazji dwóch baz danych trzeba odpowiednio zarządzać transakcjami. Nie chcemy przecież, żeby te dwa koncepty nam się ze sobą wymieszały. Z tego powodu trzeba zastosować podejście opisane w tym artykule, czyli użyć właściwości transactionManager z adnotacji @Transactional. Jednak, aby mieć wszystko w jednym miejscu postanowiłem stworzyć własne adnotacje per baza danych.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package pl.devcezz.shelter.commons.infrastructure;

import org.springframework.transaction.annotation.Transactional;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Transactional(transactionManager = "adoptionTransactionManager")
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface AdoptionTransactional {
}

Teraz nie muszę się martwić czy na pewno przekazałem odpowiedni literał. Wystarczy, że wykorzystam @AdoptionTransactional w odpowiednim miejscu modułu shelter albo proposal. Takie samo podejście oczywiście istnieje dla catalogue.

Podsumowanie

To chyba na tyle w tym wpisie. Zapomniałem napisać jeszcze jedną, bardzo ważną rzecz. Zmieniłem swoje podejście co do CQRS. Nie stosuje już własnego rozwiązania opisanego tutaj, tylko opieram się o EventListener Springa. Uznałem, że nie ma co wymyślać koła na nowo. To rozwiązanie na ten moment mi w zupełności wystarcza.

Daj znać czy Ty również masz jakiś projekt, który rozwijasz. Z chęcią zweryfikowałbym z kimś swoje podejście do tego tematu. Napisz o tym w komentarzu!

Link do GitHub: https://github.com/cezarysanecki/animal-shelter
Tag: 20220820-animalshelter-dwa-kamienie-milowe-zaliczone