Intégration d'Hibernate Search a une application Seam 2.1

Mikael Robert

De nombreuses applications Web nécessitent un moteur de recherche pour faciliter la recherche d'informations à vos utilisateurs.
Cependant, ce type d'outil peut vite être complexe à développer si l'on souhaite le rendre vraiment Full Text.
Qu'entend-on par Full Text ?
Il s'agit en fait d'une technique de recherche dans un document ou une base de données, nécessitant d'examiner tous les mots du texte.
Néanmoins en raison des ambiguïtés du langage naturel une recherche Full Text aboutit souvent à des résultats peu pertinents.
Heureusement pour nous des solutions existent. Entre autres le bien connu Lucene. Apache Lucene.
Lucene est un moteur de recherche libre développé par Apache écrit en java qui permet d'indexer et de recherche dans le texte.
Les index vont alors être gérés sous forme de fichiers très optimisés qui vont grandement accélérer vos recherches.
Enfin Lucene gère nativement la syntaxe de recherche type Google, vous n'aurez pas donc pas à parser ce qui est entré par vos utilisateurs.
Hibernate Search est donc en quelque sorte la couche qui va vous permettre d'intégrer Lucene à votre application Seam, dont le contenu dynamique est géré par Hibernate (vers votre base de données donc).
Je vous conseille de télécharger l'outil Luke qui va vous permettre de visualiser les index que nous allons créer plus tard dans ce tutoriel.
Ici nous allons donc voir comment intégrer Hibernate Search à votre application Seam, et comment l'utiliser pour développer un moteur de recherche simple.

- Titre : Intégration d'Hibernate Search a une application Seam 2.1

- Auteur : Mikael Robert

- Parution : 4 avril 2009

- Mise à jour : 17 février 2020

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2009 Mikael Robert. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Prérequis

- II. Configuration de votre application

- III. Dépendances

- IV. Déclaration d'entités pour l'indexation

- V. Indexation

- VI. Recherche

- VII. Conclusion

I - Prérequis

Pour réaliser ce tutoriel, vous devez déjà bien connaitre :

- Seam ;

- Hibernate ;

- JPA.

II - Configuration de votre application

Comme vous vous en doutez, il ne suffit pas d'ajouter un jar dans un coin de votre application pour faire fonctionner ce système.
Il faut donc commencer par configurer Hibernate.
Vous devez donc ajouter quelques éléments à votre persistence.xml.
Ajouter les lignes suivantes à la persistence-unit déjà configurée de votre application, en prenant soin de modifier le répertoire où seront stockés les index.

<!-- use a file system based index -->
<property name="hibernate.search.default.directory_provider"   value="org.hibernate.search.store.FSDirectoryProvider"/>
<!-- directory where the indexes will be stored -->
<property name="hibernate.search.default.indexBase"  value="/Users/leakim/Documents/Projects/JBoss/hibernateSearchIndex"/>
<property name="hibernate.ejb.event.post-insert" value="org.hibernate.search.event.FullTextIndexEventListener"/>
<property name="hibernate.ejb.event.post-update" value="org.hibernate.search.event.FullTextIndexEventListener"/>
<property name="hibernate.ejb.event.post-delete" value="org.hibernate.search.event.FullTextIndexEventListener"/>

III - Dépendances

