Wracam po krótkiej przerwie do opisu działania aplikacji AnimalShelter. Przyznaję, że musiałem się chwilę zastanowić nad czym ostatnio pracowałem. Nie wiem czy to wynika z niepotrzebnej komplikacji w kodzie czy mojego zapominalstwa. Nie ułatwił mi też tego fakt, że od niedawna bardziej skupiam się na nauce Angulara i Quarkusa na podstawie projektu do monitorowania jednostek morskich, o którym wspomniałem w tym artykule. Niemniej jednak zapraszam Cię do zapoznania się z tym krótkim wpisem zawierającym spis najnowszych dokonań.

Lista wszystkich wpisów dotyczących projektu AnimalShelter:
#1 - Opis projektu AnimalShelter
#2 - Pierwsze kroki w backendzie
#3 - Refactoring i prace rozwojowe części serwerowej
#4 - Tworzenie GUI w Angularze
#5 - Zatrzymaj się, przemyśl i zacznij działać!
#6 - Pomysł na architekturę
#7 - Wykorzystanie CQRS
#8 - Ponowna implementacja
#9 - Rozterki architektoniczne
#10 - Podsumowanie + implementacja wysyłki maili
#11 - Programowania ciąg dalszy
#12 - Dopinanie zadań do końca

Generowanie pliku CSV

W dawnej wersji aplikacji jedną z funkcjonalności była możliwość generowania pliku CSV z danymi zwierząt znajdujących się w schronisku. W nowej implementacji chciałem również zawrzeć tą funkcjonalność, ponieważ dążę do tego, aby odzwierciedlić jak najbardziej to co stworzyłem kiedyś. Okazało się to naprawdę proste. Wystarczyło stworzyć nowy generator, o nazwie ShelterCsvGenerator, generujący tablicę bajtów reprezentującą dane w formacie CSV. W nim pobieramy listę zwierząt z bazy danych za pomocą tego samego zapytania co w przypadku tworzenia PDFa. Następnie wykorzystując bibliotekę org.apache.commons:commons-csv dodajemy otrzymane dane do interesującego nas pliku. Konwertujemy go na tablicę bajtów i voila!

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
import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVPrinter;
import pl.devcezz.animalshelter.shelter.read.AnimalProjection;
import pl.devcezz.animalshelter.shelter.read.dto.DataToReportDto;
import pl.devcezz.animalshelter.shelter.read.query.GetDataToReportQuery;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.PrintWriter;

public class ShelterCsvGenerator {

  private final AnimalProjection animalProjection;

  ShelterCsvGenerator(AnimalProjection animalProjection) {
    this.animalProjection = animalProjection;
  }

  public byte[] generate() {
    DataToReportDto data = animalProjection.handle(new GetDataToReportQuery());

    try (var byteArrayOutputStream = new ByteArrayOutputStream();
      var csvPrinter = new CSVPrinter(new PrintWriter(byteArrayOutputStream), createCsvFormat())) {

      for (var animal : data.animals()) {
        csvPrinter.printRecord(
            animal.getAnimalId(), 
            animal.getName(), 
            animal.getAge(), 
            animal.getSpecies(), 
            animal.getGender());
      }
      csvPrinter.flush();

      return byteArrayOutputStream.toByteArray();
    } catch (IOException e) {
      throw new IllegalArgumentException(e);
    }
  }

  private CSVFormat createCsvFormat() {
    return CSVFormat.Builder.create().setHeader(
        "animalId", "name", "age", "species", "gender"
    ).build();
  }
}

Teraz jeślibyśmy chcieli dodać nasz świeżo wygenerowany plik CSV jako załącznik do maila to musimy wywołać odpowiednią metodę fasady i dołączyć do listy załączników. Patrząc na to z dalszej perspektywy można zauważyć, że moduł Mavena odpowiedzialny za generowanie raportów mógłby istnieć w tym samym mikroserwisie co serce aplikacji schroniska. Znalazłby się on po tej samej stronie co pobieranie danych do tworzenia widoków. Z drugiej strony mógłby on być również osobnym mikroserwisem, który nasłuchiwałby na zdarzenia o dodaniu nowego zwierzaka czy adopcji. Jemu niezbędne są tylko dane o aktualnie znajdujących się zwierzętach w schronisku. Jeśli zaakceptowany byłby nowy pupil to otrzymałby on wpis w bazie danych mikroserwisu generatora. Natomiast przy adopcji byłby on stamtąd po prostu usuwany. W ten sposób nie mielibyśmy niepotrzebnego synchronicznego narzutu komunikacyjnego pomiędzy dwoma komponentami.

