Moteur de recherche avec Symfony – Apiplatform et Typesense

Objectif

Mettre en place un moteur de recherche sur un site internet est une chose très simple. De plus, les bases de données (BDD) possèdent déjà des fonctions qui permettent de faire cela telles que « WHERE field = :value », « WHERE field LIKE :value« , ou une recherche avancée en FULLTEXT telle que :

WHERE MATCH(field) AGAINST(:value IN NATURAL LANGUAGE MODE);

Cependant, en terme de performance (surtout si notre BDD contient des millions d’enregistrements), utiliser ces fonctions mobilisent largement la base de données qui a pour objectif initial de stocker et de restituer les données.

Pour rémédier à cela, il faut passer par une solution externe qui indexera les données venant de cette BDD et fera la recherche dans ces données indéxées.

Des solutions payantes et gratuites ou Opensource existent telles que :

  • Typesense: opensource et léger
  • Elasticsearch : opensource et très avancé
  • Meilisearch: opensource et léger
  • Algolia: payant.

Typesense se veut être l’Algolia en open source. Il est léger et contient juste le nécessaire pour mettre en place un tel moteur de recherche. Il permet aussi de surligner les mots clés dans les résultats de la recherche.

Dans cet article, je vous présente une astuce pour créer un moteur de recherche simple et performant avec typesense et Symfony (en utilisant apiplatform).

Installation de typesense

On est sur un OS Ubuntu 20.04 LTS. On ouvre un terminal (CTRL+ALT+T).

sudo wget https://dl.typesense.org/releases/0.17.0/typesense-server-0.17.0-amd64.deb
sudo apt install ./typesense-server-0.17.0-amd64.deb

Pour tester la bonne installation de typesense

sudo systemctl status typesense-server

qui devrait donner comme retour

● typesense-server.service - Typesense Server
     Loaded: loaded (/etc/systemd/system/typesense-server.service; enabled; vendor preset: enabled)
     Active: active (running) since Fri 2020-12-25 16:34:52 CET; 2h 55min ago
       Docs: https://typesense.org
   Main PID: 1064 (typesense-serve)
      Tasks: 85 (limit: 9382)
     Memory: 45.1M
     CGroup: /system.slice/typesense-server.service
             └─1064 /usr/bin/typesense-server --config=/etc/typesense/typesense-server.ini 

On peut aussi aller sur http://localhost:8108/health pour tester, on devrait avoir comme retour

{"ok":true}

Créer un projet Symfony 5.0

On se met dans un dossier et on installe Symfony.

cd /home/miary/symfonytypesense
composer create-project symfony/website-skeleton project

On ouvre ensuite l’url suivante http://localhost dans notre navigateur – On devrait avoir notre beau projet Symfony.

Ajout des packages Symfony utiles

En ligne de commande

API Platform

Permet de consommer automatiquement une API de n’importe quelle entité juste avec des annotations 😉

composer req api

Typesense bundle

Bundle permet de se connecter à typesense, de faire une recherche indéxée et de lier les résultats de cette recherche avec Doctrine. C’est ensuite Doctrine qui nous fournira les enregistrements retournés.

composer require acseo/typesense-bundle

On renseigne les informations sur la BDD

On suppose qu’on utilise ici MySQL (mariadb) avec les informations suivantes :

db_host: localhost
db_user: root
db_password: secret
db_name: symfony_db

A la racine du dossier de Symfony, on copie le fichier .env en .env.local

On modifie le fichier .env.local pour renseigner quelques informations dont la BDD

DATABASE_URL=mysql://root:secret@localhost:3306/symfony_db?serverVersion=5.7

Et d’autres informations utiles à Typesense

###> TYPESENSE
TYPESENSE_URL=localhost:8108
TYPESENSE_KEY=0abcDefgHijkLmnopQrstuVwxyz123456789
###< TYPESENSE

La clé TYPESENSE_KEY est obligatoire. On peut mettre ce qu’on veut.

Création d’une entité

On crée une entité « Post » qui possède les propriétés suivantes :

name: string (not null)
post: text (null)
createdAt: datetime (null)

… grâce au doctrine makerbundle (installé par défaut avec Symfony)

php bin/console make:entity

Et on suit les instructions :


 Class name of the entity to create or update (e.g. BraveChef):
 > Post

 Mark this class as an API Platform resource (expose a CRUD API for it) (yes/no) [no]:
 > yes

 created: src/Entity/Post.php
 created: src/Repository/PostRepository.php
 
 Entity generated! Now let's add some fields!
 You can always add more fields later manually or by re-running this command.

 New property name (press <return> to stop adding fields):
 > name

 Field type (enter ? to see all types) [string]:
 > 

 Field length [255]:
 > 

 Can this field be null in the database (nullable) (yes/no) [no]:
 > 

 updated: src/Entity/Post.php

 Add another property? Enter the property name (or press <return> to stop adding fields):
 > post

 Field type (enter ? to see all types) [string]:
 > text

 Can this field be null in the database (nullable) (yes/no) [no]:
 > yes

 updated: src/Entity/Post.php

 Add another property? Enter the property name (or press <return> to stop adding fields):
 > createdAt

 Field type (enter ? to see all types) [datetime]:
 > 

 Can this field be null in the database (nullable) (yes/no) [no]:
 > yes

 updated: src/Entity/Post.php

 Add another property? Enter the property name (or press <return> to stop adding fields):
 > 


           
  Success! 
           

 Next: When you're ready, create a migration with php bin/console make:migration

