Gérer un index Elasticsearch avec la librairie elasticsearch php

J'ai déjà parlé sur ce blog de la librairie elasticsearch php maintenue par Elastic, la société qui édite Elasticsearch. C'est mon choix par défaut quand il s'agit d'intégrer Elasticsearch dans un projet. Contrairement à des bundles comme Fos/ElasticaBundle, il n'y a pas de gestion automatique de cycle de vie d'un index dans la librairie. Il faut tout faire soit même. Basé sur mon expérience de mise en place de cette librairie dans Symfony sur de nombreux projets, je vous propose de faire le tour des commandes que j'utilise.

Gestion des noms d'index

Un index Elasticsearch évolue avec le temps, pas seulement en terme de document indexé mais aussi de structure. Il est conseillé de ne pas activer l'automapping qui déclare à volée les nouveaux champs dans l'index. Mais d'avoir un mapping stricte quitte à ne pas prendre en compte les champs non déclaré. De ce fait, l'ajout d'un champs nécessite de revoir le mapping et dans ce cas il est préférable de créer un nouvel index.

De ce fait, pour ne pas changer le code pour chaque nouvel index, il faut que le code utilise un nom (alias) qui permet de référencer l'index qui nous intéresse. Une bonne pratique est d'horodater le nom de l'index.
En prenant comme exemple ce blog, j'ai comme nom d'alias moncode et comme nom d'index moncode_date_time.
J'ai créé un classe qui me permet de gérer le nom de mes index.

<?php
declare(strict_types=1);
 
namespace Metfan\LibSearch\Index;
 
use \DateTimeImmutable;
use \DateTimeZone;
 
class IndexNameGenerator
{
    public function __construct(private string $indexPattern)
    {
    }
 
    public function generateName(): string
    {
        $now = new DateTimeImmutable('now', new DateTimeZone('UTC'));
 
        $replacement = [
            '<DATE>' => $now->format('Ymd'),
            '<TIME>' => $now->format('His'),
        ];
 
        return str_replace(
            array_keys($replacement), 
            array_values($replacement), 
            $this->indexPattern
        );
    }
 
    public function generateWildcardPattern(): string
    {
        $replacement = [
            '<DATE>' => '*',
            '<TIME>' => '*',
        ];
 
        return str_replace(
            array_keys($replacement), 
            array_values($replacement), 
            $this->indexPattern
        );
    }
}

La méthode generateWildcardPattern() me permettra de chercher tous les index au moment du switch d'alias ou de la suppression d'un index.

Connection à Elasticsearch

La plupart du temps ma connexion à Elasticsearch est simple avec peu d'options. J'ai écrit une classe PHP en utilisant le design pattern builder basé sur une interface qui me permet de gérer le cas simple et me laisse l'opportunité d'écrire une autre implémentation si besoin.

<?php
declare(strict_types=1);
 
namespace Metfan\LibSearch\Client;
 
use Elastic\Elasticsearch\Client;
use Elastic\Elasticsearch\ClientBuilder as EsClientBuilder;
 
class ClientBuilder implements ClientBuilderInterface
{
    private ?Client $client = null;
 
    public function __construct(private string $host, private string $port)
    {
    }
 
    public function build(): Client
    {
        if (null ==! $this->client) {
            return $this->client;
        }
 
        $this->client = EsClientBuilder::create()
            ->setHosts([$this->host.':'.$this->port])
            ->build();
 
        return $this->client;
    }
}

Création d'un index

Créer un index est relativement simple. Ce n'est rien d'autre qu'une requête POST sur l'API avec le nom de l'index que l'on souhaite créer et le mapping qui lui est associé. Pour cela j'utilise toujours la même méthode, j'écris mon mapping en JSON dans un fichier twig, je trouve cela plus lisible. C'est ce que je fais dans cette article ou j'écris le corps des requêtes Elasticsearch dans des fichiers twig.

J'ai une première classe PHP qui me permet de construire le corps de la requête à partir d'un fichier twig:

<?php
declare(strict_types=1);
 
namespace Metfan\LibSearch\Request;
 
use Twig\Environment;
 
class RequestForgery
{
    public function __construct(
        private Environment $templating,
        private string $aliasName,
        private string $templateName
    ) {
    }
 
    /**
     * @param array<string, mixed>  $tplParams
     *
     * @return array{index: string, body: string}
     */
    public function forgeRequest(
        array $tplParams = [], 
        ?string $indexName = null
    ): array {
        return [
            'index' => $indexName?? $this->aliasName,
            'body' => $this->templating->render($this->templateName, $tplParams)
        ];
    }
}

La création d'index dans Elasticsearch passe par l'API et la librairie elasticsearch php met à disposition une classe Indices pour la gestion des index.

<?php
declare(strict_types=1);
 
namespace Metfan\LibSearch\Index;
 
use Metfan\LibSearch\Client\ClientBuilderInterface;
use Metfan\LibSearch\Request\RequestForgery;
 
class IndexCreator
{
    public function __construct(
        private ClientBuilderInterface $builder,
        private RequestForgery $forgery,
        private IndexNameGenerator $indexNameGenerator
    ) {
    }
 
    public function createIndex(): string
    {
        $client = $this->builder->build();
 
        $indexName = $this->indexNameGenerator->generateName();
        $request = $this->forgery->forgeRequest([], $indexName);
        $client->indices()->create($request);
 
        return $indexName;
    }
}
 

Switch de l'alias

