Dernière modification : Dec 08 , 2024

Objectifs

Dans cette leçon, nous allons ajouter des endpoints à notre Backend pour utiliser Hibernate search.

Nous utilisons le profile H2 créé dans la leçon précédente pour tester notre application..

Ajout de l'extension Hibernate Search à Quarkus

Si vous avez déjà configuré votre projet Quarkus, vous pouvez ajouter l'extension hibernate-search-orm-elasticsearch à votre projet en exécutant la commande suivante dans le répertoire de base de votre projet :

./mvnw quarkus:add-extension -Dextensions="hibernate-search-orm-elasticsearch"

Nous allons permettre à Hibernate de réaliser des recherches sur les monstres et leurs descriptions.

Pour cela modifier la classe Monster avec les annotations suivantes :


@Entity
@Indexed
@Table(name = "monsters")
public class MonsterEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @FullTextField(analyzer = "english")
    @Column(name = "name")
    private String name;

    @FullTextField(analyzer = "english")
    @Column(name = "description")
    private String description;
}

Tout d'abord, utilisons l'annotation @Indexed pour enregistrer notre entité Book en tant que partie de l'index de texte intégral.

L'annotation @FullTextField déclare un champ dans l'index spécifiquement conçu pour la recherche de texte intégral. En particulier, nous devons définir un analyseur pour diviser et analyser les jetons (~ mots) - nous en parlerons plus en détail plus tard.

Explications

L'analyse est une grande partie de la recherche de texte intégral : elle définit comment le texte sera traité lors de l'indexation ou de la construction de requêtes de recherche.

Le rôle des analyseurs est de diviser le texte en jetons (~ mots) et de les filtrer (en les mettant tous en minuscules et en supprimant les accents par exemple).

Les normaliseurs sont un type spécial d'analyseurs qui conservent l'entrée en tant que jeton unique. Ils sont particulièrement utiles pour le tri ou l'indexation des mots-clés.

Il existe de nombreux analyseurs intégrés, mais vous pouvez également développer les vôtres pour vos besoins spécifiques.

Ajout des analyseurs

Ajouter le code suivant dans votre application :

@IfBuildProfile(anyOf = "h2")
@SearchExtension
public class AnalysisConfigurer implements ElasticsearchAnalysisConfigurer {

    @Override
    public void configure(ElasticsearchAnalysisConfigurationContext context) {
        context.analyzer("name").custom()
                .tokenizer("standard")
                .tokenFilters("asciifolding", "lowercase");

        context.analyzer("english").custom()
                .tokenizer("standard")
                .tokenFilters("asciifolding", "lowercase", "porter_stem");
        
    }
}

Annoter l'implémentation du configurateur avec le qualificatif @SearchExtension pour indiquer à Quarkus qu'il doit être utilisé dans l'unité de persistance par défaut, pour tous les index Elasticsearch (par défaut).

L'annotation peut également cibler une unité de persistance spécifique (@SearchExtension(persistenceUnit = "nomDeVotrePU")), un backend spécifique (@SearchExtension(backend = "nomDeVotreBackend")), un index spécifique (@SearchExtension(index = "nomDeVotreIndex")), ou une combinaison de ceux-ci (@SearchExtension(persistenceUnit = "nomDeVotrePU", backend = "nomDeVotreBackend", index = "nomDeVotreIndex")).

Ceci est un analyseur simple qui sépare les mots sur les espaces, supprime tout caractère non-ASCII en le remplaçant par son équivalent ASCII (et supprime ainsi les accents) et met tout en minuscules. Il est utilisé dans nos exemples pour les noms d'auteur.

Nous sommes un peu plus agressifs avec celui-ci et nous incluons un peu de racinisation : nous pourrons rechercher "mystery" et obtenir un résultat même si l'entrée indexée contient "mysteries". C'est certainement trop agressif pour les noms de personnes, mais c'est parfait pour les titres de livres.