On ajoute quelques données manuellement dans la table « post » de la BDD via phpmyadmin, adminer ou ce que vous avez … pour tester.

Configuration du bundle

On crée un fichier config/packages/acseo_typesense.yaml avec le contenu suivant

acseo_typesense:
  # Typesense host settings
  typesense:
    host: '%env(resolve:TYPESENSE_URL)%'
    key: '%env(resolve:TYPESENSE_KEY)%'
  # Collection settings
  collections:
    post: # Typesense collection name
      entity: 'App\Entity\Post'              # Doctrine Entity class
      fields:
        id: # Entity attribute name
          name: id                # Typesense attribute name
          type: primary                  # Attribute type
        name:
          name: name
          type: string
        post:
          name: post
          type: string
        createdAt:
          name: created_at
          type: datetime
      default_sorting_field: createdAt
 

Dans config/services.yaml, on ajoute

services:

    ACSEO\TypesenseBundle\Finder\CollectionFinder: '@typesense.finder.post'

Apiplatform possède des filtres installés par défaut. Il permet également de créer un filtre personnalisé.

C’est ce qu’on va faire justement puisqu’on ira chercher les données dans typesense.

Pour cela, on a besoin d’une classe qui étend la classe « ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractContextAwareFilter ». On crée cette classe dans le fichier src/Filter/TypeSenseFilter.php .

<?php

namespace App\Filter;

use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractContextAwareFilter;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
use ACSEO\TypesenseBundle\Finder\TypesenseQuery;

class TypeSenseFilter extends AbstractContextAwareFilter
{

    /**
     * @var CollectionFinder
     */
    private $collectionFinder;

    public function __construct(ManagerRegistry $managerRegistry, ?RequestStack $requestStack = null, LoggerInterface $logger = null, array $properties = null, NameConverterInterface $nameConverter = null, CollectionFinder $collectionFinder)
    {
        parent::__construct($managerRegistry, $requestStack, $logger, $properties, $nameConverter);
        $this->collectionFinder = $collectionFinder;
    }

    protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null)
    {
        // TypeSense search and get results   
        $query = new TypesenseQuery($value, $property);
        $results = $this->collectionFinder->rawQuery($query)->getRawResults();
        
        // get all ids from typesense
        $ids = [];
        foreach ($results as $result) {
            $ids[] = intval($result['document']['id']) ?? 0;
        }

        $queryBuilder
            ->andWhere('o.id IN (:ids)')
            ->setParameter(':ids', $ids);
    }

    public function getDescription(string $resourceClass): array
    {
        if (!$this->properties) {
            return [];
        }

        $description = [];
        foreach ($this->properties as $property => $strategy) {
            $description["typesense_$property"] = [
                'property' => $property,
                'type' => 'string',
                'required' => false,
                'swagger' => [
                    'description' => 'Filter using a typesense. This will appear in the Swagger documentation!',
                    'name' => 'Custom name to use in the Swagger documentation',
                    'type' => 'Will appear below the name in the Swagger documentation',
                ],
            ];
        }

        return $description;
    }
}

On modifie notre entité src/Entity/Post.php pour ajouter le filtre qu’on vient de créer toujours de façon très simple grâce aux annotations fournies par apiplatform.

<?php

namespace App\Entity;

use App\Filter\TypeSenseFilter;
 
/* 
 * @ApiResource(
 * //
 * )
 * @ApiFilter(
 *     TypeSenseFilter::class,
 *     properties={
 *          "name"
 *     }
 * )
class Post
{

Création d’un front Controller

Il est maintenant temps de tester ce qu’on a mis en place.

On crée un controller src/Controller/TypesenseController.php sans template grâce à makerbundle

php bin/console make:controller --no-template

On crée ensuite une méthode dans la classe de ce controller

<?php

namespace App\Controller;

use ACSEO\TypesenseBundle\Finder\TypesenseQuery;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

/**
 * @Route("/typesense")
 */
class TypesenseController extends AbstractController
{
    private $finder;
    public function __construct($finder)
    {
        $this->finder = $finder;
    }

    /**
     * @Route("/search", name="typesense.testsearch")
     */
    public function testsearch(): Response
    {
        // Search term "premier article" inside post collection where field is "name"
        $query = new TypesenseQuery('premier article', 'name');
        $results = $this->finder->rawQuery($query)->getResults();

        $ids = [];
        foreach ($results as $result) {
            $ids[] = intval($result['document']['id']) ?? 0;
        }

        return $this->json($ids);
    }
}

Pour tester ce controller, on va sur http://localhost/typesense/search

Indexation des données dans Typesense

Ce n’est pas tout. On peut se poser la question de savoir comment pourrait-on indéxer les millions d’enregistrement dans notre BDD vers typesense.

Et bien, le bundle acseo/typebundle contient 2 commandes qu’on peut lancer dans la console Symfony, qui permettent de faire 2 choses :

  • créer une collection : php bin/console typesense:create
  • Indéxer les données venant de la BDD mysql : php bin/console typesense:populate

Une collection s’apparente à une base de données au sens de typesense.

Webographie: