État de l'art

d'Elasticsearch avec PHP

Elasticsearch

🎨

@damienalexandre

@damienalexandre JoliCode

  • 🐘 Lead développeur PHP ;
  • 🔎 Consultant et Formateur Elasticsearch ;
  • #emoji #guinness #velotaf #végé #metal

Révisons les bases

Elasticsearch

  • Un outil, pas une solution ;
  • Base de données NoSQL / orientée Document ;
  • Index inversé distribué ;
  • Pas de transaction, pas de relation.

Utilisations

  • 🔎 Moteur de recherche full-text ;
  • 📚 Base NoSQL ;
  • 📃 Stockage et analyse de logs ;
  • 📊 Statistiques / BI ;
  • 🤯 Machine Learning, monitoring applicatif, outil de présentation, dashboard...

Ça ne sert pas à

  • 🔥 Conserver vos stocks, vos sessions...
  • 💽 Stocker des données critiques ;
  • 📃Effectuer des transactions ;
  • 👕 Étendre le linge
  • 📦 Aller chercher un colis à La Poste
    Pardon, c'est ma TODO LIST.

Cluster, Node et Shard

Elasticsearch glossary

Avec PHP

Elasticsearch with PHP Elasticsearch glossary

Besoin d'appels HTTP

  • Contrairement à MongoDB ou PostgreSQL, pas d'extension nécessaire :
    • HTTP ? \file_get_contents()
    • JSON ? \json_decode()
  • Tout est natif !

✔️ Pour conclure ✔️


                $results = \json_decode(
                    file_get_contents('http://localhost:9200/_search'),
                    true
                );

Merci ! Des questions 🙏🏽 ? Il nous reste 30 minutes.

Avec PHP

Les paquets existants

  • elasticsearch/elasticsearch = Officiel ;
  • ruflin/elastica ;
  • madewithlove/elasticsearcher (5.x) ;
  • ongr/elasticsearch-dsl ;
  • doctrine/search Surprise !
  • Beaucoup de code non maintenu, expérimental,...

Le client officiel

Expose toutes les API et gère le réseau avec RingPHP.

$params = [
    'index' => 'app',
    'body'  => [
        'query' => [
            'match' => [ 'framework' => 'symfony' ]
        ]
    ]
];

$response = $client->search($params);
            

Elastica

  • Créé et maintenu par Nicolas Ruflin ❤️
  • Abstraction objet totale ;
  • "Basé" sur l'officiel ;
  • Documentation très pauvre.

Elastica

Vos requêtes avant 👎🏽

$params = [
  'query' => [
    'bool' => [
      'must' => [
        'match' => [
          'category' =>
            'Beurre'
        ]
      ]
    ]
  ]
];
                    
Après 👍🏽

$bool = new BoolQuery();
$bool->addMust(
  new Match(
    'category',
    'Beurre'
  )
);

Pas encore bien intégré

  • Pas les mêmes logger ;
  • Pas les mêmes serializer JSON ;
  • Pas les mêmes pool de connexion ;
  • Pas les mêmes clients HTTP...

Le travail est commencé pour mieux intégrer Elastica
avec le client officiel.

Feels good

Utilisez Elastica,
les Array c'est bon pour un Hello World !

Indexer un document


$index = $client->getIndex('app');

$doc = new Document(
    42, // Document ID
    ['username' => 'hans', 'likes' => ['2', '3', '5']]
);

$index->addDocuments([$doc]);
            

Encore des Array !

Avec des DTO,
on y verrait plus clair !

Data transfer object

Toilet Paper Array

Objectif DTO

Manipuler des objets en entrée et en résultat,
comme avec Doctrine.


// Création du DTO
$product = new Product();
$product->setName('Beurre Salé');
$product->setCategory('Produits vitaux');

// Indexation
$doc = new Document(43, $product);
$index->addDocuments([$doc]);

Nous allons devoir coder ! C'est pour ça qu'on est là !

Des briques à coder

  • IndexBuilder : créer les Index avec leur Mapping ;
  • Indexer : créer les Document, les pousser dans l'Index ;
  • ResultBuilder : construire nos résultats de recherche...

L'indexation

Créer un index


PUT /app
{
    "settings": {
        "number_of_shards": 1,
        "analysis": {
            "analyzer": {
                "yolo": {
                    "tokenizer": "standard"
                }
            }
        }
    },
    "mappings": {
        "properties": {
            "name": { "type": "text", "analyzer": "yolo" },
            "ref": { "type": "text", "analyzer": "yolo" }
        }
    }
}

↙️ ️Beaucoup de répétitions

YAML > JSON

analyzers.yaml (partagé)

filter:
  app_french_stemmer:
    type: stemmer
    language: light_french