1
2
3
4
5
6
7
8
9
10
11
  ...

  private List<EmailAttachment> shelterReports() {
    GeneratedFileDto generatedPdfFile = fileGeneratorFacade.generateShelterListPdf();
    GeneratedFileDto generatedCsvFile = fileGeneratorFacade.generateShelterListCsv();

    return List.of(
        new EmailAttachment(generatedPdfFile.content(), generatedPdfFile.filename()),
        new EmailAttachment(generatedCsvFile.content(), generatedCsvFile.filename())
    );
  }

Jeśli chodzi o moduł generujący maile to on mógłby pobierać niezbędne załączniki synchronicznie przez REST zamiast wywoływać bezpośrednio metody fasady FileGeneratorFacade. To rozwiązanie byłoby niezależne od tego czy zdecydowalibyśmy się na umieszczenie generatora w osobnym mikroserwisie czy w tym samym co zarządzanie schroniskiem.

Schemat generowania pliku CSV z danymi zwierząt znajdujących się w schronisku
Schemat generowania pliku CSV z danymi zwierząt znajdujących się w schronisku

Wykorzystanie GraphQL

Pierwsze koty za płoty

Skoro jest to mój pet project to chciałbym wypróbować na nim coś nowego, coś czego nigdy nie wykorzystywałem. Wybór padł na GraphQL, czyli nowy sposób na tworzenie zapytań do API. Po krótkim zapoznaniu się z dostępnymi rozwiązaniami zdecydowałem się na com.graphql-java:graphql-java. Na stronie projektu możemy znaleźć tutorial, który poprowadzi nas krok po kroku w jaki sposób zaimplementować to rozwiązanie w Spring Boot. Na samym początku musimy zadeklarować schemat niezbędny dla GraphQL w oparciu o Schema Definition Language. W przypadku aplikacji AnimalShelter nie będzie to nic wyszukanego.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
type Query {
  animalById(id: ID): AnimalInfo
  allAnimals: [Animal]
}

type AnimalInfo {
  animalId: ID
  name: String
  species: String
  age: Int
  gender: String
  admittedAt: String
  adoptedAt: String
}

type Animal {
  animalId: ID
  name: String
  species: String
  age: Int
  gender: String
  inShelter: Boolean
}

Dostępne są dwa zapytania - animalById zwracające pojedynczy typ AnimalInfo oraz allAnimals dostarczające tablicę typu Animal. Następne w kolejności jest stworzenie komponentu, który nauczymy jakie dane ma zwracać, gdy poprosimy go o dostępne dane zwierząt. Wykorzystamy w tym celu interfejs funkcyjny DataFetcher otrzymujący jako parametr DataFetchingEnvironment. Z niego możemy wydobyć niezbędne argumenty do naszych zapytań jak np. dla animalById będzie to wybrane id.

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
import graphql.schema.DataFetcher;
import io.vavr.collection.List;
import io.vavr.control.Option;
import pl.devcezz.animalshelter.shelter.read.dto.AnimalDto;
import pl.devcezz.animalshelter.shelter.read.dto.AnimalInfoDto;
import pl.devcezz.animalshelter.shelter.read.query.GetAnimalInfoQuery;
import pl.devcezz.animalshelter.shelter.read.query.GetAnimalsQuery;

import java.util.UUID;

class AnimalsGraphQLDataFetchers {

  private final AnimalProjection animalProjection;

  AnimalsGraphQLDataFetchers(AnimalProjection animalProjection) {
    this.animalProjection = animalProjection;
  }

  DataFetcher<AnimalInfoDto> getAnimalByIdDataFetcher() {
    return dataFetchingEnvironment -> {
      UUID animalId = UUID.fromString(dataFetchingEnvironment.getArgument("id"));
      Option<AnimalInfoDto> animal = animalProjection.handle(new GetAnimalInfoQuery(animalId));
      return animal.getOrElse(() -> null);
    };
  }

