Implémenter un moteur de recherche avec elasticsearch et Symfony (partie 1/3)

Publié le 22/09/2019 • Actualisé le 12/01/2020

Dans cet article nous allons voir comment créer un moteur de recherche "full-text" avec elasticsearch dans une application Symfony. Nous allons utiliser Docker compose pour mettre en place les composants elasticsearch. Nous essaierons de garder la configuration aussi succincte que possible en gardant au maximum les paramètres par défaut. A la fin, sur ce site, nous pourrons rechercher des articles et snippets correspondant à un ou plusieurs mots-clés. C'est parti ! 😎


English language detected! 🇬🇧

  We noticed that your browser is using English. Do you want to read this post in this language?

Read the english version 🇬🇧 Close

» Publié dans "Une semaine Symfonique" (du 23 au 29 septembre 2019).

Avertissement

Cet article est obsolète. J'ai supprimé tout le code concernant Elasticsearch sur ce blog. Veuillez regarder mon nouvel article sur Meilisearch (à venir).

Tutoriel

Cet article est la première partie du tutoriel: "Implémenter un moteur de recherche avec Elasticsearch et Symfony"

Prérequis

Je vais supposer que vous avez au moins une connaissance basique de Symfony4. Que vous savez comment initialiser une application et que vous savez comment gérer un schéma de base de données avec un ORM (nous utiliserons ici Doctrine). Comme un fichier "Docker compose" sera utilisé, je supposerai aussi que vous êtes familier avec, sinon, merci de lire le guide de mise en route.

Configuration

  • PHP 8.3
  • Symfony 6.4

Mise en place de l'environnement de développement avec Docker compose

Tout d'abord, nous devons préparer notre environnement de développement afin de pouvoir travailler (nous amuser ? 😄) dans de bonnes conditions. Voyons comment installer la plupart des composants que nous allons utiliser avec Docker compose. Cet environnement comprendra :

  • elasticsearch 6.8
  • elastic head 5
  • MySQL 5.7
  • Adminer (last stable)

Elasticsearch head va nous permettre de rapidement pouvoir contrôler l'état de notre cluster Elasticsearch local et adminer est une interface basique d'administration de bases de données (comme PhpMyAdmin).

Jetons un coup d'œil au fichier docker-compose.yaml :

# ./docker-compose.yaml

# DEV docker compose file ——————————————————————————————————————————————————————
# Check out: https://docs.docker.com/compose/gettingstarted/
version: '3.7'

# docker-compose -f docker-compose.yaml up -d
services:

  # Database ———————————————————————————————————————————————————————————————————

  # MySQL server database (official image)
  # https://docs.docker.com/samples/library/mysql/
  db:
    image: mysql:5.7
    container_name: sb-db
    command: --default-authentication-plugin=mysql_native_password
    ports:
      - "3309:3306"
    environment:
      MYSQL_ROOT_PASSWORD: root

  # adminer database interface (official image)
  # https://hub.docker.com/_/adminer
  adminer:
    container_name: sb-adminer
    depends_on:
      - db
    image: adminer
    ports:
      - "8089:8080"

  # elasticsearch ——————————————————————————————————————————————————————————————

  # elasticsearch server (official image)
  # https://www.elastic.co/guide/en/elasticsearch/reference/current/docker.html
  elasticsearch:
    container_name: sb-elasticsearch
    image: docker.elastic.co/elasticsearch/elasticsearch:6.8.3 # 6.8.4 out
    ports:
      - "9209:9200"
    environment:
        - "discovery.type=single-node"
        - "bootstrap.memory_lock=true"
        - "ES_JAVA_OPTS=-Xms1G -Xmx1G"
        - "xpack.security.enabled=false"
        - "http.cors.enabled=true"
        - "http.cors.allow-origin=*"

  # elasticsearch head manager (fork of mobz/elasticsearch-head for elasticsearch 6)
  # /!\ it isn't an official image /!\
  # https://hub.docker.com/r/tobias74/elasticsearch-head
  elasticsearch-head:
    container_name: sb-elasticsearch-head
    depends_on:
      - elasticsearch
    image: tobias74/elasticsearch-head:6
    ports:
      - "9109:9100"

Nous avons deux sections distinctes. La première contient les composants relatifs à Elasticsearch, la seconde étant relative à la base de données. Pour lancer le hub Docker, lancez la commande suivante :

docker-compose -f docker-compose.yaml up -d

Le hub Docker a bien été correctement lancé

Maintenant, on peut accéder aux composants exposés en HTTP du hub Docker :

Plusieurs remarques : pour accéder à la base de données avec adminer, on doit spécifier un serveur, pour notre hub, c'est la clé container_name que nous avons paramétré dans le fichier docker-compose.yml. Dans ce cas c'est sb-db, l'utilisateur est "root", de même pour le mot de passe. Ne pas utiliser en production ! ⛔

Le formulaire de login de l'interface d'administration adminer

Pour Elasticsearch head, dans la barre du haut, vous devez spécifier l'URL du cluster, à savoir : http://localhost:9209. En validant vous devriez voir apparaître un nœud vide.

Pour ce projet, j'utilise le binaire Symfony. Je démarre le serveur HTTP local avec la commande suivante : (le binaire Symfony doit être installé).

symfony serve --daemon

Alors, on peut accéder au projet localement à l'URL https://127.0.0.1:8000. Sur mon MacBookPro et MacMini, j'ai installé PHP avec Homebrew, sur ma station de travail Ubuntu 18.04, PHP 7.2 était la version installée par défaut (les trois configurations fonctionnent sans le moindre problème). Nous ne verrons pas ici comment un installer un environnement PHP complet avec Docker. Si vous voulez essayer, merci de jeter un coup d'œil aux articles de Pierstoval à ce sujet. 😉 Maintenant que notre environnement de développement est prêt, voyons comment créer un index de données Elasticsearch.

Installer et configurer le bundle FOSElastica

Tout d'abord nous allons installer le bundle FOSElastica (Vous pourriez évidemment utiliser directement elastica ou une autre interface). Veuillez noter que nous n'utiliserons pas la dernière version d'Elasticsearch (7.3) car le bundle ne semble pas encore gérer cette version. Notez aussi que changer la version d'Elasticsearch que l'on utilise est aussi simple que de changer 6.8.18 par 7.15 dans le fichier docker compose ! C'est l'immense avantage d'utiliser Docker. 💪

composer require friendsofsymfony/elastica-bundle

Ouvrez le fichier config/packages/fos_elastica.yaml et changez le port pour 9209 :

# Read the documentation: https://github.com/FriendsOfSymfony/FOSElasticaBundle/blob/master/Resources/doc/setup.md
# config/packages/fos_elastica.yaml
fos_elastica:
    clients:
        default: { host: localhost, port: 9209 }
    indexes:
        app: null

Maintenant, on peut lancer la création de l'index pour vérifier que notre paramétrage est correct :

php bin/console fos:elastica:create

Si vous ouvrez l'interface Elasticsearch head, vous devriez constater qu'un index vide "app" a été créé :

Le plugin Elasticsearch utilisé en tant que service Docker compose.

Maintenant, voyons comment ajouter des données dans l'index. Nous ne verrons pas ici tout le processus de création d'un modèle, des entités et tables correspondantes. Sur ce blog, j'ai une table article qui contient tous les articles et snippets. Le schéma a été créé avec le générateur de schéma d'API Platform. Notre prochain objectif va être d'ajouter tous les articles à l'index Elasticsearch.

Indexation des données dans Elasticsearch

Dans la suite de cet article, je prendrai mon schéma de base comme référence. Remplacez donc App\Entity\Article par le nom de votre entité. Même chose au sujet des propriétés de l'entité. Tout d'abord, ajoutons quelques champs dans le mapping Elasticsearch :

# config/packages/fos_elastica.yaml
fos_elastica:
    clients:
        default: { host: localhost, port: 9209 }
    indexes:
        app:
            types:
                articles:
                    properties:
                        type: ~
                        name: ~
                        slug: ~
                        keyword: ~
                    persistence:
                        driver: orm
                        model: App\Entity\Article

Nous avons plusieurs champs de type texte et le type des articles. (article ou snippet) Pour maintenant, gardons le paramétrage par défaut et lançons la commande d'indexation qui va nous permettre de rafraîchir les données de l'index :

php bin/console fos:elastica:populate
Resetting app
 42/42 [============================] 100%
Populating app/articles
Refreshing app

Si vous voyez ceci, c'est que la commande s'est déroulée avec succès. Nous pouvons vérifier que les documents ont bien été indexés. Ouvrez l'interface "Elasticsearch head", cliquez sur l'onglet "naviguer" puis cliquez sur un document pour voir le JSON brut qui lui est attaché. On peut voir l'id de l'entité (14) et les différents champs que nous avons déclaré précédemment (type, name, slug et keyword).