Voici le normaliseur utilisé pour le tri. Très similaire à notre premier analyseur, sauf que nous ne découpons pas les mots en jetons car nous voulons un et un seul jeton.

Nous allons ajouter une méthode pour rechercher dans AdoptionRepository

Voici le code à ajouter :

    Multi<Monster> searchMonstersByName(String name);
    Multi<Monster> searchMonstersByDescription(String name);

Nous devrons ajouter ces memes méthodes dans l'implémentation mémoire ou en tout cas ajouter un stub.

Dans H2AdoptionRepository, nous allons ajouter l'objet SearchSessionpour réaliser nos recherches.

  @Inject
    SearchSession searchSession; 

Puis implémenter les méthodes de recherche selon le modèle suivant :

   return Multi.createFrom().iterable(searchSession.search(MonsterEntity.class)
                .where(f ->
                        null == pattern || pattern.trim().isEmpty() ?
                                f.matchAll() :
                                f.simpleQueryString()
                                        .fields("description").matching(pattern)
                )
                .fetchHits(size.orElse(20))
                .stream()
                .map(MonsterEntity::toModel)
                .collect(Collectors.toList())
        );

Lancer l'indexation pour les données existantes.

Par définition, Hibernate Search n'est pas au courant des données existantes en table.

Nous pouvons ajouter le code suivant au Repository pour indexer les données existantes :

    @Inject
    SearchMapping searchMapping;
    public Long count() {
        return (Long) entityManager.createQuery("SELECT COUNT(t) FROM MonsterEntity t")
        .getSingleResult();
    }
    void onStart(@Observes StartupEvent ev) throws InterruptedException { 
        // only reindex if we imported some content
        if (count() > 0) {
            searchMapping.scope(Object.class) 
                    .massIndexer() 
                    .startAndWait(); 
        }
    }

Il est nécessaire d'ajouter les propriétés suivantes à notre profile H2 :

Dans le fichier application-h2.properties :

# 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
quarkus.ssl.native=false
quarkus.hibernate-search-orm.elasticsearch.version=8 
quarkus.hibernate-search-orm.indexing.plan.synchronization.strategy=sync

Dans le fichier application-prod.properties :

quarkus.datasource.jdbc.url=jdbc:postgresql://localhost/quarkus_test
quarkus.datasource.username=quarkus_test
quarkus.datasource.password=quarkus_test
quarkus.hibernate-orm.database.generation=create
quarkus.hibernate-search-orm.elasticsearch.hosts=localhost:9200

Voici un exemple de ressource REST pour tester la fonctionnalité :

    @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)
        );
    }

Tester la fonctionnalité

Il est possible de créer un endpoint REST pour tester la fonctionnalité ou directement écrire un test d'intégration.

Voici un exemple de test :

    @Test
    void testSearchMonsterByName() {
final Uni<List<Monster>> monstersWithDraculaName = adoptionRepository.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 monsters");
        Assertions.assertTrue(items.stream().anyMatch(hasMonster("Dracula")), "Dracula is present");
        }

Exemple de logs de fonctionnement

20:54:01 INFO  traceId=, parentId=, spanId=, sampled= [or.hi.se.ma.po.ma.im.PojoMassIndexingLoggingMonitor] (Hibernate Search - Mass indexing - MonsterEntity - ID loading - 0) HSEARCH000027: Mass indexing isHibernate: ndex 10 entities.
    select
        me1_0.id 
    from
        monsters me1_0

Hibernate: 
    select
        me1_0.id,
        me1_0.age,
        me1_0.description,
        me1_0.image_url,
        me1_0.location,
        me1_0.monsterId,
        me1_0.monsterUUID,
        me1_0.name,
        me1_0.price 
    from
        monsters me1_0 
    where
        me1_0.id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)


Références

  • https://quarkus.io/guides/hibernate-search-orm-elasticsearch
  • https://quarkus.io/guides/dev-services