  DataFetcher<List<AnimalDto>> getAnimalsDataFetcher() {
    return dataFetchingEnvironment -> animalProjection.handle(new GetAnimalsQuery());
  }
}

Na koniec zaimplementujemy providera, w którym połączymy wcześniej utworzone klocki. W skrócie - pobierzemy schemat z pliku, powiążemy zdefiniowane zapytania z wcześniej przedstawionymi metodami getAnimalByIdDataFetcher i getAnimalsDataFetcher, a na koniec utworzymy nowy obiekt klasy GraphQL, który zarejestrujemy jako bean w kontenerze Springa.

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
import com.google.common.base.Charsets;
import com.google.common.io.Resources;
import graphql.GraphQL;
import graphql.schema.GraphQLSchema;
import graphql.schema.idl.RuntimeWiring;
import graphql.schema.idl.SchemaGenerator;
import graphql.schema.idl.SchemaParser;
import graphql.schema.idl.TypeDefinitionRegistry;
import graphql.schema.idl.TypeRuntimeWiring;
import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import java.io.IOException;
import java.net.URL;

@Component
class AnimalsGraphQLProvider {

  private final AnimalsGraphQLDataFetchers animalsGraphQLDataFetchers;
  private GraphQL graphQL;

  AnimalsGraphQLProvider(AnimalsGraphQLDataFetchers animalsGraphQLDataFetchers) {
    this.animalsGraphQLDataFetchers = animalsGraphQLDataFetchers;
  }

  @Bean
  GraphQL graphQL() {
    return graphQL;
  }

  @PostConstruct
  public void init() throws IOException {
    URL url = Resources.getResource("animal-schema.graphqls");
    String sdl = Resources.toString(url, Charsets.UTF_8);
    GraphQLSchema graphQLSchema = buildSchema(sdl);
    this.graphQL = GraphQL.newGraphQL(graphQLSchema).build();
  }

  private GraphQLSchema buildSchema(String sdl) {
    TypeDefinitionRegistry typeRegistry = new SchemaParser().parse(sdl);
    RuntimeWiring runtimeWiring = buildWiring();
    SchemaGenerator schemaGenerator = new SchemaGenerator();
    return schemaGenerator.makeExecutableSchema(typeRegistry, runtimeWiring);
  }

  private RuntimeWiring buildWiring() {
    return RuntimeWiring.newRuntimeWiring()
        .type(TypeRuntimeWiring.newTypeWiring("Query")
            .dataFetcher("animalById", animalsGraphQLDataFetchers.getAnimalByIdDataFetcher()))
        .type(TypeRuntimeWiring.newTypeWiring("Query")
            .dataFetcher("allAnimals", animalsGraphQLDataFetchers.getAnimalsDataFetcher()))
        .build();
  }
}

Na koniec jeszcze chciałem, aby możliwość wykonywania zapytań była dostępna pod innym URL niż jest to zdefiniowane domyślnie. Należało, więc w application.properties dodać następujący wpis - graphql.url=graphql/shelter/animals. Poniżej przedstawiam rezultat naszych prac.

Wykonanie zapytanie przy pomocy GraphQL w Postman Wykonanie zapytanie przy pomocy GraphQL w Postman

Niby wszystko działa jak należy, ale nie do końca leży mi to rozwiązanie. Postanowiłem pogrzebać w Internecie nieco bardziej no i udało się znaleźć według mnie coś lepszego.

Drugie podejście

Wyrzuciłem wszystko to co utworzyłem powyżej (poza animal-schema.graphqls) i do POM dodałem następujące zależności.

1
2
3
4
5
6
7
8
9
10
<dependency>
  <groupId>com.graphql-java-kickstart</groupId>
  <artifactId>graphql-spring-boot-starter</artifactId>
  <version>12.0.0</version>
</dependency>
<dependency>
  <groupId>com.graphql-java-kickstart</groupId>
  <artifactId>graphql-java-tools</artifactId>
  <version>12.0.1</version>
</dependency>