Dans Elasticsearch il n'existe pas d'API spécifique pour cette tache. Un alias peut être mis sur un ou plusieurs index, pour cela il faut mettre à jour la configuration des index. N'ayant pas d'index journalier (cas avec des logs), je souhaite que l'alias ne soit présent que sur un seul index à la fois.
J'ai donc besoin de récupérer la liste de tous les index matchant le pattern de nom de mes index en version wildcard. Ensuite il ne reste plus qu'a écrire une requête qui ajoute l'alias sur l'index qui m’intéresse et qui supprime l'alias sur les autres index.

<?php
declare(strict_types=1);
 
namespace Metfan\LibSearch\Index;
 
use Metfan\LibSearch\Client\ClientBuilderInterface;
use Elastic\Elasticsearch\Response\Elasticsearch;
use Webmozart\Assert\Assert;
 
class IndexSwitcher
{
    public function __construct(
        private ClientBuilderInterface $clientBuilder,
        private IndexNameGenerator $indexNameGenerator,
        private string $aliasName
    ) {
    }
 
    public function switchIndex(?string $indexName = null): string
    {
        $indices = $this->clientBuilder->build()->indices();
 
        // get all indices from indice pattern
        /** @var Elasticsearch $indicesAliasResponse */
        $indicesAliasResponse = $indices->getAlias(
            ['index' => $this->indexNameGenerator->generateWildcardPattern()]
        );
 
        Assert::isInstanceOf($indicesAliasResponse, Elasticsearch::class);
        $indicesAlias = $indicesAliasResponse->asArray();
 
        if (null === $indexName) {
            ksort($indicesAlias);
            $indexName = (string) key(array_reverse($indicesAlias, true));
        }
 
        $aliasName = $this->aliasName;
        //extract all indices with alias
        $currentIndicesAlias = array_keys(
            array_filter(
                $indicesAlias,
                function (array $aliases) use ($aliasName) {
                    return is_array($aliases['aliases']) 
                        && isset($aliases['aliases'][$aliasName]);
                }
            )
        );
 
        $request = [
            'body' => [
                'actions' => [
                    [
                        'add' => [
                            'index' => $indexName, 
                            'alias' => $aliasName,
                        ]
                    ],
                ]
            ]
        ];
        foreach ($currentIndicesAlias as $current) {
            $request['body']['actions'][] = [
                'remove' => [
                    'index' => $current, 
                    'alias' => $aliasName,
                ]
            ];
        }
 
        $indices->updateAliases($request);
 
        return $indexName;
    }
}

D'accord à voir comme ça, ce n'est pas très digeste, cela est dû à la réponse d'Elasticsearch qui est un tableau multi dimensionnel.

Suppression d'un index

La suppression d'un index est simple, la librairie elasticsearch php propose une méthode delete() sur la classe Indices.

<?php
declare(strict_types=1);
 
namespace Metfan\LibSearch\Index;
 
 
use Metfan\LibSearch\Client\ClientBuilderInterface;
use Metfan\LibSearch\Exception\IndexDeletionFailedException;
use Metfan\LibSearch\Exception\IndexDeletionUnauthorizedException;
use Metfan\LibSearch\Exception\IndexNotFoundException;
use Elastic\Elasticsearch\Response\Elasticsearch;
use Webmozart\Assert\Assert;
 
class IndexRemover
{
    /** @var array<string, array{name: string, with_alias: bool}>  */
    private array $indicesList = array();
 
    public function __construct(private ClientBuilderInterface $clientBuilder)
    {
    }
 
    /**
     * @return array<string, array{name: string, with_alias: bool}>
     */
    public function getIndicesList(string $indexPattern): array
    {
        $indices = $this->clientBuilder->build()->indices();
        $list = $indices->get(['index' => $indexPattern]);
 
        Assert::isInstanceOf($list, Elasticsearch::class);
 
        foreach ($list->asArray() as $indexName => $config) {
            if (isset($config['aliases']) and !empty($config['aliases'])) {
                $this->indicesList[$indexName] = [
                    'name' => $indexName, 
                    'with_alias' => true,
                ];
                continue;
            }
 
            $this->indicesList[$indexName] = [
                'name' => $indexName, 
                'with_alias' => false,
            ];
        }
 
        return $this->indicesList;
    }
 
    public function remove(string $indexName): void
    {
        if (empty($this->indicesList)) {
            $this->getIndicesList($indexName);
        }
 
        if (!isset($this->indicesList[$indexName])) {
            throw new IndexNotFoundException($indexName);
        }
 
        if (true === ($this->indicesList[$indexName]['with_alias'])) {
            throw new IndexDeletionUnauthorizedException($indexName);
        }
 
        $indices = $this->clientBuilder->build()->indices();
        $response = $indices->delete(['index' => $indexName]);
 
        Assert::isInstanceOf($response, Elasticsearch::class);
 
        if (200 !== $response->getStatusCode()) {
            throw new IndexDeletionFailedException($indexName);
        }
    }
}
 
 

Il faut faire attention à ne pas supprimer l'index qui est référencé par l'alias au risque de rendre indisponible la recherche sur le site.

Voici donc tout le code nécessaire pour gérer le cycle de vie des alias dans Elasticsearch avec la librairie elasticsearch php. J'ai mis à disposition tout le code ainsi que des commandes Symfony pour l'utiliser sur github.
Dans l'arthicle suivant je parle d'indexer des documents dans Elasticsearch.

Ajouter un commentaire