État de l'art

d'Elasticsearch avec Symfony

Elasticsearch

🎨

@damienalexandre

@damienalexandre JoliCode

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

Révisons les bases

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 Symfony

Les paquets existants

  • elasticsearch/elasticsearch-php = Officiel ;
  • friendsofsymfony/elastica-bundle ;
  • ruflin/elastica ;
  • madewithlove/elasticsearcher (5.x) ;
  • doctrine/search Surprise !
  • Beaucoup de code non maintenu, inutile,...

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);
            

FOSElasticaBundle

Implémentation plutôt que librairie. Très bien pour des projets rapides...

  • Pont entre Doctrine et Elastica ;
  • Clé en main.

Elastica

  • Créé et maintenu par Nicolas Ruflin ❤️
  • Abstraction objet totale ;
  • "Basé" sur le 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

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.

🎓 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 }

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 !
Elasticsearch propose des alias d'index.


$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é 👌🏽

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); // GO BULK
$indexer->scheduleIndex($index, $doc4);
$indexer->scheduleIndex($index, $doc5);
$indexer->flush(); // Et ici on force

Elastically c'est une petite lib pour simplier 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 !

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 !

Avec Symfony Messenger

  • Tout est mis à disposition !
    • 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

Quelques conseils

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

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 !

🌼 Utilisez ICU 🌼

MAIS SURTOUT ça ajoute le support des Emoji !

Kibana = PHPStorm

Kibana 7

SymfonyCloud

  • Service Elasticsearch en quelques lignes de YAML :
    app_elasticsearch:
        type: elasticsearch:6.5 # Pas encore Elasticsearch 7 :(
        configuration:
            plugins:
                - analysis-icu
  • Pas de Kibana, mais facile à faire via un tunnel :
    $ symfony tunnel:open -f
    $ docker run -it --rm --network host --name kibana_sfcloud \
        -e ELASTICSEARCH_URL=http://127.0.0.1:30002 \
        docker.elastic.co/kibana/kibana:6.5.4

N'exposez pas Elastic

Exposed Elasticsearch Exposed Elasticsearch Exposed Elasticsearch Exposed Elasticsearch Exposed 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 mais pas encore "stable" !

Stack finale idéale

  • Du YAML pour les mappings ;
  • Des DTO pour les données ;
  • Elastica pour les Query / les manipulation ;
  • Composants : Serializer, Messenger et HttpClient ;

Wishlist

  • Meilleur intégration Elastica / Client Officiel ;
  • Export Curl des requêtes HttpClient #33311 ;
  • Commandes à la "Curator" pour la gestion courante du cluster...

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

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/

Installer Jane


composer require --dev jane-php/json-schema "^4.0"
composer require jane-php/json-schema-runtime "^4.0"
composer require --dev friendsofphp/php-cs-fixer "^2.7.3"

# Run the code generator
php vendor/bin/jane generate \
 --config-file=jane-elasticsearch-dto-config.php

Installer Jane

Configuration Jane

return [
  'json-schema-file' => '/es.json',
  'root-class' => 'Model',
  'namespace' => 'Elasticsearch',
  'directory' => '/generated',
];

                    
JSON Schema
{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "definitions": {
    "Product": {
      "type": "object",
      "properties": {
        "name": {
          "type": "string"
        },
        "category": {
          "type": "string"
        }
      }
    }
  }
}

Et 💥 BOOM 💥


$ tree generated/

generated
├── Model
│   └── Product.php
└── Normalizer
    ├── NormalizerFactory.php
    └── ProductNormalizer.php
            

Product.php


namespace Elasticsearch\Model;

class Product
{
    protected $name;
    protected $category;

    // + getter and setters
}
            

ProductNormalizer.php


namespace Elasticsearch\Normalizer;

class ProductNormalizer implements DenormalizerInterface, NormalizerInterface, DenormalizerAwareInterface, NormalizerAwareInterface
{
    use DenormalizerAwareTrait;
    use NormalizerAwareTrait;

    public function supportsDenormalization($data, $type, $format = null)
    {
        return $type === 'Elasticsearch\\Model\\Product';
    }

    public function supportsNormalization($data, $format = null)
    {
        return $data instanceof \Elasticsearch\Model\Product;
    }

    public function denormalize($data, $class, $format = null, array $context = [])
    {
        $object = new \Elasticsearch\Model\Product();

        if (property_exists($data, 'name')) {
            $object->setName($data->{'name'});
        }
        if (property_exists($data, 'category')) {
            $object->setCategory($data->{'category'});
        }

        return $object;
    }

    public function normalize($object, $format = null, array $context = [])
    {
        $data = new \stdClass();

        if (null !== $object->getName()) {
            $data->{'name'} = $object->getName();
        }
        if (null !== $object->getCategory()) {
            $data->{'category'} = $object->getCategory();
        }

        return $data;
    }
}

NormalizerFactory.php


namespace Elasticsearch\Normalizer;

class NormalizerFactory
{
    public static function create()
    {
        $normalizers = [];
        $normalizers[] = new \Symfony\Component\Serializer\Normalizer\ArrayDenormalizer();
        $normalizers[] = new \Jane\JsonSchemaRuntime\Normalizer\ReferenceNormalizer();
        $normalizers[] = new ProductNormalizer();

        return $normalizers;
    }
}

Jane + Serializer


$normalizers = \Elasticsearch\Normalizer\NormalizerFactory::create();
$encoders = [
    new JsonEncoder(
       new JsonEncode([JsonEncode::OPTIONS => \JSON_UNESCAPED_SLASHES]),
       new JsonDecode([JsonDecode::ASSOCIATIVE => false])
    )
];

$serializer = new Serializer($normalizers, $encoders);

// Super fast Product serializer!

Jane

Vous pouvez l'utiliser pour tous vos besoins
DTO / Normalisation.

👍🏽 (Un AutoMapper dans Symfony ? PR en cours !) 👍🏽

La création de Document

La dénormalisation
en NoSQL

  • De quels champs avez-vous besoin à l'affichage ?
  • De quels champs avez-vous besoin à la recherche ?

Envoyez le nécessaire,
indexez le minimum !

La dénormalisation
en NoSQL

  • Un Article est lié à un Auteur ;
  • Nous voulons chercher par Auteur ;
  • Mettre à jour un Auteur ? Potentiellement des milliers de mises à jour ! 💥
Denormaliser

Adieu les relations !

  • Sans jointures, mises à jour plus complexes ;
  • Certaines requêtes difficiles ;
  • Compromis entre tout dénormaliser et devoir faire plusieurs requêtes.

Ne dénormalisez pas
toute votre BDD !

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

Elasticsearch limite à 1000 le nombre de champs.


{
  "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.