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
- Api platform
https://api-platform.com/ - acseo/Typesense
https://github.com/acseo/TypesenseBundle
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:
- Acseo – Utiliser Typesense dans un projet Symfony : https://www.acseo.fr/symfony-acseo-typesense-bundle
- Apiplatform – Creating Custom Filters : https://api-platform.com/docs/core/filters/#creating-custom-filters
- Grafikart – Découverte de Typesense : https://www.grafikart.fr/tutoriels/typesense-recherche-1366
- Typesense: https://typesense.org/