Source brute d'un document Elasticsearch que nous venons d'indexer.

Maintenant que nous avons un index avec quelques données, essayons d'y faire une recherche.

Rechercher et afficher des résultats

Par souci de clarté, nous allons créer un contrôleur basique dédié à la recherche. Premièrement, nous devons affecter une variable (🇬🇧 bind) au service de recherche lié au type articles. Ce service est généré automatiquement par le bundle FOSElastica en fonction des types déclarés dans la configuration. Ajoutez cette ligne au fichier config/services.yaml.

# config/services.yaml
services:
    _defaults:
        bind:
            $articlesFinder: '@fos_elastica.finder.app.articles'

Grâce à l'autoloading, nous pouvons désormais injecter ce service dans notre nouveau contrôleur :

<?php

declare(strict_types=1);

// src/Controller/SearchPart1Controller.php

namespace App\Controller;

use FOS\ElasticaBundle\Finder\TransformedFinder;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\Routing\Annotation\Route;

use function Symfony\Component\String\u;

/**
 * You know, for search.
 */
#[Route(path: '/{_locale}', name: 'search_part1_', requirements: ['_locale' => '%locales_requirements%'])]
final class SearchPart1Controller extends AbstractController
{
    public function __construct(
        private readonly TransformedFinder $articlesFinder,
    ) {
    }

    #[Route(path: ['en' => '/part1/search', 'fr' => '/partie1/recherche'], name: 'main')]
    public function search(Request $request, SessionInterface $session): Response
    {
        $q = u($request->query->get('q', ''))->trim();
        $results = !$q->isEmpty() ? $this->articlesFinder->findHybrid($q->toString()) : [];
        $session->set('q', $q);

        return $this->render('search/search_part1.html.twig', compact('results', 'q'));
    }
}

L'action de ce contrôleur va être très succincte. Nous récupérons le mot clé à partir d'un paramètre GET (q pour "🇬🇧 query") de la requête HTTP. Ensuite nous appelons la fonction findHybrid pour chercher les articles correspondant, puis nous sauvons le mot-clé en session. Pour chaque résultat, la fonction findHybrid va retourner deux objets : Le premier, le "hit" va contenir les métas informations de la réponse brute d'Elasticsearch. C'est dans cet objet que nous allons récupérer le score du document. Quand on fournit un mot-clé, tous les résultats sont triés par score, du plus au moins pertinent. Le second objet est l'entité Doctrine liée au résultat de recherche. Ainsi, nous n'avons pas à traiter directement la réponse brute Elasticsearch. Maintenant nous pouvons afficher les résultats :

{% extends 'layout.html.twig' %}

