Chapitre Développement avec Quarkus
Améliorer notre application
Nous avons écrit notre première application mais il y a de nombreuses choses qu'il nous reste à découvrir dans Quarkus.
Nous allons visiter des fonctionnalités telles que :
- l'utilisation de CDI
- exécuter un code au démarrage de Quarkus
- ajouter de la configuration pour l'application
- configurer les traces et les logs
Objectifs
- Créer un composant dans le backend qui lit une liste de monstres disponibles à l'adoption
- la liste des monstres est un fichier JSON stocké sur le disque
- le fichier est référencé par une propriété dans la configuration du backend
- le composant est injecté dans une resource REST pour être utilisée
- nous créons une resource REST retournant la liste des monstres à adopter.
- nous créons un nouveau client REST pour obtenir les données de cette ressource REST
- nous modifions la page d'accueil pour ajouter un bouton vers la page d'ajout d'adoptions
- nous créons la page pour les adoptions
- nous ajoutons un controleur pour gérer l'affichage de la page pour les adoptions ainsi que pour la soumission du formulaire.
- nous ajoutons des logs et configurons les logs pour voir passer les appels réseaux
Architecture de notre application.
Télécharger la liste des monstres à adopter.
La liste des monstres à adopter est dans un fichier JSON stocké dans le projet GIT au niveau du répertoire monster-adoption-factory/backend-monster-adoption-store/src/main/resources/monsters.json
.
La structure de données est la suivante :
Le fichier est référencé par une propriété dans la configuration du backend
Créer une propriété dans la configuration du backend pour référencer le fichier JSON.
Par exemple, utilisons la propriété suivante :
backend.file.monsters = src/main/resources/monsters.json
Modifier le backend pour gérer les adoptions
Nous allons créer le composant qui nous permet de lire la liste des monstres disponibles à l'adoption.
Créer le modèle
Nous allons créer les classes pour implémenter le schéma suivant.
La classe Monster
contient les propriétés définissant les entités de type Monstre.
La classe AdoptionRepository
contient les méthodes pour gérer les monstres.
La classe AdoptionMemoryRepository
est une implémentation de l'interface AdoptionRepository
qui stocke les monstres en mémoire vive.
La classe MonsterFileLoader
est une classe qui charge les monstres depuis un fichier JSON au démarrage de l'application.
Créer la classe Monster
Copier le code suivant dans le fichier Monster.java
dans le répertoire src/main/java/com/byoskill/adoption/model/
du backend.
package com.byoskill.adoption.model;
public class Monster {
private Integer id;
private String monsterUUID;
private String name;
private String description;
private Integer price;
private Integer age;
private String location;
public Monster() {
}
public String getMonsterUUID() {
return monsterUUID;
}
public void setMonsterUUID(String monsterUUID) {
this.monsterUUID = monsterUUID;
}
private Integer monsterId;
public Integer getMonsterId() {
return monsterId;
}
public void setMonsterId(Integer monsterId) {
this.monsterId = monsterId;
}
public String getName() {
return name;
}
public void setId(Integer id) {
this.id = id;
}
public Integer getId() {
return id;
}
public void setName(String name) {
this.name = name;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public Integer getPrice() {
return price;
}
public void setPrice(Integer price) {
this.price = price;
}
public Integer getAge() {
return age;
}
public void setAge(Integer age) {
this.age = age;
}
public String getLocation() {
return location;
}
public void setLocation(String location) {
this.location = location;
}
}
Cette classe contient toutes les propriétés d'un monstre. Ces propriétés seront chargées depuis le fichier JSON.
Créer l'interface du connecteur
Copier le code suivant dans le fichier AdoptionRepository.java
dans le répertoire src/main/java/com.byoskill.adoption.repository/
du backend.
package com.byoskill.adoption.repository;
import java.util.List;
import com.byoskill.adoption.model.Monster;
/**
* This interface describe the list of methods available to interact with the adoption system.
*/
public interface AdoptionRepository {
List<Monster> getAllMonsters();
void addMonsterToAdopt(Monster monster);
Monster getMonsterByUuid(String id);
List<Monster> searchMonstersByName(String name);
void deleteMonsterByUuid(String id);
Monster updateMonsterByUuid(String id, Monster monster);
}
Cette interface définit les méthodes que le connecteur doit implémenter pour gérer les monstres.
Créez la classe MonsterView qui va être utilisée pour transporter notre liste de monstres dans un objet JSON :
Voici un exemple de code :
import java.util.List;
public record MonsterView(List<Monster> monsters) {
}
Créer l'implémentation qui stocke les monstres en mémoire
Copier le code suivant dans le fichier AdoptionMemoryRepository.java
dans le répertoire src/main/java/com/byoskill/adoption/adapter/memory/
du backend.
package com.byoskill.adapters.memory;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.stream.Collectors;
import com.byoskill.adoption.model.Monster;
import com.byoskill.adoption.repository.AdoptionRepository;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class AdoptionMemoryRepository implements AdoptionRepository {
private static int counter = 0;
private final Logger logger;
private List<Monster> monsters;
@Inject
public AdoptionMemoryRepository(Logger logger) {
monsters = new ArrayList<>();
this.logger = logger;
}
@Override
public List<Monster> getAllMonsters() {
return Collections.unmodifiableList(monsters);
}
public void addMonsterToAdopt(Monster monster) {
logger.info("Adding monster to adoption list : " + monster.getName());
monsters.add(monster);
monster.setId(++counter);
monster.setMonsterUUID(UUID.randomUUID().toString());
}
@Override
public Monster getMonsterByUuid(String id) {
return monsters.stream().filter(m -> Objects.equals(m.getMonsterUUID(), id)).findFirst().orElse(null);
}
@Override
public List<Monster> searchMonstersByName(String name) {
return monsters.stream().filter(m -> m.getName().contains(name)).collect(Collectors.toList());
}
@Override
public void deleteMonsterByUuid(String id) {
monsters.removeIf(m -> m.getMonsterUUID().equals(id));
}
@Override
public Monster updateMonsterByUuid(String uuid, Monster monster) {
if (uuid == null || monster == null) {
return null;
}
Monster monsterToUpdate = getMonsterByUuid(uuid);
if (monsterToUpdate != null) {
if (monster.getName() != null) monsterToUpdate.setName(monster.getName());
if (monster.getAge() != null) monsterToUpdate.setAge(monster.getAge());
if (monster.getDescription() != null) monsterToUpdate.setDescription(monster.getDescription());
if (monster.getLocation() != null) monsterToUpdate.setLocation(monster.getLocation());
if (monster.getPrice() != null) monsterToUpdate.setPrice(monster.getPrice());
} else throw new IllegalArgumentException("Monster not found");
return monsterToUpdate;
}
}
Créer le composant qui charge les monstres depuis le disque dur
Copier le code suivant dans le fichier MonsterFileLoader.java
dans le répertoire src/main/java/com/byoskill/adoption/adapter/memory/
du backend.
package com.byoskill.adapters.memory;
import java.io.File;
import java.io.IOException;
import java.util.List;
import java.util.stream.Stream;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.byoskill.adoption.model.Monster;
import com.byoskill.adoption.repository.AdoptionRepository;
import com.fasterxml.jackson.core.exc.StreamReadException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DatabindException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.quarkus.runtime.StartupEvent;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;
@ApplicationScoped
public class MonsterFileLoader {
private static final Logger LOGGER = LoggerFactory.getLogger(MonsterFileLoader.class);
public static class MonsterList {
public List<Monster> monsters;
public Stream<Monster> stream() {
return monsters.stream();
}
}
public void initLoad(@Observes StartupEvent event,
AdoptionRepository AdoptionRepository,
@ConfigProperty(name = "backend.file.monsters") String jsonFileName ) throws StreamReadException, DatabindException, IOException {
File monsterFileName = new File(jsonFileName);
LOGGER.info("Loading default monster list from {}", monsterFileName.getAbsolutePath());
MonsterList monsters = new ObjectMapper().readValue(monsterFileName, new TypeReference<MonsterList>() {});
monsters.stream().forEach(monster -> adoptionRepository.addMonsterToAdopt(monster));
LOGGER.info("Monsters loaded successfully");
}
}
Tester l'intégration de l'interface avec l'implémentation
Nous allons écrire des tests pour vérifier que la méthode d'ajout de monstres à l'adoption fonctionne correctement.
Copier le code suivant dans le fichier AdoptionRepositoryTest.java
dans le répertoire src/test/java/com/byoskill/adoption/repository/
du backend.
Version synchrone
package adoption.domain;
import io.quarkus.test.junit.QuarkusTest;
import io.smallrye.mutiny.Multi;
import io.smallrye.mutiny.Uni;
import io.smallrye.mutiny.helpers.test.AssertSubscriber;
import io.smallrye.mutiny.helpers.test.UniAssertSubscriber;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Optional;
import java.util.function.Predicate;
@QuarkusTest
class AdoptionRepositoryTest {
private static final Integer MONSTERS_IN_JSON = 10;
private static Predicate<? super Monster> hasMonster(final String string) {
return (m) -> m.getName().equals(string);
}
@Inject
AdoptionRepository monsterRepository;
@Test
void testIntegrationMonsterRepository() {
final List<Monster> items = monsterRepository.getAllMonsters();
Assertions.assertFalse(items.isEmpty(), "We should have some monsters");
Assertions.assertTrue(items.stream().anyMatch(hasMonster("Dracula")), "Dracula is present");
}
@Test
void testSearchMonsterByName() {
final List<Monster> monstersWithDraculaName = monsterRepository.searchMonstersByName("Dracula");
Assertions.assertFalse(monstersWithDraculaName.isEmpty(), "We should have one monster");
Assertions.assertTrue(monstersWithDraculaName.stream().anyMatch(hasMonster("Dracula")), "Dracula is present");
}
@Test
void testgetAnyMonsters() {
final List<Monster> items = monsterRepository.getAllMonsters();
Assertions.assertFalse(items.isEmpty(), "We should have one monster");
}
}
Version asynchrone
package com.byoskill.adoption.repository;
import com.byoskill.domain.adoption.model.Monster;
import com.byoskill.domain.adoption.repository.AdoptionRepository;
import io.quarkus.test.junit.QuarkusTest;
import io.smallrye.mutiny.Multi;
import io.smallrye.mutiny.Uni;
import io.smallrye.mutiny.helpers.test.AssertSubscriber;
import io.smallrye.mutiny.helpers.test.UniAssertSubscriber;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Optional;
import java.util.function.Predicate;
@QuarkusTest
class MonsterRepositoryTest {
private static final Integer MONSTERS_IN_JSON = 10;
@Inject
AdoptionRepository monsterRepository;
@Test
void testIntegrationMonsterRepository() {
final Uni<List<Monster>> allMonstersP = monsterRepository.getAllMonsters().collect().asList();
final var subscriber = allMonstersP.subscribe().withSubscriber(UniAssertSubscriber.create());
final var sut = subscriber.assertCompleted();
final List<Monster> items = sut.getItem();
Assertions.assertFalse(items.isEmpty(), "We should have some monsters");
Assertions.assertTrue(items.stream().anyMatch(hasMonster("Dracula")), "Dracula is present");
}
private Predicate<? super Monster> hasMonster(final String string) {
return (m) -> m.getName().equals(string);
}
@Test
void testSearchMonsterByName() {
final Uni<List<Monster>> monstersWithDraculaName = monsterRepository.searchMonstersByName("Dracula", Optional.of(10)).collect().asList();
final var subscriber = monstersWithDraculaName.subscribe().withSubscriber(UniAssertSubscriber.create());
final var sut = subscriber.assertCompleted();
final List<Monster> items = sut.getItem();
Assertions.assertFalse(items.isEmpty(), "We should have one monster");
Assertions.assertTrue(items.stream().anyMatch(hasMonster("Dracula")), "Dracula is present");
}
@Test
void testgetAnyMonsters() {
final Multi<Monster> monsters = monsterRepository.getAllMonsters();
final var subscriber = monsters.subscribe().withSubscriber(AssertSubscriber.create());
final var sut = subscriber.awaitItems(1);
final List<Monster> items = sut.getItems();
Assertions.assertFalse(items.isEmpty(), "We should have one monster");
}
}
Créer la resource REST associée aux adoptions
Nous allons créer la ressource REST pour les adoptions. Cette ressource offre les méthodes suivantes :
GET /adoptions : Renvoie la liste des monstres à l'adoption
GET /adoptions/{id} : Renvoie le monstre avec l'ID spécifié
POST /adoptions : Ajoute un monstre à l'adoption
PUT /adoptions/{id} : Met à jour le monstre avec l'ID spécifié
DELETE /adoptions/{id} : Supprime le monstre de la liste d'adoption
N'oubliez pas d'injecter le repository avec l'annotation @Inject.
Copier-collez le code suiant pour créer les différentes métthodes de la ressource REST :
package adoption.rest;
import adoption.domain.AdoptionRepository;
import adoption.domain.Monster;
import jakarta.inject.Inject;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import org.jboss.resteasy.reactive.ResponseStatus;
@Path("/adoptions")
@Produces(MediaType.APPLICATION_JSON)
public class AdoptionResource {
@Inject
AdoptionRepository adoptionRepository;
@GET
public MonsterView getAllMonsters() {
return new MonsterView(adoptionRepository.getAllMonsters());
}
@GET
@Path("/{id}")
public Monster getMonsterByUuid(String id) {
return adoptionRepository.getMonsterByUuid(id);
}
@GET
@Path("/search/{name}")
public MonsterView searchMonstersByName(String name) {
return new MonsterView(adoptionRepository.searchMonstersByName(name));
}
@POST
public Monster createMonster(Monster monster) {
adoptionRepository.addMonsterToAdopt(monster);
return monster;
}
@DELETE
@Path("/{id}")
@ResponseStatus(204)
public void deleteMonsterById(String id) {
adoptionRepository.deleteMonsterByUuid(id);
}
@PUT
@Path("/{id}")
public Monster updateMonsterById(String id, Monster monster) {
return adoptionRepository.updateMonsterByUuid(id, monster);
}
}
Tester la ressource REST
Nous allons désormais écrire les différents tests pour vérifier que notre ressource REST est correctement implémentée.
Voici un exemple que vous pouvez copier coller dans le fichier AdoptionResourceTest.java
.
package adoption.rest;
import adoption.domain.AdoptionRepository;
import adoption.domain.Monster;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import jakarta.ws.rs.core.MediaType;
import static org.hamcrest.CoreMatchers.*;
import java.util.List;
import org.hamcrest.Description;
import org.hamcrest.Matchers;
import org.hamcrest.TypeSafeMatcher;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static io.restassured.RestAssured.given;
@QuarkusTest
public class AdoptionResourceTest {
@Inject
private AdoptionRepository adoptionRepository;
public static class GreaterOrEqualsThanSizeMatcher extends TypeSafeMatcher<List<?>> {
private int expectedSize;
public GreaterOrEqualsThanSizeMatcher(int expectedSize) {
this.expectedSize = expectedSize;
}
@Override
public void describeTo(Description description) {
description.appendText("The unmber of items should be greater than " + expectedSize);
}
@Override
protected boolean matchesSafely(List<?> item) {
return item != null && item.size() > expectedSize;
}
}
@Test
void testGetMonsters() {
given()
.when().get("/adoptions")
.then()
.statusCode(200)
.contentType(MediaType.APPLICATION_JSON)
.body("monsters", new GreaterOrEqualsThanSizeMatcher(10));
}
@DisplayName("Essai de récupération d'un monstre mais son ID n'existe pas")
@Test
void testGetOneMonster_not_found() {
given()
.when().get("/adoptions/randomkekekf")
.then()
.statusCode(204)
.contentType(MediaType.APPLICATION_JSON);
}
@DisplayName("Essai de récupération d'un monstre, le monstre existe")
@Test
void testGetOneMonster_existing() {
Monster firstMonster = adoptionRepository.getAllMonsters().get(0);
given()
.when().get("/adoptions/" + firstMonster.getMonsterUUID())
.then()
.contentType(MediaType.APPLICATION_JSON)
.body("name", equalTo( "Godzilla"));
}
@DisplayName("Recherche d'un monstre par son nom.")
@Test
void testSearchMonster_by_name() {
given()
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.when().get("/adoptions/search/Dracula")
.then()
.statusCode(200)
.body("monsters", Matchers.hasSize(1))
.body("monsters[0].name", equalTo("Dracula"));
}
@DisplayName("Ajout d'un monstre pour l'adoption")
@Test
void testAddMonster_for_adoption() {
given()
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.body("""
{"name": "Windigo", "location": "Canada", "age": 100, "description": "", "price":"200"}
""")
.when()
.post("/adoptions")
.then()
.statusCode(200)
.body("name", equalTo("Windigo"))
.body("id", not(is("")));
}
@DisplayName("Suppression d'un monstre pour l'adoption")
@Test
void testDeletionMonster_for_adoption() {
Monster firstMonster = adoptionRepository.getAllMonsters().get(0);
given()
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.when()
.delete("/adoptions/" + firstMonster.getMonsterUUID())
.then()
.statusCode(204);
}
@DisplayName("Update du prix d'un monstre pour l'adoption")
@Test
void testUpdateMonster_price() {
Monster firstMonster = adoptionRepository.getAllMonsters().get(0);
given()
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.body("""
{"price": "300"}
""")
.when()
.put("/adoptions/" + firstMonster.getMonsterUUID())
.then()
.statusCode(200);
}
}
Modifications du Frontend
Nous allons modifier le backend pour offrir la possibilité d'ajouter un monstre à l'adoption. Nous allons modifier la page principale pour ajouter un bouton qui permettra d'ajouter un monstre à l'adoption. Une nouvelle page Web sera également offerte pour permettre à l'utilisateur d'ajouter un monstre à l'adoption.
Nous allons devoir créer un nouveau module fonctionnel dans notre application responsable de la gestion des adoptions. Ce module fonctionnel contiendra son propre client REST pour gérer les adoptions. Cela permettra plus tard d'avoir un microservice dédié aux adoptions.
Création d'un nouveau module fonctionnel
Dans le projet frontend-monster-adoption-store
, nous allons créer un nouveau module fonctionnel. Pour cela, créer un package java nommé com.byoskill.adoption
. Puis créer les sous-packages suivants :
com.byoskill.adoption.model
com.byoskill.adoption.controllers
com.byoskill.adoption.client
Création de la page Web
Créer un fichier adoptionForm.html
dans le répertoire src/main/resources/templates
.
Copier le code suivant dans le fichier adoptions.html
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Add Monster for Adoption</title>
<!-- Latest compiled and minified CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@3.4.1/dist/css/bootstrap.min.css"
integrity="sha384-HSMxcRTRxnN+Bdg0JdbxYKrThecOKuH5zCYotlSAcp1+c8xmyTe9GYg1l9a69psu" crossorigin="anonymous">
<!-- Optional theme -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@3.4.1/dist/css/bootstrap-theme.min.css"
integrity="sha384-6pzBo3FDv/PJ8r2KRkGHifhEocL+1X2rVCTTkUfGk7/0pbek5mMa1upzvWbrUbOZ" crossorigin="anonymous">
<!-- Latest compiled and minified JavaScript -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@3.4.1/dist/js/bootstrap.min.js"
integrity="sha384-aJ21OjlMXNL5UyIl/XNwTMqvzeRMZH2w8c5cRVpzpU8Y5bApTppSuUkhZXN0VxHd"
crossorigin="anonymous"></script>
</head>
<body>
<div class="container">
<h1>Add Monster for Adoption</h1>
<form action="/adoption/submit" method="post" class="form">
<div class="form-group">
<label for="name" for="name">Name:</label><br/>
<input class="form-control" type="text" id="name" name="name" placeholder="Monster name"
required/><br/><br/>
</div>
<div class="form-group">
<label for="description">Description:</label><br/>
<textarea
id="description"
name="description"
rows="4"
cols="50"
class="form-control"
placeholder="Monster description"
required
></textarea>
</div>
<br/><br/>
<div class="form-group">
<label for="price">Price:</label><br/>
<input class="form-control" type="number" id="price" name="price" placeholder="10"
required/><br/><br/>
</div>
<div class="form-group">
<label for="age">Age:</label><br/>
<input class="form-control" type="number" id="age" name="age" placeholder="100" required/><br/><br/>
</div>
<div class="form-group">
<label for="location">Location:</label><br/>
<input class="form-control" type="text" id="location" name="location" placeholder="near your home" required/><br/><br/>
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
</div>
</div>
</body>
</html>
Création des classes pour échanger les données
Nous avons besoin d''une classe pour envoyer les données du formulaire au backend. Généralement ces classes sont appelées DTO ou Value Object.
Nous allons créer une classe nommée MonsterForm
qui contiendra les données du formulaire.
Créer un fichier MonsterForm.java
dans le répertoire `com.byoskill.adoption.
Copier le code suivant dans le fichier MonsterForm.java
:
package com.byoskill.adoption.model;
public class MonsterForm {
private String name;
private String description;
private Integer price;
private String location;
private Integer age;
private String monsterUuid;
public String getMonsterUuid() {
return monsterUuid;
}
public void setMonsterUuid(String monsterUuid) {
this.monsterUuid = monsterUuid;
}
public Integer getAge() {
return age;
}
public void setAge(Integer age) {
this.age = age;
}
public String getLocation() {
return location;
}
public void setLocation(String location) {
this.location = location;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public Integer getPrice() {
return price;
}
public void setPrice(Integer price) {
this.price = price;
}
}
Création du contrôleur pour afficher la page web d'adoption
Créer un fichier AdoptionController.java
dans le répertoire com.byoskill.adoption.controllers
.
Copier le code suivant dans le fichier AdoptionController.java
:
package com.byoskill.adoption.controllers;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import com.byoskill.adoption.model.MonsterForm;
import com.byoskill.communication.client.CommunicationMessageClient;
import com.byoskill.communication.model.WelcomeMessage;
import io.quarkus.qute.Template;
import io.quarkus.qute.TemplateInstance;
import io.smallrye.common.annotation.Blocking;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.FormParam;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path("/adoptions")
public class AdoptionController {
@Inject
Template adoptionForm;
@Blocking
@GET
@Produces(MediaType.TEXT_HTML)
public TemplateInstance get() {
return adoptionForm.instance();
}
@POST
@Path("/submit")
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
public Uni<Response> addMonster(
@FormParam("name") String name,
@FormParam("description") String description,
@FormParam("price") Integer price,
@FormParam("age") Integer age,
@FormParam("location") String location) {
MonsterForm monster = new MonsterForm();
monster.setName(name);
monster.setDescription(description);
monster.setPrice(price);
monster.setAge(age);
monster.setLocation(location);
monster.setImage_url("https://robohash.org/"+ name + ".png?set=set1");
Uni<Response> item = Uni.createFrom().item(Response.seeOther(URI.create("/")).build());
return adoptionClient.addMonster(monster)
.ifNoItem().after(Duration.ofSeconds(1)).failWith(new WebApplicationException(500))
.flatMap(r -> item);
}
}
Création du client REST pour gérer les adoptions
Créer un fichier AdoptionClient.java
dans le répertoire com.byoskill.adoption.client
.
Copier le code suivant dans le fichier AdoptionClient.java
:
package com.byoskill.adoption.client;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
import com.byoskill.adoption.model.MonsterForm;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/adoptions")
@RegisterRestClient(configKey = "adoption-api")
public interface AdoptionClient {
@POST
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
MonsterForm addMonster(MonsterForm monsterForm);
}
Déclaration du client dans le contrôleur
Modifier le code du controlleur pour déclarer le client REST :
@RestClient
AdoptionClient adoptionClient;
Ajouter l'adresse du microservice pour gérer les adoptions
Ajouter l'adresse du microservice pour gérer les adoptions dans le fichier application.properties
:
quarkus.rest-client.adoption-api.url=http://localhost:8090
Ajouter une redirection sur la page d'accueil
Il serait intéressant de rajouter une redirection vers la page d'accueil après avoir ajouté le monstre à l'adoption.
Nous pouvons utiliser la classe Response
pour rediriger l'utilisateur vers la page d'accueil.
Modifier le code du contrôleur pour ajouter la redirection :
@POST
@Path("/submit")
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
public Response addMonster(
@FormParam("monsterUUID") String monsterUUID,
@FormParam("name") String name,
@FormParam("description") String description,
@FormParam("price") Integer price,
@FormParam("age") Integer age,
@FormParam("location") String location,
@Context UriInfo uriInfo) {
MonsterForm monster = new MonsterForm();
monster.setName(name);
monster.setDescription(description);
monster.setPrice(price);
monster.setAge(age);
monster.setLocation(location);
adoptionClient.addMonster(monster);
URI uri = uriInfo.getBaseUriBuilder().path("/").build();
return Response.seeOther(uri).build();
}
Nous avons besoin d'utiliser la classe UriInfo
pour calculer l'URI de la page d'accueil.
Nous utilisons URIInfo
pour construire l'URI de la page d'accueil.
Puis nous retournons la réponse Response.seeOther()
avec l'URI de la page d'accueil afin de réaliser la redirection.
Tester l'ajout d'un monstre
Ouvrez le navigateur et allez à l'adresse http://localhost:8080/adoptions
.
Remplissez le formulaire et cliquez sur le bouton "Submit".
Vous devriez être redirigé vers la page d'accueil.
Mettre à jour la page d'accueil
Notre page d'accueil doit intégrer la liste de monstres désormais reçue par le backend :
Modifiez votre page HTML index.html pour suivre ce modèle :
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Latest compiled and minified CSS -->
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@3.4.1/dist/css/bootstrap.min.css"
integrity="sha384-HSMxcRTRxnN+Bdg0JdbxYKrThecOKuH5zCYotlSAcp1+c8xmyTe9GYg1l9a69psu"
crossorigin="anonymous"
/>
<!-- Optional theme -->
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@3.4.1/dist/css/bootstrap-theme.min.css"
integrity="sha384-6pzBo3FDv/PJ8r2KRkGHifhEocL+1X2rVCTTkUfGk7/0pbek5mMa1upzvWbrUbOZ"
crossorigin="anonymous"
/>
<!-- Latest compiled and minified JavaScript -->
<script
src="https://cdn.jsdelivr.net/npm/bootstrap@3.4.1/dist/js/bootstrap.min.js"
integrity="sha384-aJ21OjlMXNL5UyIl/XNwTMqvzeRMZH2w8c5cRVpzpU8Y5bApTppSuUkhZXN0VxHd"
crossorigin="anonymous"
></script>
<title>Monster Adoption Platform</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
}
header {
background-color: #333;
color: #fff;
padding: 10px 0;
text-align: center;
}
nav {
background-color: #444;
color: #fff;
text-align: center;
padding: 10px 0;
}
nav a {
color: #fff;
text-decoration: none;
margin: 0 10px;
}
.container {
width: 80%;
margin: auto;
padding: 20px 0;
}
.product {
border: 1px solid #ccc;
padding: 20px;
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.product img {
max-width: 100px;
max-height: 100px;
}
footer {
background-color: #333;
color: #fff;
text-align: center;
padding: 10px 0;
position: absolute;
width: 100%;
}
</style>
</head>
<body>
<header>
<h1>Monster Adoption Platform</h1>
</header>
<nav>
<a href="#">Home</a>
<a href="/adoptions">Adoptions</a>
<a href="#">About Us</a>
<a href="#">Contact</a>
</nav>
<div class="container">
<div class="bienvenue justify-center text-center">
<h2>{motd}</h2>
</div>
{#for monster in monsters}
<div class="product">
<img src="{monster.url}" alt="{monster.name}" />
<div>
<h2>{monster.name}</h2>
<p>Description: {monster.description}</p>
<p>Price: ${monster.price}</p>
<a href="/adopt/{monster.id}" class="btn btn-primary">Adopt Now</a>
</div>
</div>
{/for}
</div>
<footer>
<p>© 2024 Monster Adoption Platform. All rights reserved.</p>
</footer>
</body>
</html>
Modifiez le controleur dans HomePage.java pour utiliser votre client REST :
package controllers;
import adoption.client.AdoptionClient;
import adoption.client.MonsterView;
import adoption.dto.MonsterDto;
import communication.client.CommunicationMessageClient;
import communication.dto.WelcomeMessage;
import io.smallrye.mutiny.Multi;
import io.smallrye.mutiny.Uni;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import io.quarkus.qute.TemplateInstance;
import io.quarkus.qute.Template;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
@Path("")
public class HomePage {
@Inject
Template index;
@Inject
@RestClient
CommunicationMessageClient communicationMessageClient;
@Inject
@RestClient
AdoptionClient adoptionClient;
@GET
@Produces(MediaType.TEXT_HTML)
public TemplateInstance get() {
MonsterView allMonsters = adoptionClient.getAllMonsters();
WelcomeMessage welcomeMessage = communicationMessageClient.getWelcomeMessage();
return index.instance()
.data("monsters", allMonsters.monsters())
.data("motd", welcomeMessage.motd());
}
}
Ajouter la méthode dans le client REST d'adoption pour récupérer la liste des monstres :
package adoption.client;
import adoption.dto.MonsterForm;
import jakarta.ws.rs.*;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
import jakarta.ws.rs.core.MediaType;
@Path("/adoptions")
@RegisterRestClient(configKey = "adoption-api")
public interface AdoptionClient {
@POST
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
MonsterForm addMonster(MonsterForm monsterForm);
@GET
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
MonsterView getAllMonsters();
}
Améliorer les logs de l'application
Nous pouvons améliorer les logs de l'application parce que nous ne voyons aucun échange entre les pages web, le frontend et le backend.
Nous pouvons commencer par ajouter des logs quand nous recevons une soumission de formulaire.
Déclarons un logger dans la classe AdoptionController
avec le code suivant :
private static final Logger LOGGER = Logger.getLogger(AdoptionController.class);
Nous utilisons JBoss logging qui est fourni par défaut avec Quarkus.
Il existe d'autres façons plus simple d'intégrer un logger par exemple ainsi :
@LoggerName("com.byoskill.adoptions.controllers")
Logger LOGGER;
Dans la méthode qui traite la soumission du formulaire, nous pouvons ajouter un log avec le code suivant :
LOGGER.info("Received new adoption request with the following details :" + name + ", " + description + ", " + price + ", " + age + ", " + location);
Enfin, il est possible de définir globalement la verbosité des logs en utilisant le code suivant dans le fichier application.properties
:
quarkus.log.level=INFOl
It is also possible to improve our logs by getting more details about what is happening with our rest clients.
Modify the application.properties
in the frontend project and add the following code :
quarkus.rest-client.logging.scope=request-response
quarkus.rest-client.logging.body-limit=50
quarkus.log.category."org.jboss.resteasy.reactive.client.logging".level=DEBUG
Ajout de traces avec OpenTelemetry
Nous allons explorer l'intégration d'OpenTelemetry, un framework d'observabilité, avec votre application Quarkus existante. OpenTelemetry joue un rôle crucial en fournissant une observabilité unifiée dans les architectures de microservices.
L'Importance de la Tracabilité Distribuée
La traçabilité distribuée est une composante essentielle dans le développement et l'exploitation des systèmes logiciels modernes, en particulier dans les environnements distribués tels que les architectures de microservices ou les infrastructures basées sur le cloud. Elle consiste à suivre et enregistrer le cheminement d'une requête ou d'une transaction à travers différents composants d'un système distribué.
Voici quelques points clés sur l'importance de la traçabilité distribuée :
-
Détection et Diagnostic des Problèmes : Dans un système distribué, une seule requête peut traverser de multiples services. En cas de défaillance ou de problème de performance, il est crucial de pouvoir identifier rapidement la source du problème. La traçabilité distribuée permet de suivre le cheminement de la requête à travers chaque service, facilitant ainsi la détection et le diagnostic des problèmes.
-
Optimisation des Performances : En comprenant précisément les interactions entre les différents services, il est possible d'identifier les goulots d'étranglement et les zones de performance suboptimale. La traçabilité distribuée permet d'optimiser les performances en identifiant les améliorations potentielles à apporter à chaque composant du système.
-
Compréhension du Comportement Applicatif : En observant le flux de requêtes à travers le système, les développeurs peuvent mieux comprendre le comportement réel de leur application. Cela peut aider à prendre des décisions éclairées sur la conception et l'évolution de l'architecture logicielle.
-
Analyse de la Sécurité : La traçabilité distribuée peut également jouer un rôle crucial dans la détection des menaces et la réponse aux incidents de sécurité. En suivant le cheminement des requêtes, il est possible d'identifier les comportements suspects ou les tentatives d'accès non autorisées.
En résumé, la traçabilité distribuée est un outil puissant pour assurer la fiabilité, les performances et la sécurité des systèmes distribués. Elle permet aux développeurs et aux opérateurs de mieux comprendre et gérer la complexité croissante des environnements informatiques modernes.
Configuration du Projet :
- Ajoutez les dépendances OpenTelemetry nécessaires au fichier pom.xml de votre projet.
quarkus extension add opentelemetry
- Configurez l'exportateur OpenTelemetry pour envoyer les données d'observabilité vers votre backend de choix.
Modifiez le fichier application.properties
du projet monster-adoption-store-backend
avec les lignes suivantes :
quarkus.application.name=monster-adoption-store-backend
quarkus.otel.exporter.otlp.traces.endpoint=http://localhost:4317
quarkus.otel.exporter.otlp.traces.headers=authorization=SECRET_OPENTELEMETRY_TOKEN
quarkus.log.console.format=%d{HH:mm:ss} %-5p traceId=%X{traceId}, parentId=%X{parentId}, spanId=%X{spanId}, sampled=%X{sampled} [%c{2.}] (%t) %s%e%n
# Alternative to the console log
quarkus.http.access-log.pattern="...traceId=%{X,traceId} spanId=%{X,spanId}"
Modifiez le fichier application.properties
du projet monster-adoption-store-frontend
avec les lignes suivantes :
quarkus.application.name=monster-adoption-store-frontend
quarkus.otel.exporter.otlp.traces.endpoint=http://localhost:4317
quarkus.otel.exporter.otlp.traces.headers=authorization=SECRET_OPENTELEMETRY_TOKEN
quarkus.log.console.format=%d{HH:mm:ss} %-5p traceId=%X{traceId}, parentId=%X{parentId}, spanId=%X{spanId}, sampled=%X{sampled} [%c{2.}] (%t) %s%e%n
# Alternative to the console log
quarkus.http.access-log.pattern="...traceId=%{X,traceId} spanId=%{X,spanId}"
Note: Il est possible de tester avec un vrai serveur de télémétrie en utilisant le projet opentelemetry
. Docker compose est nécessaire.
La documentation est ici : https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/examples/demo
Services chargés :
- Jaeger at http://0.0.0.0:16686
- Zipkin at http://0.0.0.0:9411
- Prometheus at http://0.0.0.0:9090
Consultez la documentation officielle d'OpenTelemetry et de Quarkus pour plus d'informations détaillées.
Références :
- https://quarkus.io/guides/cdi-reference
- https://quarkus.io/guides/cdi
- https://jakarta.ee/specifications/cdi/4.0/jakarta-cdi-spec-4.0.html#bean_defining_annotations