Commentaires
Intégration d'Elasticsearch dans vos applications Symfony2
La recherche est un domaine dans lequel les SGBD traditionnels sont particulièrement mauvais :
- pas d'agrégation ;
- lenteur proportionnelle à la taille des données ;
- pertinence complexe à calculer ;
- pas de scalling ;
- index à créer manuellement ;
Et ça tombe bien, des logiciels dédiés à ces tâches existent : Lucene, Solr et le plus hype d'entre tous, Elasticsearch (ES pour les intimes).
Il s'agit d'une application Java dans laquelle vous envoyez des documents JSON, et effectuez des recherches via une API REST avec des temps de réponses à faire rougir Usain Bolt. Je ne vais pas m'étendre plus longtemps sur le produit, si vous manipulez une quantité de données significative (qu'il s'agisse de vos données métier ou de vos logs) Elasticsearch est un must-have.
L'objectif de cet article est de vous initier à l'utilisation de FOSElasticaBundle, à ses subtilités et à son fonctionnement.
Nous utilisons ici la version 3.0 du Bundle, qui n'est pas encore déclarée stable.
Elastica et FOSElasticaBundle
Elastica est un des meilleurs clients PHP pour Elasticsearch à ce jour1. Il est massivement supporté dans l'écosystem Symfony2 avec un nombre important de Bundle dédiés, mais aussi dans des librairies tierces tel que Monolog qui permet d'envoyer les logs directement dans votre cluster.
FOSElasticaBundle est le Bundle le plus complet pour intégrer Elastica, il nous permet :
- d'utiliser facilement JMSSerializerBundle pour transformer vos entités en document JSON (nouveautée de la version 3.0) ;
- de déclarer vos
index
et type
via un fichier Yml (et non en JSON) ;
- d'indexer automatiquement des entités Doctrine ou Propel en
bulk
;
- d'avoir un service de recherche à votre disposition ;
- d'obtenir directement vos entités Doctrine ou Propel en résultat de recherche, plutôt que vos documents JSON ;
- d'utiliser Pagerfanta ou KnpPaginator facilement ;
- d'avoir un récapitulatif des requêtes dans le profiler ;
- d'indexer automatiquement tout changement dans vos entités Doctrine.
Il couvre vraiment une grande partie des besoins et nous allons voir comment implémenter ces features dans votre applicatif existant.
Mettre en place le Bundle
Prenons l'exemple du site web de l'Afsy, qui est une application Symfony2 tout à fait classique.
Il est composé d'entités Article et Author que nous souhaitons indexer.
La première chose à faire est d'installer nos nouvelles dépendances, FOSElasticaBundle et JMSSerializerBundle :
composer require jms/serializer-bundle 0.12.0
composer require friendsofsymfony/elastica-bundle 3.0.*@dev
Faites toujours attention aux numéros de version, référez-vous à packagist !J'utilise une version en cours de développement à l'heure où j'écris ces lignes, les dernières nouveautés du Bundle n'étant pas encore déclarées stable.
On ajoute, comme d'habitude, les Bundles à notre Kernel :
// app/AppKernel.php
public function registerBundles()
{
$bundles = array(
// ...
new \JMS\SerializerBundle\JMSSerializerBundle(),
new \FOS\ElasticaBundle\FOSElasticaBundle(),
);
}
Et enfin, le vif du sujet, la configuration. Notre node ES expose ses API sur le port 9200 et nos deux entités seront des types dans le vocabulaire d'Elasticsearch, eux-même inclus dans un index que nous nommerons afsy.
fos_elastica:
clients:
default: { host: localhost, port: 9200 }
serializer:
callback_class: FOS\ElasticaBundle\Serializer\Callback
serializer: serializer
indexes:
afsy:
client: default
types:
Article:
mappings: ~
persistence:
driver: orm # orm, mongodb, propel are available
model: Afsy\Bundle\CoreBundle\Entity\Article
provider: ~
Author:
mappings: ~
persistence:
driver: orm
model: Afsy\Bundle\CoreBundle\Entity\Author
provider: ~
Déclarer le mapping complet des types n'est pas obligatoire, il est automatique dans ES mais il est souvent nécessaire de l'affiner manuellement, pour donner un boost ou changer un analyzer. Cela ce fait donc ici !
Nous avons aussi indiqué au Bundle à quelle Entité Doctrine se réfère chaque type (Propel et MongoDB sont aussi supportés).
Indexer vos entités
Si tout s'est bien passé vous devriez pouvoir lancer la commande suivante :
$ php app/console fos:elastica:populate
Resetting afsy
Populating afsy/Article, 100.0% (2/2), 12 objects/s
Populating afsy/Author, 100.0% (1/1), 29 objects/s
Refreshing afsy
Elle fait plusieurs choses :
- créer nos index et types (ou les recréer s'ils existent déjà) ;
- faire appel au
provider
de chaque type pour récupérer les entités à indexer ;
- envoyer ses entités au
serializer
pour transformation ;
- et enfin, envoyer en bulk les documents dans le moteur d'indexation.
Vous pouvez constater le bon fonctionnement de la commande en consultant le contenu de votre serveur ES via le plugin Head.
Dans une application classique, cette commande ne devrait pas vous servir, à part en cas de changement de mapping. En effet, elle supprime complètement votre index pour le recréer de zéro : en production cela signifierait que vos requêtes de recherche n'aboutieraient plus pendant toute cette phase d'indexation.
Vous pouvez jouer avec les contenus, les requêtes et le mapping sans rien installer grâce au playground found.no créé pour l'occasion ♥.
Indexation automatique au fil de l'eau
Plutôt que de recréer l'index à chaque modification de contenu, nous allons mettre à jour l'index de façon transparente. Grâce aux LifeCycle Events de Doctrine, il est possible de demander à notre Bundle d'envoyer dans ES l'entité à chaque changement :
persistence:
driver: orm
model: Afsy\Bundle\CoreBundle\Entity\Article
provider: ~
listener: ~ # by default, listens to "insert", "update" and "delete"
Cette nouvelle ligne dans la configuration va donc s'assurer pour nous d'envoyer dans ES nos entités à chaque changement ! C'est très sexy mais laissez moi vous mettre en garde sur l'impact négatif que cela pourrait avoir sur vos performances : à utiliser en connaissance de cause, chaque flush
Doctrine produira alors des requêtes HTTP !
Pour information, une fois envoyé dans ES, votre document est disponible à la recherche environ une seconde plus tard (et immédiatement en cas de _get
) : votre moteur de recherche est donc presque temps réel !
Vos premières recherches
Pour faire des recherches dans l'index, des services sont mis à votre disposition :
- fos_elastica.index.afsy qui représente votre index (
Elastica\Index
) ;
- fos_elastica.index.afsy.article qui représente le type Article (
Elastica\Type
) ;
- fos_elastica.index.afsy.author qui représente le type Author (
Elastica\Type
).
Ainsi, le code suivant recherche "toto" dans le type Articles :
$article_type = $this->get('fos_elastica.index.afsy.article');
$article_type->search("toto");
Alors que celui-ci recherche aussi bien dans les Articles que dans les Authors car il cherche dans l'Index afsy :
$index = $this->get('fos_elastica.index.afsy');
$index->search("toto");
Dans ce second cas, il est intéressant de noter que le ResultSet
comprend aussi bien des documents Article que Author, à vous de faire le tri à l'affichage.
La recherche que nous effectuons ici est la plus simple, il s'agit d'une QueryString construite automatiquement pour nous par Elastica, voyons une requête plus complexe !
$type = $this->get('fos_elastica.index.afsy.article');
$query_part = new \Elastica\Query\Bool();
$query_part->addShould(
new \Elastica\Query\Term(array('title' => array('value' => 'introduction', 'boost' => 3)))
);
$query_part->addShould(
new \Elastica\Query\Term(array('markdown_body' => array('value' => 'introduction')))
);
$filters = new \Elastica\Filter\Bool();
$filters->addMust(
new \Elastica\Filter\Term(array('language' => 'fr'))
);
$filters->addMust(
new \Elastica\Filter\NumericRange('published_at', array(
'lte' => date('c'),
))
);
$query = new \Elastica\Query\Filtered($query_part, $filters);
$type->search($query);
Ça pique un peu n'est-ce pas ! Nous demandons ici à ES les Articles dont le title
ou le markdown_body
contiennent la chaine "introduction", filtrés par date de publication inférieure à maintenant et par langue "fr".
Pour information, en JSON cela donne :
{
"query": {
"filtered": {
"query": {
"bool": {
"should": [{
"term": {
"title": {
"value": "introduction",
"boost": 3
}
}
}, {
"term": {
"markdown_body": {
"value": "introduction"
}
}
}]
}
},
"filter": {
"bool": {
"must": [{
"term": {
"language": "fr"
}
}, {
"numeric_range": {
"published_at": {
"lte": "2013-11-30T23:22:23+01:00"
}
}
}]
}
}
}
}
}
Vous l'aurez compris, composer une requête Elastica est aussi verbeux mais bien plus sûr que d'écrire son équivalent JSON. Il est recommandé de les regrouper dans des services dédiées afin d'éviter de polluer vos controleurs.
Le Finder Doctrine
FOSElasticaBundle permet, comme je le disais en introduction, de vous retourner vos entités plutôt que des objets Elastica\Result
. Il utilise pour cela des Finder.
persistence:
driver: orm # orm, mongodb, propel are available
model: Afsy\Bundle\CoreBundle\Entity\Article
provider: ~
listener: ~ # by default, listens to "insert", "update" and "delete"
finder: ~
La nouvelle ligne finder: ~
dans notre configuration permet alors d'utiliser le service fos_elastica.manager.orm
et de lancer des recherches sur des entités plutôt que sur des types ES :
/** var FOS\ElasticaBundle\Manager\RepositoryManager */
$repositoryManager = $container->get('fos_elastica.manager.orm');
/** var FOS\ElasticaBundle\Repository */
$repository = $repositoryManager->getRepository('AfsyCoreBundle:Article');
/** Array of Article entities */
$articles = $repository->find('toto');
Il est aussi possible d'utiliser findHybrid()
pour récupérer à la fois le Elastica\Result
(contenant les informations du résultat tel que le score du document, son JSON source...) et les entités.
La méthode find()
accepte aussi une Query
Elastica évidement, et il est aussi possible de créer votre propre repository :
persistence:
driver: orm # orm, mongodb, propel are available
model: Afsy\Bundle\CoreBundle\Entity\Article
provider: ~
listener: ~ # by default, listens to "insert", "update" and "delete"
finder: ~
repository: Afsy\Bundle\CoreBundle\SearchRepository\ArticleRepository
<?php
namespace Afsy\Bundle\CoreBundle\SearchRepository;
use FOS\ElasticaBundle\Repository;
class ArticleRepository extends Repository
{
/**
* @param $searchText
* @return array<Article>
*/
public function findPublished($searchText)
{
$query_part = new \Elastica\Query\Bool();
$query_part->addShould(
new \Elastica\Query\Term(array('title' => array('value' => $searchText, 'boost' => 3)))
);
$query_part->addShould(
new \Elastica\Query\Term(array('markdown_body' => array('value' => $searchText)))
);
$filters = new \Elastica\Filter\Bool();
$filters->addMust(
new \Elastica\Filter\Term(array('language' => 'fr'))
);
$filters->addMust(
new \Elastica\Filter\NumericRange('published_at', array(
'lte' => date('c'),
))
);
$query = new \Elastica\Query\Filtered($query_part, $filters);
// return $this->findHybrid($query); if you also want the ES ResultSet
return $this->find($query);
}
}
Bien plus propre pour stocker vos requêtes Elastica !
Mettre en place une facette
Une des forces d'Elasticsearch est la possibilité de faire de l'aggregation sur les résultats et d'en sortir des statistiques. Il nous serait par exemple possible de récupérer la répartition des articles par année avec une facette Date Histogram. Voici comment l'implémenter avec Elastica :
$query = \Elastica\Query::create(); // Nous recherchons tout (MatchAll)
$date_facet = new DateHistogram('years'); // Nous donnons un nom à la facette
$date_facet->setField('published_at');
$date_facet->setInterval('year');
$query->addFacet($date_facet);
Malheureusement, nous ne pouvons pas utiliser directement le finder
vu précédement. En effet, il ne sait retourner qu'un tableau de résultats (entités ou hybrid) et ne se préoccupe pas de tout ce qu'une réponse Elasticsearch peut contenir :
- des facettes ;
- des suggestions ;
- des mlt (More lile this)...
Nous passons donc à nouveau par notre service Elastica\Type
pour lancer la recherche :
$articles = $this->get('fos_elastica.index.afsy.article')->search($query);
$facets = $articles->getFacets();
$years = $facets['years']['entries'];
// Retourne :
// array
// array 'time' => int 1325376000000 'count' => int 1
// array 'time' => int 1356998400000 'count' => int 2
Nous pouvons alors manipuler les résultats comme avec une installation standard d'Elastica. La récupération des entités Doctrine se fait ensuite via TransformedFinder
2 :
$entities = $this->get('fos_elastica.finder.afsy.article')->transform($articles);
Définir un mapping plus fin
Pour vous épargner quelques chapitres, j'omets, dans les exemples ci-dessus, toute notion de mapping, et je fais mes recherches avec les analyses par défaut. Cela fonctionne car les paramètres par défaut d'Elasticsearch sont plutôt bons... Mais il ne font que de la tokenisation standard. Si nous recherchons "poney" alors que le mot est écrit au pluriel "poneys" dans tous les documents, la recherche ne retournera rien du tout.
Idem pour des mots composés comme "l'avent", si nous cherchons "avent", rien ne sera retourné.
Pour voir comment ES tokenise une chaîne, nous pouvons utiliser l'API analyse:
curl 'http://localhost:9200/afsy/_analyze?pretty=true' -d "l'avent"
{
"tokens" : [ {
"token" : "l'avent",
"start_offset" : 0,
"end_offset" : 7,
"type" : "<ALPHANUM>",
"position" : 1
} ]
}
Nous n'avons qu'un seul token, et il ne correspond pas au token "avent".
C'est là qu'entre en jeu l'analyse. Nous devons spécifier à ES comment couper nos phrases, comment transformer nos mots en tokens, quels mots utiliser pour remplacer d'autres, que filtrer... Voici donc ici un exemple complet de configuration pour la langue française, et appliqué via notre fichier de configuration - c'est cadeau ♥ - mais bien sûr à adapter à vos besoins !
indexes:
afsy:
client: default
settings:
index:
analysis:
analyzer:
custom_french_analyzer:
type: custom
tokenizer: letter
filter: ["asciifolding", "lowercase", "french_stem", "stop_fr"]
filter:
stop_fr:
type: "stop"
stopwords: ["l", "m", "t", "qu", "n", "s", "j", "d"]
Nous faisons plusieurs choses avec cette configuration3 :
- remplacer les lettres accentuées par leurs équivalent ASCII (
é
-> e
) ;
- appliquer un lowercase (
E
-> e
) ;
- tronquer la fin des mots pour enlever toute forme d'accords (
bundles
-> bundl
, envoutée
-> envoute
) ;
- séparer les mots composés et supprimer l'article (
l'afsy
-> l
,afsy
-> afsy
).
Nous pouvons refaire notre test et constater que "bundles" devient "bundl" une fois passé dans notre analyzer custom_french_analyzer
:
curl 'http://localhost:9200/afsy/_analyze?pretty=true&analyzer=custom_french_analyzer' -d "bundles"
{
"tokens" : [ {
"token" : "bundl",
"start_offset" : 0,
"end_offset" : 7,
"type" : "word",
"position" : 1
} ]
}
Vous pouvez ensuite appliquer cette analyse aux champs que vous voulez via le mapping, lorsque vous chercherez sur ces champs, ES appliquera automatiquement la même analyse à votre recherche pour ce champ là :
types:
Article:
mappings:
title: { analyzer: custom_french_analyzer }
body: { analyzer: custom_french_analyzer }
Ces trois recherches nous retournent bien notre document contenant le mot "bundles" :
$search1 = $this->get('fos_elastica.index.afsy.article')->search(
new Field('body', 'bundles')
);
$search2 = $this->get('fos_elastica.index.afsy.article')->search(
new Field('body', 'bundle')
);
$search3 = $this->get('fos_elastica.index.afsy.article')->search(
new Field('body', 'des BUNDLES')
);
Aller encore plus loin
Nous pourrions écrire un livre sur l'utilisation d'Elasticsearch, mais finissons plutôt avec ces quelques recommandations !
Ne pas faire d'indexation synchrone
Je vous parlais des soucis de performance liés à l'utilisation du listener
Doctrine, la bonne solution serait d'implémenter votre propre listener et d'envoyer les demandes d'indexation à un broker (type RabbitMQ). À votre charge alors de développer les workers pour traiter ces messages, mais les avantages sont multiples :
- accélerer l'indexation est aussi simple que d'ajouter des workers ;
- réindexer toute votre base se fait simplement en produisant des messages pour le broker ;
- vous ne ralentissez plus votre application en demandant des indexations au fil de l'eau ;
- d'autres consommateurs de votre base de données peuvent eux aussi produire des messages pour demander une indexation...
Débugger directement dans Elasticsearch
Autre astuce, plus orientée debug, sachez que vous pouvez transformer une Elastica\Request
en appel cURL
facilement executable (cURL
et Sense sont vos deux seuls amis en cas de coup dur sur la construction d'une requête) :
\Elastica\Util::convertRequestToCurlCommand($type->getIndex()->getClient()->getLastRequest())
// curl -XGET 'http://localhost:9200/afsy/Article/_search' -d '{"query":{"query_string":{"query":"toto"}}}'
Depuis peu, le web profiler vous propose aussi la version cURL de chaque requête.
N'ayez pas peur d'utiliser ES directement sans passer par votre client, vous irez beaucoup plus vite pour débugger ! Cette classe Util
contient quelques méthodes d'échappage de requête Lucene ou encore de transformation de date. À connaitre donc.
Brancher votre SonataAdminBundle sur vos Index
Si vous utilisez SonataAdminBundle, il est desormais possible de brancher tous vos filtres sur ES via SonataElasticaBundle. Sur de grande quantités de données le gain de performance sera sans précédent !
Quelques liens utile pour débuter
Il n'existe pas beaucoup de ressources en français sur Elasticsearch, voici quelques recommandations de lecture en anglais :
J'espère que cet article vous aura mis le pied à l'étrier et que plus jamais vous ne ferez appel à un LIKE pour faire de la recherche dans vos applications Symfony2 ! N'hésitez pas non plus à poser vos questions dans les commentaires, j'y répondrais avec plaisir.