analyzer:
  app_french_heavy:
    tokenizer: icu_tokenizer
    filter:
      - app_french_elision
      - icu_folding
      - app_french_stemmer
mapping.yaml (par index)

settings:
  number_of_shards: 1
  # Include analyzers.yaml here
mappings:
  properties:
    name:
      type: text
      analyzer: app_standard

https://noyaml.com/

Mais j'aime toujours YAML.

IndexBuilder.php


public function createIndex($indexName): Index
{
    // Read the YAML's
    $mapping = Yaml::parse(file_get_contents($indexName .'_mapping.yaml'));
    $analyzer = Yaml::parse(file_get_contents('/analyzers.yaml'));

    // Merge the YAML's
    $mapping['settings']['analysis'] = array_merge_recursive(
        $mapping['settings']['analysis'] ?? [],
        $analyzer
    );

    // Build Index name
    $realName = sprintf('%s_%s', $indexName, date('Y-m-d-His'));
    $index = $this->client->getIndex($realName);

    // Actually create the Index with Mapping
    $index->create($mapping);

    return $index;
}

Et voilà index créé !

🎓 Protip © Index Version

Versionnez vos index avec des Alias

Alias Elasticsearch

🎓 Protip © Index Version

Dans Elastically :


$realName = sprintf('%s_%s', $indexName, date('Y-m-d-His'));
$index = $this->client->getIndex($realName);
$index->create($mapping);

public function markAsLive(Index $index, $indexName): Response
{
    $data = ['actions' => []];

    $data['actions'][] = ['remove' => ['index' => '*', 'alias' => $indexName]];
    $data['actions'][] = ['add' => ['index' => $index->getName(), 'alias' => $indexName]];

    return $this->client->request('_aliases', Request::POST, $data);
}
            

🎓 Protip © Dynamic

Par défaut le mapping est dynamique,
il se crée tout seul ! 🎉


PUT /app/_doc/1
{ "rating": 9 }

PUT /app/_doc/2
{ "rating": 9.9 }

GET /app/_mapping
> { "rating": { "type": "long" }}
            

Oops, mon 9.9 perd sa décimale !

🎓 Protip © Dynamic

Désactivez le mapping dynamique. C'est bon
pour les tutos, pas pour les pros.


mappings:
    dynamic: false
    properties:
        rating:
            ...
            

👿🔥🔥 dynamic: true, c'est LE MAL ! 🔥🔥👿

Indexer.php

Indexer un Document avec Elastica


// Via Array
$doc = new Document(43, ['name' => 'Beurre salé']);
$index->addDocuments([$doc]);
            

// Or via JSON
$doc = new Document(43, '{"name": "Beurre salé"}');
$index->addDocuments([$doc]);
            

Serialiser nos DTO

DTO to JSON

Pour passer d'un DTO PHP à un JSON :

  • \json_encode (coucou JsonSerializable)
  • ObjectNormalizer de symfony/serializer
  • JMSSerializer
  • Jane...

Nous devons pouvoir dénormaliser aussi,
en résultat de recherche !

Serializer de Symfony


use Symfony\Component\Serializer as Serializer;

$serializer = new Serializer\Serializer([
    new Serializer\Normalizer\ArrayDenormalizer(),
    new Serializer\Normalizer\ObjectNormalizer(),
], [
    new Serializer\Encoder\JsonEncoder()
]);

$serializer->serialize($product, 'json');
            

                {"name":"WashWash 3000","category":"Dentifrice"}
            

Serializer de Symfony


// Super DTO
$product = new Product();
$product->setName('Pizza Poutine');

// Document Elastica
$doc = new Document(
    43,
    $serializer->serialize($product, 'json')
);

// Indexation
$index->addDocuments([$doc]);
            

Le DTO est indexé 👌🏽

Jane : Tools for generating PHP Code

ObjectNormalizer est plutôt lent car il va lire
votre DTO via \Reflection.

Jane va générer le DTO et ses Normalizer
en pure PHP via un JSON Schema.

https://jane.readthedocs.io/

Une indexation complète

  1. Requête à la base de données ;
  2. Loop sur tous les résultats ;
    • Construction des DTO, JSON et Document ;
    • Ajout dans un Array $docs[] ;
  3. Appel à $index->addDocuments($docs); qui produit l'appel HTTP à l'API Bulk.

Ça va pas du tout scaler 💥

Une indexation complète

  1. Requête à la base de données ;
  2. Loop sur tous les résultats ;
    • Construction des DTO, JSON et Document ;
    • Ajout dans un Array $docs[] ;
    • Quand le Array atteint un seuil,
      appel à
      $index->addDocuments($docs);
  3. Appel à $index->addDocuments($docs); final.

Une indexation complète