Następnie na podstawie artykułu Piotra Mińskowiego dowiedziałem się, że wystarczy zarejestrować w kontenerze Springa komponent implementujący interfejs GraphQLQueryResolver z nazwami metod odpowiadającym zapytaniom z pliku o rozszerzeniu graphqls. W nich oczywiście piszemy logikę odpowiedzialną za pobranie interesujących nas danych. I to w sumie tyle! Efekt prac można zobaczyć poniżej.

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
import graphql.kickstart.tools.GraphQLQueryResolver;
import org.springframework.stereotype.Component;
import pl.devcezz.animalshelter.shelter.read.dto.AnimalDto;
import pl.devcezz.animalshelter.shelter.read.dto.AnimalInfoDto;
import pl.devcezz.animalshelter.shelter.read.query.GetAnimalInfoQuery;
import pl.devcezz.animalshelter.shelter.read.query.GetAnimalsQuery;

import java.util.List;
import java.util.UUID;

@Component
class AnimalGraphQLQuery implements GraphQLQueryResolver {

  private final AnimalProjection animalProjection;

  AnimalGraphQLQuery(AnimalProjection animalProjection) {
    this.animalProjection = animalProjection;
  }

  public AnimalInfoDto animalById(String id) {
    UUID animalId = UUID.fromString(id);
    return animalProjection.handle(new GetAnimalInfoQuery(animalId))
        .getOrElse(() -> null);
  }

  public List<AnimalDto> allAnimals() {
    return animalProjection.handle(new GetAnimalsQuery()).asJava();
  }
}

Tak jak poprzednio również i tutaj zmieniłem endpoint dla GraphQL. Wystarczyło dodać właściwość graphql.servlet.mapping z odpowiadającą mi wartością /graphql/shelter/animals. Warto również zwrócić uwagę na rozwiązanie o nazwie GraphiQL, dzięki któremu uzyskujemy dostęp do aplikacji, gdzie możemy wykonywać interesujące nas zapytania. Wystarczy w application.properties ustawić następujące opcje.

1
2
3
4
5
6
7
graphql:
  ...
  graphiql:
  mapping: /graphiql
  enabled: true
  endpoint:
    graphql: ${graphql.servlet.mapping}

Teraz wchodząc na localhost:8080/graphiql możemy pobawić się naszym zaimplementowanym rozwiązaniem. Ważne jest wskazanie adresu w konfiguracji GraphiQL, na którym znajduje się udostępnione przez nas API GraphQL.

Działanie GraphiQL w akcji Działanie GraphiQL w akcji

Jak widać poświęcając dodatkową część swojego czasu można znaleźć o wiele czytelniejsze rozwiązania wymagające mniejszą ilość linijek kodu.

Asynchroniczna wysyłka maili

Po zaimplementowaniu możliwości wysyłania maili, gdy nasze schronisko będzie zbliżało się do limitu dostępnego miejsca, bolało mnie to, że użytkownik musiał czekać aż cały proces dobiegnie końca. Przyjmując nowego zwierzaka oczywiście warto, aby dostał on informację zwrotną czy udało się wykonać tą operację. Jednak nie widziałem sensu w tym, żeby to oczekiwanie zawierało w sobie łączenie się z serwerem SMTP. Stąd zacząłem drożyć ten temat i wpadłem na jakże przydatną adnotację Springa @Async.

W moim przypadku metoda już zwracała void co jest zgodne z tym co jest zawarte w dokumentacji. Jednak, aby wszystko działało jak należy niezbędne jest jeszcze dodanie jednej adnotacji. Mianowicie chodzi tutaj o @EnableAsync, którą dodajemy w klasie konfiguracyjnej. W moim przypadku ograniczyłem się tylko do jej użycia jednak możliwe jest również skonfigurowanie asynchroniczności w naszej aplikacji. Jak czytamy w dokumentacji to w konfiguracji trzeba stworzyć nową klasę implementującą interfejs AsyncConfigurer. Przyznam, że nie zagłębiałem się zbytnio w szczegóły. Uznałem, że domyślne ustawienia jak najbardziej wystarczą w tym projekcie.

Podsumowanie

To wszystko co chciałem Ci przedstawić w tym wpisie. Jak widzisz prace poszły do przodu i jeśli chodzi o podstawową część serwerową są praktycznie na ukończeniu. Już niedługo jeszcze raz podejdę do kwestii stworzenia interfejsu użytkownika. Chyba dalej pozostanę przy Angularze, aby jeszcze bardziej zrozumieć mechanizmy jakie nim rządzą.