{# templates/search/search_part1.html.twig // This is the template of the 1st part of the tutorial #}

{% trans_default_domain 'search' %}

{% set esArticle = article_es() %} {# Don't do this! This is to avoid polluting the SearchController #}

{% block content %}
    <div class="col-md-12">
        <div class="card">
            <div class="card-header card-header-primary">
                <p class="h3">{{ 'your_search_for'|trans}} <b>"{{ q }}"</b>, <b>{{ results|length }}</b> {{ 'results'|trans}}.</p>
            </div>
            <div class="card-body">
                <p class="h3">&raquo; {{ 'get_back'|trans}} "<a href="{{ path('blog_show', {'slug': esArticle[1].slug|a_slug(locale), 'q': q}) }}#search_form">{{ ('title_'~esArticle[1].id)|trans({}, 'blog') }}</a>"</p>
            </div>
        </div>
    </div>
    {% for result in results %}
        {% set hit = result.result.hit %}
        {% set article = result.transformed %}
        {% if article.isArticle %}
            {% set tag_route = 'blog_list_tag' %}
            {% set pathEn = path('blog_show', {'_locale': 'en','slug': article.slug|a_slug('en')}) %}
            {% set pathFr = path('blog_show', {'_locale': 'fr','slug': article.slug|a_slug('fr')}) %}
            {% set title = ('title_'~article.id)|trans({}, 'blog') %}
        {% else %}
            {% set tag_route = 'snippet_list_tag' %}
            {% set pathEn = path('snippet_show', {'_locale': 'en', 'slug': article.slug|s_slug('en') }) %}
            {% set pathFr = path('snippet_show', {'_locale': 'fr', 'slug': article.slug|s_slug('fr') }) %}
            {% set title = ('title_'~article.id)|trans({}, 'snippet') %}
        {% endif %}
        <div class="card">
            <div class="card-header">
                <h2 class="h3">
                    [{{ ('type_'~article.type.value)|trans({}, 'blog') }}] {{ title }} &raquo; {{ 'score'|trans }} <b>{{ hit._score }}</b>
                </h2>
            </div>

            <div class="card-body">
                <div class="blog-tags">
                    {% for tag in article.keywords %}<a class="badge badge-{{ random_class() }}" href="{{ path(tag_route, {'tag': tag}) }}"><i class="far fa-tag"></i> &nbsp;{{ tag|trans({}, 'breadcrumbs') }}</a> {% endfor %}
                </div>
                <br/>
                <p class="card-text text-center">
                    <a href="{{ pathEn }}" class="btn btn-primary card-link">🇬🇧 {{ 'read_in_english'|trans({}, 'blog') }}</a>
                    <a href="{{ pathFr }}" class="btn btn-primary card-link">🇫🇷 {{ 'read_in_french'|trans({}, 'blog') }}</a>
                </p>
            </div>
        </div>
    {% endfor %}
    <div class="col-md-12">
        {% if results is empty %}
            <p class="h3">{{ 'no_results'|trans }}</p>
        {% endif %}
    </div>

    <div class="col-md-12">
        {% include 'search/_form.html.twig' with {route: 'search_part1_main'} %}
    </div>
{% endblock %}

Regardons le template. Ne soyez pas effrayé ! Il y a du code spécifique à ce blog, je ne m'attarderai pas dessus (c'est le template réel utilisé par le blog). Les deux lignes les plus importantes sont au tout début de la boucle for :

{% set hit = result.result.hit %}
{% set article = result.transformed %}

Comme indiqué auparavant, tout d'abord nous récupérons l'objet "hit" par lequel nous pouvons récupérer le score avec hit._score. (il est affiché sur la liste de résultats à droite du titre de l'article ou du snippet). Ensuite, nous récupérons l'entité Doctrine Article avec result.transformed. Maintenant, nous pouvons accéder aux getters comme nous avons l'habitude de le faire avec Twig. Par exemple, article.isArticle va retourner vrai si l'article est un article de blog et faux si c'est un snippet. (il y a uniquement deux types d'article). Et voilà ! Vous pouvez tester la recherche avec le formulaire suivant :

Quand on lance une recherche, une nouvelle entrée est automatiquement ajoutée au panneau de debug pour qu'on puisse facilement débugger la requête Elasticsearch brute qui a été exécutée.

img4_alt

Veuillez noter qu'on affiche au maximum dix résultats (pas de pagination). Notre moteur de recherche fonctionne même s'il est très rudimentaire pour l'instant. Un problème très gênant, vous l'aurez sans doute constaté, est que les articles et snippets ne sont pas encore complétement indexés puisque les textes sont stockés dans des fichiers de traduction YAML. Donc, le prochain objectif sera d'inclure ces textes dans l'index afin de rendre la pertinence bien meilleure. Nous pouvons aussi ajouter plusieurs autres choses intéressantes, la pagination, les boosts, les alias... Cet article étant déjà assez conséquent, gardons toutes ces améliorations potentielles pour le prochain. 😌

Et voilà ! J'espère que vous avez aimé. Découvrez d'autres informations en rapport à cet article avec les liens ci-dessous. Comme toujours, retours, likes et retweets sont les bienvenus. (voir la boîte ci-dessous) À tantôt ! COil. 😊

  Lire la 2ème partie

  Lire la doc  Plus sur le web

Ils m'ont donné leurs retours et m'ont aidé à corriger des erreurs et typos dans cet article, un grand merci à : dkarlovi, jmsche. 😊

  Travaillez avec moi !


A vous de jouer !

Ces articles vous ont été utiles ? Vous pouvez m'aider à votre tour de plusieurs manières : (cf le tweet à droite pour me contacter )

  • Me remonter des erreurs ou typos.
  • Me remonter des choses qui pourraient être améliorées.
  • Aimez et retweetez !
  • Suivez moi sur Twitter
  • Inscrivez-vous au flux RSS.
  • Cliquez sur les boutons Plus sur Stackoverflow pour me faire gagner des badges "annonceur" 🏅.

Merci d'avoir tenu jusque ici et à très bientôt sur Strangebuzz ! 😉

COil