Vous aurez besoin d'une queue d'indexation locale :


$client = new Client();
$indexer = new \JoliCode\Elastically\Indexer($client);
$index = $client->getIndex('app');

$indexer->scheduleIndex($index, $doc1);
$indexer->scheduleIndex($index, $doc2);
$indexer->scheduleIndex($index, $doc3); // Envoi du bulk
$indexer->scheduleIndex($index, $doc4);
$indexer->scheduleIndex($index, $doc5);
$indexer->flush(); // Et ici on force

Elastically, c'est une petite lib pour simplifier nos implémentations...

Nos résultats de recherche

Récupérer notre DTO

Elastica répond avec des Elastica\Result,
dont "data" est un Array associatif.


$results = $index->search(
    new \Elastica\Query\Match('name', 'washwash')
);

// Elastica\ResultSet:
//     results: Elastica\Result[]

Result.php


namespace JoliCode\Elastically;

use Elastica\Document;
use Elastica\Result as ElasticaResult;

class Result extends ElasticaResult
{
    protected $model;

    // Getter + Setter
}

Extension du Result d'origine pour ajouter un "model".

Elastica\ResultSet\BuilderInterface

Nous pouvons avoir notre propre builder de résultats.


$result = new Result($hit);

$result->setModel(
    $this->serializer->denormalize(
        $result->getSource(),
        \Product::class
    );
);

Et voilà nous avons des Product dans nos résultats !

Pour l'utiliser

À chaque recherche :


use \JoliCode\Elastically\ResultSetBuilder;

$search = $index->createSearch(
    $query,
    null,
    new ResultSetBuilder($serializer)
);

Notre Search va répondre des Product !

La création de Document

Oui elles sont au beurre salé.

Mapping ≠ Document

  • Le Mapping : vos champs dans l'index Lucene ;
  • Le Document : vos données à vous ;
  • Ne pas corréler les deux.

{
  title: "Vive la Guinness",
  url: "https://joli.beer/"
}

title: [vive] [guinness]

"url" n'est pas indexé !

🎓 Protip © Clé JSON

Ne mettez pas de valeurs en clé de JSON.


{
  "title": "Denver, le dernier dinosaure",
  "comments": {
    "340": { "text": "Pourquoi seulement 52 épisodes !" },
    "341": { "text": "Rendez nous les Minikeums !" }
  }
}

"Mapping explosion" en devenir ! 💥

🎓 Protip © Clé JSON


{
  "title": "Denver, le dernier dinosaure",
  "comments": [
    { "id": "340", "text": "Pourquoi seulement 52 épisodes !" },
    { "id": "341", "text": "Rendez nous les Minikeums !" }
  ]
}

Et vous gagnez la possibilité de chercher par Id. Denver

Connexion à la base de données

Propager les mises à jour

  • Notifier un bus de messages (traitement asynchrone) :
    • Event applicatif ;
    • Event Doctrine ;
    • Trigger de base de données.
  • Logstash (inputs-jdbc) ;
  • Indexer lors du flush() 💥.

Pourquoi pas synchrone ?

  • Latence par défaut de 1 seconde (refresh) ;
  • Connexion HTTP à ouvrir ;
  • Ralentissement de l'application ;
  • Elasticsearch down = perte de la mise à jour ;
  • Autant indexer en asynchrone donc !

Un worker d'indexation

  • Déléguez-lui le plus de travail possible ;
  • Payload très léger :
    {
        "op": "delete",
        "class": "Author",
        "id": 123
    }
  • Arbre des relations dénormalisées à calculer !

Quelques conseils

Accélérer l'indexation

  • Souvent PHP le problème...
    • Faire des bulk, jouer avec leur taille ;
    • Blackfire.io pour trouver où vous perdez du temps ;
    • Utiliser un Serializer rapide (coucou Jane 👋🏽) ;
    • Doctrine iterate().
  • Côté Elasticsearch :
    • Augmentation du refresh_interval ;
    • Désactivation des replicas.

Pertinence des résultats

Le SQL me manque


POST /_sql?format=txt
{
  "query": "SELECT * FROM app LIMIT 5"
}
            

   category    |     name
---------------+---------------
Dentifrice     |WashWash 3000
Dentifrice     |WashWash 3200 S
            

C'est dans X-Pack Basic donc gratuit.

POST /_sql/translate pour obtenir le Query DSL.

Client Node

Client Node Elasticsearch

Placez un Node Elasticsearch sur vos frontaux PHP.

Utilisez ICU

  • Plugin officiel analysis-icu ;
  • Meilleur support du Français :
    • Ligature : Œ indexé OE ;
    • Cédille : Ç indexé C ;
  • Mais aussi les autres langues (CJK)...
  • Collation pour trier !