Nous allons maintenant ajouter les dépendances nécessaires, attention je vous précise ici la version d'hibernate pour une raison simple : il faut que les versions d'Hibernate et Hibernate Search soient compatibles entres elles ou vous aurez des ennuis.
Voyons donc les dépendances avec Maven (je vous renvoie à mon article sur l'utilisation de Maven avec Seam, voir références en bas de cet article).
Dans votre pom.xml principal :

<!-- Hibernate search -->
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-commons-annotations</artifactId>
<version>3.0.0.ga</version>
</dependency>
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-search</artifactId>
<version>3.0.1.GA</version>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-core</artifactId>
<version>2.3.0</version>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-analyzers</artifactId>
<version>2.3.0</version>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-highlighter</artifactId>
<version>2.3.0</version>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-snowball</artifactId>
<version>2.3.0</version>
</dependency>
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-entitymanager</artifactId>
<version>3.3.2.GA</version>
<exclusions>
<exclusion>
<groupId>javassist</groupId>
<artifactId>javassist</artifactId>
</exclusion>
</exclusions>
</dependency>

Dans le projet qui va implémenter le moteur de recherche :

<!-- Hibernate Search dependencies -->
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-commons-annotations</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-search</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-core</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-analyzers</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-snowball</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-entitymanager</artifactId>
<scope>provided</scope>
</dependency>

Ici vous avez des dépendances supplémentaires, je les ai précisées pour ceux d'entre vous qui connaitraient déjà et souhaitent utiliser Lucene SnowBall ou les Analyzer personnalisés.
Nous ne rentrerons pas ici en détail sur ces éléments, je vous renvoie à la doc de Lucene pour cela.

IV - Déclaration d'entités pour l'indexation

Maintenant qu'Hibernate Search est installé, il va falloir indexer nos entités.
Vous allez voir avec l'exemple que c'est très simple grâce aux annotations disponibles.

/**
* Entity which represent a user text entry.
* @author leakim
*
*/
@Entity
@Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE)
@Indexed
@Name("textEntry")
public class TextEntry implements Serializable {

/**
* uid.
*/
private static final long serialVersionUID = 792694656532097709L;

/**
* Unique Id.
*/
@Id @GeneratedValue
@DocumentId
private Long id;

/**
* Text Entry title.
*/
@NotNull @Length(max = 70)
@Field(index = Index.TOKENIZED)
private String title;

/**
* Text Content.
*/
@NotNull
@Field(index = Index.TOKENIZED)
@Basic(fetch = FetchType.LAZY)
@Length(max = 10000)
private String content;

/**
*
*/
@NotNull
private Date date = new Date();

/**
* The category.
*/

@ManyToOne
@IndexedEmbedded
private Category category;

/**
* The status.
*/
private TextEntryStatus status;

/** Code retiré par souci de lisibilité*/

/**
* Entity intended to represent a category.
* @author leakim
*
*/
@Entity
@Table(name = "Category", uniqueConstraints = {
@UniqueConstraint(columnNames = "name") })
@Indexed
public class Category implements Serializable {

/**
* ID.
*/
@Id @GeneratedValue
@DocumentId
private Long id;

/**
* The name of the category.
*/
@Length(max = 25)
@Field(index = Index.TOKENIZED)
private String name;
/** Code retiré par souci de lisibilité*/

Explications

- @Indexed indique à Hibernate que l'entité est indexée.

- @DocumentId indique à Hibernate l'ID utilisé comme Document ID, autant Indexed que DocumentId sont strictement nécessaires si vous voulez indexer, sans cela ça ne marchera pas.

- @Field(index = Index.TOKENIZED) indique à Hibernate Search qu'il s'agit d'un champ indexé de type Tokenized, ce qui signifie en quelque sorte un champ multiélément. (Pensez au StringTokenizer …)

- @IndexedEmbedded ici l'annotation porte bien son nom, il s'agit d'embarquer l'index de l'entité annotée.

- Si vous souhaitez faire une recherche dans une liste ou un set, il faudra utiliser @ContainedIn.

V - Indexation

Voilà vos entités sont prêtes à être indexées, pour cela on peut créer une petite classe utilitaire.
Ici je le fais à chaque démarrage de l'application, car celle-ci est en cours de développement, mais ce pas forcément nécessaire.
Par exemple :

/**
* Index Blog entry at startup.
*
* @author Mikael Robert
*/
@Name("indexerService")
@Scope(ScopeType.APPLICATION)
@Startup
public class IndexerService {

/**
* The logger.
*/
@Logger
private Log logger;

/**
* Hibernate Search Entity Manager.
*/
@In
private FullTextEntityManager entityManager;

/**
* Creation method called on application startup.
*/
@SuppressWarnings("unchecked")
@Create
public void indexTextEntry() {
List blogEntries = entityManager.createQuery("select be from TextEntry be").getResultList();
entityManager.purgeAll(TextEntry.class);
logger.debug("Indexing textEntries ....");
for (Object be : blogEntries) {
entityManager.index(be);
}

entityManager.purgeAll(Category.class);
List categoryList = entityManager.createQuery("select category From Category category").getResultList();
logger.debug("Indexing Categories....");
for (Object category : categoryList) {
entityManager.index(category);
}

}
}

Explications
Vous voyez c'est relativement simple : on sélectionne tous les éléments de la table correspondant à l'entité à indexer, et on demande à Hibernate de les indexer.
Le purgeAll utilisé en début de chaque indexation indique de supprimer les index existants.

VI - Recherche

Et maintenant l'élément attendu, la partie moteur de recherche

/**
* @author leakim
*
*/
@Name("searchService")
@Scope(ScopeType.PAGE)
public class SearchService {

/**
* logger.
*/
@Logger
private Log logger;

/**
* Hibernate fullTextEntityManager.
*/
@In
private FullTextEntityManager entityManager;

/**
* The search pattern.
*/
private String searchPattern;

/**
* The searchs results.
*/
private List<TextEntry> searchResults;

/**
* Construct the search results.
*
*/
public void  search() {
if (searchResults != null) {
logger.debug("Old result list size : #0", searchResults.size());
}
if (searchPattern == null || "".equals(searchPattern)) {
searchPattern = null;
searchResults =  null;
} else {
Map<String, Float> boostPerField = new HashMap<String, Float>();
boostPerField.put("title", 3f);
boostPerField.put("category.name", 2f);
boostPerField.put("content", 1f);
String[] productFields =  {"title", "content", "category.name"};

QueryParser parser = new MultiFieldQueryParser(productFields, new StandardAnalyzer(), boostPerField);
parser.setAllowLeadingWildcard(true);
org.apache.lucene.search.Query luceneQuery = null;
try {
luceneQuery = parser.parse("*" + searchPattern + "*");
} catch (ParseException e) {
searchResults =   null;
}

searchResults =   entityManager.createFullTextQuery(luceneQuery, TextEntry.class)
.setMaxResults(100).getResultList();
logger.debug("Search count #0", searchResults.size());
}
}
}

Explications

- Le champ de texte de recherche de la page web est mappé au champ searchPattern.

- La partie correspondant à la construction du map boostPerField permet, lors d'une recherche multichamp, d'indiquer un « booster » à Hibernate Search. Cela va agir sur la « pertinence » du champ. On attribue une « note » indiquant l'importance du champ dans la recherche.

- Les productFields sont donc les champs dans lesquels Hibernate Search devra chercher.

- Ici vous voyez donc que quand la luceneQuery parse le champ on ajoute « * » de chaque côté du searchPattern, syntaxe bien connue :)

- StandardAnalyzer : je ne vais pas entrer dans les détails sur les Analyzer dans ce tutoriel, mais Lucene utilise des objets Analyzer pour analyser votre texte, cela vous permet avec les snowBall de grandement affiner la pertinence de vos résultats.

- setAllowLeadingWildcard permet l'utilisation de * et ?.

La logique est donc :

- vérifier le pattern entré par l'utilisateur ;

- instancier la recherche multichamp et les booster ;

- parser le champ ;

- lancer la recherche avec le fullTextEntityManager. Attention ! pas l'entityManager classique ;

- récupérer la liste en retour de la fonction et l'afficher.

VII - Conclusion

Et voilà vous avez votre moteur de recherche. Vous pourrez rapidement le constater, celui-ci est déjà très pertinent.
Mais le gros apport est surtout au niveau de la performance, les index Lucene garantissent une rapidité de parcours et de recherche assez ahurissante.
De plus vos requêtes mettront systématiquement le même temps, quel que soit le nombre de lignes dans la ou les tables parcourues.