Dans cet article, je me propose de faire une introduction à ElasticSearch
et à son intégration avec Ruby On Rails. Puisque l’intégration est plutôt facile
à réaliser, je vais également introduire l’utilisation de l’extension postgresql
hstore
.
Pour cela, je vais prendre en exemple une petite application de recherche de produits. L’idée est d’avoir un nombre arbitraire de propriétés sur ces produits et de pouvoir effectuer des recherches efficacement sur celles-ci.
Avant de commencer, assurez vous d’avoir une machine pourvue d’ElasticSearch,
d’une base de données postgresql, d’une version de Ruby récente ainsi que de la
gem bundler
.
Vous pouvez ensuite rapatrier le code de cet article depuis Github. Si vous voulez tester l’application, vous pouvez également le faire directement via la démo en ligne.
Si vous installez l’application chez vous, pensez à activer l’extension hstore
sur la base que vous utiliserez. ActiveRecord 4 inclut le support pour ce type
de champs de manière native.
Pour illustrer l’utilisation d’un hstore, on va créer un modèle Product
.
Ce modèle n’a qu’un title
et un champ properties
. Notre objectif est d’utiliser
properties
pour étendre au besoin les caractéristiques de nos produits à la
manière d’une base sans schéma.
bundle exec rails g scaffold Product title:string properties:hstore
Ensuite, il n’y a plus qu’à réaliser la migration.
La gem tire
était la référence en terme d’intégration avec Rails. Toutefois
en septembre 2013, elle cesse d’être maintenue au profit d’une autre
implémentation : elasticsearch-rails
via elasticsearch-ruby
et
elasticsearch-model
. Ce sont donc ces gems que nous utilisons pour cet article.
La principale différence avec tire
est l’absence du DSL de requêtage. De mon
point de vue, ce dernier n’était pas nécessaire dans la mesure ou ElasticSearch
utilise déjà un DSL, même si ce dernier est en JSON.
Pour indiquer à Rails qu’il faut indexer les produits et que l’on souhaite
disposer des fonctions de recherche depuis la classe Product
on aura :
class Product < ActiveRecord::Model
include Elasticsearch::Model
include Elasticsearch::Model::Callbacks
end
Ces quelques lignes vont permettre d’écrire ce type de recherches :
Product.search({
filter: {
terms: { 'properties.color' => ['green', 'blue'] }
}
})
Cette recherche retourne une instance de Elasticsearch::Response::Response
depuis laquelle on peut accéder au résultat de la requête à ElasticSearch, aux
Product
s associés à ces résultats, etc.
L’application de démo dispose de seeds pour Product
. Vous pouvez donc créer
1000 produits à l’aide de la commande suivante :
bundle exec rake db:seed
Pour chaque Product
créé, une requête d’ajout est envoyée à ElasticSearch. Ces
requêtes vont placer dans l’index products
un document de type product
avec comme id l’id
du produit créé. Les termes index, document, type et
id sont relatif à ElasticSearch, voir le glossaire pour plus d’informations.
Le contenu du document envoyé par Rails à ElasticSearch est le résultat de la
méthode Product#as_indexed_json
. Vous pouvez bien entendu redéfinir cette méthode
qui par défaut est :
def as_indexed_json(options={})
self.as_json(options.merge root: false)
end
Dans notre cas, properties
est retourné sous forme de Hash
et est donc indexé
par ElasticSearch.
Note : Si vous avez créé des Products
avant l’intégration avec
ElasticSearch alors il vous faudra réindexer votre modèle à l’aide de la
commande suivante :
bundle exec rake environment elasticsearch:import:model CLASS='Product' FORCE=y
Chaque produit dispose dans properties
de clés et de valeurs associées. Pour
lister ces clés, j’utilise la requête suivante :
SELECT DISTINCT k FROM (SELECT skeys(properties) AS k FROM products) AS dt
Cette dernière retourne actuellement les éléments suivants :
+----------+
| k |
+----------+
| color |
| size |
| gender |
| category |
+----------+
Une fois ces clés obtenues, on peut s’en servir pour filtrer les produits.
Pour mettre en place la recherche, je construis une requête en JavaScript qui se
définit comme un tableau associatif où les clés sont les clés des properties
des
produits et les valeurs des tableaux des valeurs acceptées ; voici un exemple :
{
"size": ["12", "12.5", "13"],
"gender": ["male"],
"category": ["sport"]
}
Cette requête est traitée pour retourner une liste de produits via le code suivant :
es_params = {
filter: {
and: query.map{ |key, values| { terms: { "properties.#{key}" => values } } }
}
}
Product.search(es_params).records.all
On remarque que les paramètres de Product.search
sont exactement le JSON qu’attend
ElasticSearch. L’intégration avec Rails est donc légère et ne risque pas de se mettre
en travers de requêtes avancées.
Si vous avez utilisé la démo de l’application, vous avez pu remarquer que de
l’autocomplétion était réalisée sur les clés des properties
. On utilise la recherche
facetée d’ElasticSearch (documentation) pour récupérer la liste des dix
valeurs les plus communes pour une propriété donnée.
field = "properties.color"
es_params = {
"size" => 0,
"query" => {
"prefix" => {
field => "bl"
}
},
"facets" => {
"color" => {
"terms" => {
"field" => field
}
}
}
}
response = Product.search(es_params)
response.response["facets"]["color"]["terms"].map{ |f| f['term']}
Le code ci-dessus va faire une requête, limitant le nombre de résultats retournés
à zéro. La recherche prendra en compte tous les documents contenant un champ
properties.color
commençant par bl
. La partie facets
est exploitée
directement depuis la réponse d’ElasticSearch.
Note : C’est Visualsearch qui est utilisé pour réaliser le composant de recherche.
Suite à l’utilisation d’un champ de type hstore
, on doit traiter de manière différente
les formulaires d’ajout et d’édition d’un produit. En effet, il faut permettre l’ajout, la
suppression et la modification de clés et de valeurs. Pour celà, j’utilise un peu de code
JavaScript qui ne vaut pas la peine d’être détaillé. En plus de cela, les vues ont été
légèrement modifiées pour prendre en compte la nature dynamique de notre hstore
.
Les actions search
et autocomplete
retournent respectivement une vue partielle et une
liste de chaînes de caractères au format JSON. La vue partielle est celle des éléments de
la liste. La liste des chaînes de caractères correspond aux valeurs autocomplétées des
propriétés.
Le reste du code est principalement autogénéré par le scaffold initial.
En l’état, l’application à certaines limites. La gestion des majuscules par exemple ;
si vous recherchez un produit dont la propriété color
vaut Red
vous n’aurez aucun
resultat. C’est lié à mes requêtes à ElasticSearch.
Un autre point problématique est la mise à jour du hstore properties
via l’action
update
du controlleur. En effet, je n’ai pas réussi, avec strong_parameters
, à
autoriser n’importe quelle clé pour le hash properties
. Je m’en sors avec le code
suivant :
def product_params
properties = params.require(:product).fetch(:properties, nil).try(:permit!)
params.require(:product).permit(:title).merge(properties: properties)
end
C’est terminé pour cet article. Je vous invite, si ce n’est pas encore fait, à jouer avec notre démo et à consulter le code associé.
Nous n’avons fait que mettre en place les fondations qui permettrons d’exploiter les nombreuses fonctionnalités d’ElasticSearch dans un projet Ruby On Rails. Toutefois, j’espère vous avoir montré à quel point il est facile d’intégrer cet outil à vos projet.
L’équipe Synbioz.
Libres d’être ensemble.