🌼 Indexez les emoji 🌼

Kibana = PHPStorm

Kibana 7

N'exposez pas Elastic

Exposed Elasticsearch Exposed Elasticsearch Exposed Elasticsearch Exposed Elasticsearch Exposed Elasticsearch

Monitorez votre Elasticsearch

Disaster Elastic

N'hébergez pas Elasticsearch ?

En conclusion

Introducing Elastically

  • https://github.com/jolicode/elastically
  • Mes bonnes pratiques dans une librairie ;
  • L'IndexBuilder, l'Indexer, le transport HttpClient, le handler Messenger...
  • Gestion des alias, rétention des index...
  • Open-Source et stable !

Stack finale idéale

  • Du YAML pour les mappings ;
  • Des DTO pour les données ;
  • Elastica pour les Query / les manipulations ;
  • JanePHP pour la sérialisation ;

Wishlist

  • Meilleure intégration Elastica / Client Officiel ;
  • Commandes à la "Curator" pour la gestion courante du cluster...
  • Doctrine Migration mais pour les index Elasticsearch
  • Un parseur visuel pour les "explain"

Merci pour votre écoute !

❓ Questions ❓

https://github.com/jolicode/elastically

@damienalexandre
coucou@jolicode.com

Crédit photos

unsplash-logoPriscilla Du Preez unsplash-logoAaron Burden unsplash-logoSuzanne D. Williams unsplash-logoAnthony Martino
unsplash-logoAlexandre Godreau unsplash-logoDavid Clode unsplash-logoVincent Botta unsplash-logoBrooke Lark Winner ShutterStock

Remerciements

Merci à la team JoliCode et à Perrine.

Nicolas Ruflin pour avoir créé Elastica
et Joël Wurtz pour Jane.

Bonus

🎓 Protip © Bulk

Un Bulk répond toujours le code HTTP 200 !
Il faut toujours en lire la réponse :


{
    "took" : 22,
    "errors" : true,
    "items" : [
        {
            "index" : {
                "_index" : "app",
                "_type" : "_doc",
                "_id" : "1",
                "status" : 400,
                "error" : { … }
            }
        }
    ]
}

🎓 Protip © YAML anchor

Votre YAML

mappings:
    properties:
      first_name: &simple_text
        type: text
        fields:
          raw: { type: keyword }
          stemmed: { type: text, analyzer: french }
      last_name: *simple_text
      company: *simple_text
      city:
        <<: *simple_text
        fields:
          raw: { type: keyword }
Équivalent

mappings:
    properties:
      first_name:
        type: text
        fields:
          raw: { type: keyword }
          stemmed: { type: text, analyzer: french }
      last_name:
        type: text
        fields:
          raw: { type: keyword }
          stemmed: { type: text, analyzer: french }
      company:
        type: text
        fields:
          raw: { type: keyword }
          stemmed: { type: text, analyzer: french }
      city:
        type: text
        fields:
          raw: { type: keyword }

Un agent APM pour PHP

En cours de développement :

https://github.com/elastic/apm-agent-php

Avec Symfony Messenger

  • Tout est mis à disposition dans Elastically !
    • Bus et Transport ;
    • Handler ;
    • Worker ;
    • Gestion des erreurs...
  • Très peu de code pour de gros bénéfices.

Avec Symfony Messenger

Un DTO pour notre "Message" :


namespace JoliCode\Elastically\Messenger;

final class IndexationRequest
{
    private $operation;
    private $type;
    private $id;

    public function __construct(string $type, string $id, string $operation = IndexationRequestHandler::OP_INDEX)
    {
        $this->type = $type;
        $this->id = $id;
        $this->operation = $operation;
    }

...

Avec Symfony Messenger

Un Handler qui va traiter le message :


class IndexationRequestHandler implements MessageHandlerInterface
{
    private $client;

    public function __invoke(IndexationRequest $message)
    {
        $model = // todo fetch the model

        $indexer->scheduleIndex($indexName, new Document($message->getId(), $model, '_doc'));
        $indexer->flush();
    }
}

Avec Symfony Messenger

Envoyer une demande d'indexation :


$this->bus->dispatch(
    new IndexationRequest(Product::class, 123, 'index')
);

Traiter les messages :

bin/console messenger:consume-messages

Symfony HttpClient

Il est possible de changer le Client HTTP d'Elastica !

Elastica\Transport\AbstractTransport

Une simple classe à implémenter 🎉

Symfony HttpClient

/**
 * Executes the transport request.
 *
 * @param Request $request Request object
 * @param array   $params  Hostname, port, path, ...
 */
abstract public function
        exec(Request $request, array $params): Response;

Symfony HttpClient

Symfony HTTPClient in Elastica