Chapitre Quarkus et la Persistance
Configuration des sources de données avec Quarkus
Dans ce cours, nous allons apprendre à configurer une source de données (h2) pour stocker nos adoptions en base de données avec Quarkus.
Objectifs
Nous allons dans cette leçon, remplacer le AdoptionRepository
qui est basé sur une implémentation mémoire par une implémentation utilisant la base de données H2.
Création d'un nouvel adapteur pour les adoptions
Nous ne souhaitons pas supprimer le code qui utilise actuellement l'implémentation mémoire pour notre gestion d'adoptions. Nous allons donc utiliser des profils pour démarrer soit la version utilisant la mémoire soit la version H2.
Nous aurons donc un profil particulier pour charger la version H2 :
h2
: pour démarrer l'application avec l'implémentation H2- les autres profils utilisent l'implémentation mémoire.
Pour définir si la version H2 ou mémoire doit être activée, nous allons utiliser un feature toggle, soit une propriété qui contient une valeur qui change en fonction du profil et qui nous permet d'activer certaines portions du code.
Cette propriété sera nommée com.byoskill.adoptions.adapter
.
Nous allons donc créer un fichier de configuration supplémentaire application-h2.properties
dans le répertoire src/main/resources
du projet backend-monster-adoption-store
.
Le fichier application-h2.properties
contiendra les propriétés suivantes :
com.byoskill.adoptions.adapter=h2
# drop and create the database at startup (use `update` to only update the schema)
quarkus.hibernate-orm.active=true
quarkus.hibernate-orm.enabled=true
quarkus.hibernate-orm.database.generation=drop-and-create
quarkus.hibernate-orm.generate-ddl=true
quarkus.datasource.db-kind=h2
quarkus.datasource.jdbc.url=jdbc:h2:file:../src/main/resources/data/database;AUTO_SERVER=true;DB_CLOSE_DELAY=-1
quarkus.hibernate-orm.dialect=org.hibernate.dialect.H2Dialect
quarkus.hibernate-orm.log.sql=true
Il est aussi nécessaire d'ajouter notre feature toggle dans le fichier application.properties pour activer l'implémentation mémoire quand le profile h2 n'est pas souhaité.
# Activer l'implémentation mémoire
com.byoskill.adoptions.adapter=memory
Avec Quarkus, il est possible de modifier au moment du build, les Beans qui doivent être activés en fonction du profil.
Créer la classe suivante dans le package com.byoskill
:
package com.byoskill;
import com.byoskill.adapters.adoption.h2.H2AdoptionRepository;
import com.byoskill.adapters.adoptions.memory.AdoptionMemoryRepository;
import com.byoskill.domain.adoption.repository.AdoptionRepository;
import io.quarkus.arc.DefaultBean;
import io.quarkus.arc.properties.IfBuildProperty;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.context.Dependent;
import jakarta.enterprise.inject.Produces;
import jakarta.persistence.EntityManager;
@Dependent
public class AdoptionConfiguration {
@Produces
@ApplicationScoped
@IfBuildProperty(name = "com.byoskill.adoptions.adapter", stringValue = "h2")
public AdoptionRepository H2AdoptionRepository(final EntityManager entityManager) {
return new H2AdoptionRepository(entityManager);
}
@Produces
@DefaultBean
@ApplicationScoped
public AdoptionRepository memoryAdoptionRepository() {
return new AdoptionMemoryRepository();
}
}
Créer notre implémentation pour H2
Nous allons utiliser les documentations suivantes pour cette partie de l'exercice :
Nous allons commencer par ajouter l'extension n quarkus-jdbc-h2
dans le fichier pom.xml
du projet backend-monster-adoption-store
:
quarkus ext add io.quarkus:quarkus-hibernate-orm
quarkus ext add io.quarkus:quarkus-jdbc-h2
Vérification de la santé de la source de données
Si vous utilisez l'extension quarkus-smallrye-health
, les extensions quarkus-agroal
et reactive
client ajoutent automatiquement une vérification de la disponibilité pour valider la source de données.
Lorsque vous accédez au point de terminaison de disponibilité de santé de votre application, /q/health/ready par défaut, vous recevez des informations sur l'état de validation de la source de données. Si vous avez plusieurs sources de données, toutes les sources de données sont vérifiées, et en cas d'échec de validation d'une seule source de données, le statut passe à DOWN.
Ce comportement peut être désactivé en utilisant la propriété quarkus.datasource.health.enabled.
Pour exclure uniquement une source de données particulière de la vérification de santé, utilisez :
quarkus.datasource."nom-de-la-source-de-données".health-exclude=true
Métriques de la source de données
Si vous utilisez l'extension quarkus-micrometer
ou quarkus-smallrye-metrics
, quarkus-agroal
peut contribuer à certaines métriques liées à la source de données dans le registre des métriques. Cela peut être activé en définissant la propriété quarkus.datasource.metrics.enabled
sur true.
Traçage de la source de données
Pour utiliser le traçage avec une source de données, vous devez ajouter l'extension quarkus-opentelemetry
à votre projet.
Vous n'avez pas besoin de déclarer un pilote différent car vous avez besoin de traçage. Si vous utilisez un pilote JDBC, vous devez suivre les instructions de l'extension OpenTelemetry ici.
Même avec toute l'infrastructure de traçage en place, le traçage de la source de données n'est pas activé par défaut, et vous devez l'activer en définissant cette propriété :
# Activer le traçage
quarkus.datasource.jdbc.telemetry=true
Ajouter la dépendance le POM
<dependency>
<groupId>io.opentelemetry.instrumentation</groupId>
<artifactId>opentelemetry-jdbc</artifactId>
</dependency>
Création du repository pour H2
Nous allons utiliser la documentation suivante.
Ajoutons l'extension Hibernate :
quarkus ext add io.quarkus:quarkus-hibernate-orm
Nous allons créer la classe MonsterEntity
dans le package com.byoskill.adapters.adoption.h2
.
Copier le code suivant dans la classe MonsterEntity
:
package adapters.h2;
import adoption.model.Monster;
import jakarta.persistence.*;
@Entity
@Table(name = "monsters")
public class MonsterEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(name = "name")
private String name;
@Column(name = "description")
private String description;
@Column(name = "image_url")
private String imageUrl;
@Column(name = "monsterUUID")
private String monsterUUID;
@Column(name = "price")
private Integer price;
@Column(name = "age")
private Integer age;
@Column(name = "location")
private String location;
@Column(name = "monsterId")
private Integer monsterId;
// Getters and Setters
public MonsterEntity() {
}
public static MonsterEntity fromModel(final Monster monster) {
final MonsterEntity monsterEntity = new MonsterEntity();
monsterEntity.id = monster.getId();
monsterEntity.name = monster.getName();
monsterEntity.description = monster.getDescription();
monsterEntity.monsterUUID = monster.getMonsterUUID();
monsterEntity.price = monster.getPrice();
monsterEntity.age = monster.getAge();
monsterEntity.location = monster.getLocation();
monsterEntity.monsterId = monster.getMonsterId();
return monsterEntity;
}
public Monster toModel() {
final Monster monster = new Monster();
monster.setId(id);
monster.setName(name);
monster.setDescription(description);
monster.setMonsterUUID(monsterUUID);
monster.setPrice(price);
monster.setAge(age);
monster.setLocation(location);
monster.setMonsterId(monsterId);
return monster;
}
public Integer getId() {
return id;
}
public void setId(final Integer id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(final String name) {
this.name = name;
}
public String getDescription() {
return description;
}
public void setDescription(final String description) {
this.description = description;
}
public String getImageUrl() {
return imageUrl;
}
public void setImageUrl(final String imageUrl) {
this.imageUrl = imageUrl;
}
public String getMonsterUUID() {
return monsterUUID;
}
public void setMonsterUUID(final String monsterUUID) {
this.monsterUUID = monsterUUID;
}
public Integer getPrice() {
return price;
}
public void setPrice(final Integer price) {
this.price = price;
}
public Integer getAge() {
return age;
}
public void setAge(final Integer age) {
this.age = age;
}
public String getLocation() {
return location;
}
public void setLocation(final String location) {
this.location = location;
}
public Integer getMonsterId() {
return monsterId;
}
public void setMonsterId(final Integer monsterId) {
this.monsterId = monsterId;
}
}
Nous devons désormais créer la classe H2AdoptionsRepository dans le package com.byoskill.adapters.adoption.h2
.
Cette classe fournira le remplacement de notre composant d'adoption qui gérait les adoptions en mémoire.
Voici un code dont vous pouvez vous inspirer :
package com.byoskill.adapters.adoption.h2;
import com.byoskill.domain.adoption.model.Monster;
import com.byoskill.domain.adoption.repository.AdoptionRepository;
import io.smallrye.mutiny.Multi;
import io.smallrye.mutiny.Uni;
import jakarta.inject.Inject;
import jakarta.persistence.EntityManager;
import jakarta.persistence.TypedQuery;
import jakarta.transaction.Transactional;
import java.time.Duration;
import java.util.List;
public class H2AdoptionRepository implements AdoptionRepository {
private final EntityManager entityManager;
@Inject
public H2AdoptionRepository(final EntityManager entityManager) {
this.entityManager = entityManager;
}
@Override
public Multi<Monster> getAllMonsters() {
final List<MonsterEntity> monsters = entityManager
.createQuery("SELECT monster from MonsterEntity monster", MonsterEntity.class)
.getResultList();
return Multi.createFrom().items(monsters.stream()
.map(MonsterEntity::toModel)
);
}
@Transactional
@Override
public Uni<Monster> addMonsterToAdopt(final Monster monster) {
final MonsterEntity entity = MonsterEntity.fromModel(monster);
entityManager.persist(entity);
return Uni.createFrom().item(entity.toModel());
}
@Override
public Uni<Monster> getMonsterByUuid(final String uuid) {
final TypedQuery<MonsterEntity> query = entityManager.createQuery("SELECT monster from MonsterEntity monster where monster.monsterUUID = :id", MonsterEntity.class);
query.setParameter("id", uuid);
final List<MonsterEntity> resultList = query.getResultList();
return 1 == resultList.size() ? Uni.createFrom().item(resultList.get(0).toModel()) : Uni.createFrom().nullItem();
}
@Override
public Multi<Monster> searchMonstersByName(final String name) {
final TypedQuery<MonsterEntity> query = entityManager.createQuery("SELECT monster from MonsterEntity monster where monster.name = :name", MonsterEntity.class);
query.setParameter("name", name);
final List<MonsterEntity> resultList = query.getResultList();
return Multi.createFrom().items(resultList.stream()
.map(MonsterEntity::toModel)
);
}
@Transactional
@Override
public void deleteMonsterByUuid(final String uuid) {
entityManager.createQuery("DELETE FROM MonsterEntity monster where monster.monsterUUID = :id");
entityManager.flush();
}
@Transactional
@Override
public Uni<Monster> updateMonsterByUUID(final String uuid, final Monster monster) {
final var query = """
UPDATE MonsterEntity monster set monster.name = :name,
monster.age = :age,
monster.description = :description,
monster.imageUrl = :imageUrl,
monster.location = :location,
monster.price = :price
where monster.monsterUUID = :uuid
""";
entityManager.createQuery(query)
.setParameter("uuid", uuid)
.setParameter("name", monster.getName())
.setParameter("age", monster.getAge())
.setParameter("description", monster.getDescription())
.setParameter("imageUrl", monster.getImage_url())
.setParameter("location", monster.getLocation())
.setParameter("price", monster.getPrice())
.executeUpdate();
return Uni.createFrom().item(monster);
}
@Override
public Multi<Monster> searchMonstersByAge(final Integer age) {
final TypedQuery<MonsterEntity> query = entityManager.createQuery("SELECT monster from MonsterEntity monster where monster.age = :age", MonsterEntity.class);
query.setParameter("age", age);
final List<MonsterEntity> resultList = query.getResultList();
return Multi.createFrom().items(resultList.stream()
.map(MonsterEntity::toModel)
);
}
@Transactional
@Override
public Uni<Monster> changeName(final Monster entityToBeUpdated, final String newName) {
final Uni<Monster> monsterByUuid = getMonsterByUuid(entityToBeUpdated.getMonsterUUID());
return monsterByUuid
.map(monster -> {
monster.setName(newName);
return monster;
})
.log("monster::changeName")
.ifNoItem().after(Duration.ofMillis(500)).fail()
.onItem().invoke(updatedMonster -> {
entityManager.merge(updatedMonster);
});
}
}
Redémarrez le backend en utilisant les profils h2 et dev pour substituer le backend vers la base de données H2.
Correction des erreurs d'exécution
Nous remarquons que l'appel à nos ressources REST plante désormais. En effet l'API Hibernate n'est pas réactive par défaut et bloque le thread principal de l'application.
Pour remédier le problème, nous allons annoter nos ressources REST avec l'annotation @Blocking
.
Vous pouvez utiliser l'annotation @Blocking
pour indiquer que le traitement est bloquant et doit être exécuté sur un thread de travail. Vous pouvez en savoir plus sur le traitement bloquant.
E utilisant cette annotation, nous indiquons que l'exécution de la méthode se réalisera dans un thread dédié dans la limite des threads disponibles sur la machine.
Voici un exemple de code qui corrige le souci avec Hibernate :
@Blocking
@GET
public Uni<MonsterView> getAllMonsters() {
return adoptionRepository.getAllMonsters()
.log("getAllMonsters")
.collect()
.asList()
.map(MonsterView::new);
}
Mise à jour des tests unitaires
Vos tests devraient fonctionner avec l'implémentation H2 ou la version mémoire sans refactoring.
Implémentation d'Hibernate Reactive
En option vous pouvez remplacer Hibernate par Hibernate Reactive.
- Quels sont les avantages d'Hibernate reactive avec Quarkus ?
Implémentation d'Hibernate avec Panache
Panache utilise le pattern ActiveRecord pour rendre la programmation avec Hibernate plus simple.
Vous pouvez trouver plus d'information ici : Panache