Implémenter un moteur de recherche avec elasticsearch et Symfony (partie 3/3)

Publié le 16/11/2019 • Actualisé le 18/04/2020

Dans cette troisième et dernière partie, nous allons continuer à peaufiner notre moteur de recherche. Tout d'abord, nous allons améliorer notre stack elasticsearch en incorporant Kibana. Ensuite, nous implémenterons un autocomplete en utilisant la fonctionnalité de suggestion d'elasticsearch. C'est parti ! 😎


English language detected! 🇬🇧

  We noticed that your browser is using English. Do you want to read this post in this language?

Read the english version 🇬🇧 Close

» Publié dans "Une semaine Symfonique 672" (du 11 au 17 novembre 2019).

Tutoriel

Cet article est le troisième et dernier du tutoriel : "Implémenter un moteur de recherche avec Elasticsearch et Symfony"

Prérequis

Les prérequis sont les mêmes que pour les deux premières parties. Je vous recommande bien sûr de les lire (liens au dessus) avant d'attaquer celle-ci.

Configuration

  • PHP 8.3
  • Symfony 6.4.3
  • elasticsearch 6.8

Installation de Kibana

Tout d'abord nous allons améliorer notre stack Elasticsearch. Jusqu'à maintenant, nous avons utilisé le plugin "head" pour gérer notre cluster. Mais cet outil de développement est assez ancien et n'est plus maintenu. Donc, ajoutons Kibana à notre hub docker. Kibana est un plugin open-source de visualisation de données pour Elasticsearch. Bien sûr, il permet aussi de faire les tâches de maintenance courantes que nous avions l'habitude de faire avec head : supprimer, fermer un index, créer et supprimer un alias, vérifier un document, vérifier le mapping des index, mais il permet bien plus encore ! La liste de ce qu'il est possible de faire est assez impressionnante (regardez le menu à gauche de la capture d'écran suivante). Ajoutons l'entrée correspondante dans le fichier docker-compose.yaml :

kibana:
    container_name: sb-kibana
    image: docker.elastic.co/kibana/kibana:6.8.18
    ports:
      - "5601:5601"
    environment:
      - "ELASTICSEARCH_URL=http://sb-elasticsearch"
    depends_on:
      - elasticsearch
Cliquez ici pour voir le nouveau fichier docker-compose.yaml complet.
# ./docker-compose.yaml

# DEV docker compose file ——————————————————————————————————————————————————————
# Check out: https://docs.docker.com/compose/gettingstarted/
version: '3.7'

services:
  # Database ———————————————————————————————————————————————————————————————————
  # MySQL server database (official image)
  # https://docs.docker.com/samples/library/mysql/
  db:
    container_name: strangebuzz-db-1
    image: mysql:5.7
    platform: linux/x86_64
    command: --default-authentication-plugin=mysql_native_password
    ports:
      - "3309:3306"
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_HOST: '%'
    healthcheck:
      test: ["CMD-SHELL", "mysqladmin ping -P 3306 -proot | grep 'mysqld is alive' || exit 1"]
      interval: 10s
      timeout: 30s
      retries: 10

  # Snippet L21+4 in templates/snippet/code/_133.html.twig

  # —— Elasticsearch ———————————————————————————————————————————————————————————

  # Elasticsearch server (official image)
  # https://www.elastic.co/guide/en/elasticsearch/reference/current/docker.html
  # https://hub.docker.com/_/elasticsearch/
  # https://stackoverflow.com/questions/68877644/how-to-run-elasticsearch-6-on-an-apple-silicon-mac
  elasticsearch:
    container_name: strangebuzz-elasticsearch-1
    # image: docker.elastic.co/elasticsearch/elasticsearch:7.17.9
    # image: docker.elastic.co/elasticsearch/elasticsearch:7.1.
    image: webhippie/elasticsearch:6.4
    platform: linux/amd64
    ports:
      - "9209:9200"
      - "9309:9300" # Important if you have multiple es instances running
    environment:
        - "http.port=9200"
        - "discovery.type=single-node"
        - "bootstrap.memory_lock=true"
        - "ES_JAVA_OPTS=-Xms1G -Xmx1G"
        - "xpack.security.enabled=false"
        - "http.cors.enabled=true"
        - "http.cors.allow-origin=*"
        - "cluster.routing.allocation.disk.threshold_enabled=false" # https://www.elastic.co/guide/en/elasticsearch/reference/6.8/disk-allocator.html
    healthcheck:
        test: ["CMD-SHELL", "curl --silent --fail localhost:9200/_cluster/health?wait_for_status=yellow&timeout=30s || exit 1"]
        interval: 10s
        timeout: 30s
        retries: 10

  # Cache ——————————————————————————————————————————————————————————————————————
  # Redis (official image)
  # https://hub.docker.com/_/redis
  redis:
    container_name: strangebuzz-redis-1
    image: redis:5.0.13-alpine
    ports:
      - '6389:6379'
    healthcheck:
      test: ["CMD-SHELL", "redis-cli -h 127.0.0.1 ping | grep 'PONG' || exit 1"]
      interval: 10s
      timeout: 30s
      retries: 10

  # Snippet L55+12 in templates/snippet/code/_236.html.twig

  # PHP-FPM ————————————————————————————————————————————————————————————————————
  # https://github.com/dunglas/symfony-docker
  php:
    container_name: strangebuzz-php-1
    build:
      context: .
      target: symfony_php
      args:
        SYMFONY_VERSION: ${SYMFONY_VERSION:-}
        SKELETON: ${SKELETON:-symfony/skeleton}
        STABILITY: ${STABILITY:-stable}
    restart: unless-stopped
    volumes:
      - php_socket:/var/run/php
    healthcheck:
      interval: 10s
      timeout: 3s
      retries: 3
      start_period: 30s
    environment:
      # Run "composer require symfony/mercure-bundle" to install and configure the Mercure integration (@todo)
      # MERCURE_URL: ${CADDY_MERCURE_URL:-http://caddy/.well-known/mercure}
      # MERCURE_PUBLIC_URL: https://${SERVER_NAME:-localhost}/.well-known/mercure
      # MERCURE_JWT_SECRET: ${CADDY_MERCURE_JWT_SECRET:-!ChangeMe!}

      # new from legacy project (.env to transfer)
      APP_ENV:      prod
      APP_SERVER:   prod
      APP_DEBUG:    0
      DATABASE_URL: mysql://root:root@db:3306/strangebuzz?serverVersion=5.7
      REDIS_URL:    redis://redis:6379/1
      ES_HOST:      elasticsearch
      ES_PORT:      9200

      # Taken from .env
      APP_SECRET:           ${APP_SECRET}
      APP_HTML5_VALIDATION: ${APP_HTML5_VALIDATION}
      ADMIN_PASSWORD:       ${ADMIN_PASSWORD}
      SLACK_TOKEN:          ${SLACK_TOKEN}
      CORS_ALLOW_ORIGIN:    ${CORS_ALLOW_ORIGIN}
      JWT_PASSPHRASE:       ${JWT_PASSPHRASE}

  # Caddy Web Server ———————————————————————————————————————————————————————————
  # https://github.com/dunglas/symfony-docker
  caddy:
    container_name: strangebuzz-caddy-1
    build:
      context: .
      target: symfony_caddy
    depends_on:
      - php
    environment:
      SERVER_NAME: ${SERVER_NAME:-localhost, caddy:80}
      MERCURE_PUBLISHER_JWT_KEY: ${CADDY_MERCURE_JWT_SECRET:-!ChangeMe!}
      MERCURE_SUBSCRIBER_JWT_KEY: ${CADDY_MERCURE_JWT_SECRET:-!ChangeMe!}
    restart: unless-stopped
    volumes:
      - php_socket:/var/run/php
      - caddy_data:/data
      - caddy_config:/config
    ports:
      # HTTP
      - target: 80
        published: 80
        protocol: tcp
      # HTTPS
      - target: 443
        published: 443
        protocol: tcp
      # HTTP/3
      - target: 443
        published: 443
        protocol: udp

volumes:
  php_socket:
  caddy_data:
  caddy_config:

Comme vous pouvez le voir, nous passons l'URL du serveur Elasticsearch dont le nom d'hôte est celui du conteneur docker (sb-elasticsearch). Nous gardons le port standard 5601. Nous utilisons aussi la même version d'image (6.8.18) que nous avons utilisée pour Elasticsearch afin qu'il n'y ait pas de problème de compatibilité. Si vous redémarrez le hub docker, vous pouvez accéder à la page de gestion des index :

Kibana en action !

Voilà pour Kibana. Je vais m'arrêter ici pour cette partie, ça demanderait bien plus qu'un article pour présenter toutes les fonctionnalités. Accédez au site officiel pour plus d'informations. Kibana est très puissant, il peut aussi être utilisé pour consulter vos logs Symfony ! À ce sujet, je vous conseille la lecture de ce très intéressant article du blog JoliCode.

Ajout d'un autocomplete dans la barre de recherche

Comme vous pouvez le voir, j'ai mis un champ de recherche dans l'entête de ce site. Ça marche, mais si nous essayions de compléter la saisie de l'utilisateur afin de lui suggérer des termes qu'il peut trouver sur ce blog ? Voyons comment nous pouvons faire cela avec Elasticsearch, nous allons construire un index qui sera dédié à cette fonctionnalité.

Configuration du mapping

Jusqu'à maintenant nous n'avons utilisé que le type par défaut "text" pour toutes les propriétés du mapping. Pour ce nouvel index, nous allons utiliser un type spécial : completion. Nous allons ajouter une nouvelle configuration pour cet index "suggest" juste après le principal "app" que nous avons utilisé dans les articles précédents :

# config/packages/fos_elastica.yaml
fos_elastica:
    clients:
        default: { host: '%es_host%', port: '%es_port%' }
    indexes:
        app:
          ###
        suggest:
            use_alias: true
            settings:
                index:
                    analysis:
                        analyzer:
                            suggest_analyzer:
                                type: custom
                                tokenizer: standard
                                filter: [lowercase, asciifolding]
            types:
                keyword:
                    properties:
                        locale:
                            type: keyword
                        suggest:
                            type: completion
                            analyzer: suggest_analyzer
                            contexts:
                                - name: locale
                                  type: category
                                  path: locale
Cliquez ici pour voir le mapping YAML complet.
# config/packages/fos_elastica.yaml
fos_elastica:
    clients:
        default: { host: '%es_host%', port: '%es_port%' }
    indexes:
        app:
            use_alias: true
            types:
                articles:
                    properties:
                        type: ~
                        keywordFr: { boost: 4 }
                        keywordEn: { boost: 4 }
                        # i18n
                        titleEn: { boost: 3 }
                        titleFr: { boost: 3 }
                        headlineEn: { boost: 2 }
                        headlineFr: { boost: 2 }
                        ContentEn: ~ # The default boost value is 1
                        ContentFr: ~
                    persistence:
                        driver: orm
                        model: App\Entity\Article
                        provider:
                            service: App\Elasticsearch\Provider\ArticleProvider
                        listener:
                            insert: false
                            update: false
                            delete: false
            settings:
                index:
                    analysis:
                        analyzer:
                            synonym:
                                tokenizer: "standard"
                                filter: [synonym]
                        filter:
                            synonym:
                                type: "synonym"
                                synonyms:
                                    - "elastic,elastica => elasticsearch"


        # L1->L29 snippet in templates/blog/posts/_48.html.twig
        suggest:
            use_alias: true
            settings:
                index:
                    analysis:
                        analyzer:
                            suggest_analyzer:
                                type: custom
                                tokenizer: standard
                                filter: [lowercase, asciifolding]
            types:
                keyword:
                    properties:
                        locale:
                            type: keyword
                        suggest:
                            type: completion
                            analyzer: suggest_analyzer
                            contexts:
                                - name: locale
                                  type: category
                                  path: locale

Quelques explications à propos de cet index et de son mapping. Avant de déclarer le type, j'ajoute un analyseur dans la section "setting". Le filtre asciifolding va nous permettre d'ignorer les accents pour permettre à la suggestion de fonctionner même si ceux-ci ne sont pas utilisés. Par exemple, si on saisit "element", le mot "élément" devrait être suggéré.
Ensuite, dans la section "type", on utilise aussi un alias tout comme l'index "app". Dans le mapping nous avons deux propriétés : suggest qui est de type "completion". Nous avons besoin de ce type particulier pour utiliser le "suggester" Elasticsearch comme nous le verrons dans le chapitre suivant. Nous avons une seconde propriété locale qui va nous permettre de filtrer les suggestions en fonction de la langue courante (en ou fr). On a ajouté un contexte au champ "suggest" et celui-ci est associé à la propriété "locale" (path: locale).
Si nous relançons la commande d'indexation, le nouvel index est créé. À cette étape, nous avons désormais deux index dans le cluster Elasticsearch :

Kibana en action !

Peupler l'index de suggestion

Maintenant, nous allons peupler le nouvel index de suggestion. Comme il n'y a pas de modèle Doctrine associé, nous n'allons pas créer un fournisseur de données mais une commande Symfony. L'idée est d'extraire tous les mots qui ont été utilisés dans l'index app. Voilà la nouvelle commande Symfony : (quelques éclaircissements après le code 🤔)

<?php

declare(strict_types=1);

// src/Command/PopulateSuggestCommand.php (used by templates/blog/posts/_51.html.twig)

namespace App\Command;

use Doctrine\Inflector\Inflector;
use Doctrine\Inflector\NoopWordInflector;
use Elastica\Document;
use FOS\ElasticaBundle\Elastica\Index;
use FOS\ElasticaBundle\Finder\TransformedFinder;
use FOS\ElasticaBundle\HybridResult;
use FOS\ElasticaBundle\Paginator\FantaPaginatorAdapter;
use Pagerfanta\Pagerfanta;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use function Symfony\Component\String\u;

/**
 * Populate the suggest elasticsearch index.
 */
final class PopulateSuggestCommand extends BaseCommand
{
    public static $defaultName = self::NAMESPACE.':populate';
    protected static $defaultDescription = 'Populate the "suggest" Elasticsearch index';

    private Inflector $inflector;

    public function __construct(
        private TransformedFinder $articlesFinder,
        private Index $suggestIndex
    ) {
        parent::__construct();
        $this->inflector = new Inflector(new NoopWordInflector(), new NoopWordInflector());
    }

    protected function configure(): void
    {
        [$desc, $class] = [self::$defaultDescription, self::class];
        $this->setHelp(
            <<<EOT
$desc

COMMAND:
<comment>$class</comment>

<info>%command.full_name%</info>
EOT
        );
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $output->writeln((string) self::$defaultDescription);
        $pagination = $this->findHybridPaginated($this->articlesFinder);
        $nbPages = $pagination->getNbPages();
        $keywords = [];

        foreach (range(1, $nbPages) as $page) {
            $pagination->setCurrentPage($page);
            foreach ($pagination->getCurrentPageResults() as $result) {
                if ($result instanceof HybridResult) {
                    foreach ($result->getResult()->getSource() as $property => $text) {
                        if ($property === 'type') {
                            continue;
                        }
                        $locale = explode('_', $this->inflector->tableize($property))[1] ?? 'en';
                        $text = strip_tags($text ?? '');
                        $words = str_word_count($text, 2, 'çéâêîïôûàèùœÇÉÂÊÎÏÔÛÀÈÙŒ'); // FGS dot not remove french accents! 🙃
                        $textArray = array_filter($words);
                        $keywords[$locale] = array_merge($keywords[$locale] ?? [], $textArray);
                    }
                }
            }
        }

        // Index by locale
        foreach ($keywords as $locale => $localeKeywords) {
            // Remove small words and remaining craps (emojis) 😖
            $localeKeywords = array_unique(array_map('mb_strtolower', $localeKeywords));
            $localeKeywords = array_filter($localeKeywords, static function ($v): bool {
                return u($v)->trim()->length() > 2;
            });
            $documents = [];
            foreach ($localeKeywords as $keyword) {
                $documents[] = (new Document())
                    ->setType('keyword')
                    ->set('locale', $locale)
                    ->set('suggest', $keyword);
            }
            $responseSet = $this->suggestIndex->addDocuments($documents);

            $output->writeln(sprintf(' -> TODO: %d -> DONE: <info>%d</info>, "%s" keywords indexed.', \count($documents), $responseSet->count(), $locale));
        }

        return self::SUCCESS;
    }

    /**
     * @return Pagerfanta<mixed>
     */
    private function findHybridPaginated(TransformedFinder $articlesFinder): Pagerfanta
    {
        $paginatorAdapter = $articlesFinder->createHybridPaginatorAdapter('');

        return new Pagerfanta(new FantaPaginatorAdapter($paginatorAdapter));
    }
}

Quelques explications : 💡

  • On lance une recherche joker pour récupérer le nombre total de pages.
  • On itère sur chaque page pour récupérer les articles relatifs.
  • Pour chaque article, on extrait toutes les clés du document Elasticsearch.
  • Pour chaque clé, on extrait du texte tous les mots grâce à la fonction PHP str_word_count().
  • On supprime tout ce qui est vide, les doublons et les mots trop petits.
  • Pour chaque mot restant on crée un document Elasticsearch en spécifiant sa langue.
  • Finalement, on lance le processus d'indexation avec la fonction addDocuments.

À l'heure ou je vous écris, il y a environ 3500 mots indexés. Voici la sortie console de la nouvelle tâche "populate" du MakeFile :

/Users/coil/Sites/strangebuzz.com$ make populate
php bin/console fos:elastica:reset
Resetting app
Resetting suggest
Resetting app
 53/53 [============================] 100%
Refreshing app
Populate the "suggest" elasticsearch index
 -> TODO: 2167 -> DONE: 2167, "fr" keywords indexed.
 -> TODO: 1549 -> DONE: 1549, "en" keywords indexed.

Le contenu de cette nouvelle entrée :

## —— elasticsearch 🔎 —————————————————————————————————————————————————————————
populate: ## Reset and populate the Elasticsearch index
	#@$(SYMFONY) fos:elastica:reset
	#@$(SYMFONY) fos:elastica:populate --index=app
	@$(SYMFONY) strangebuzz:index-articles

Vous pouvez trouver mon MakeFile Symfony complet dans ce snippet. Maintenant que l'index est peuplé, voyons comment l'utiliser pour l'implémentation de la fonctionnalité autocomplete.

Implémentation de l'autocomplete

Le but ici va être d'ajouter une action qui va retourner via Ajax les suggestions pour le widget autocomplete alors que l'utilisateur saisit un mot-clé. Créons un nouveau contrôleur dédié à cette tâche :

<?php

declare(strict_types=1);

// src/Controller/SuggestController.php

namespace App\Controller;

use App\Elasticsearch\ElastiCoil;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;

#[Route(path: '/{_locale}', requirements: ['_locale' => '%locales_requirements%'])]
final class SuggestController extends AbstractController
{
    #[Route(path: ['en' => '/suggest', 'fr' => '/suggerer'], name: 'suggest')]
    public function suggest(Request $request, string $_locale, ElastiCoil $elastiCoil): JsonResponse
    {
        $q = $request->query->get('q', '');

        return $this->json($elastiCoil->getSuggestions($q, $_locale));
    }
}

Et le service Elasticsearch personnalisé :

<?php

declare(strict_types=1);

// src/Elasticsearch/ElastiCoil.php

namespace App\Elasticsearch;

use Elastica\Query;
use Elastica\Suggest;
use Elastica\Suggest\Completion;
use Elastica\Util;
use FOS\ElasticaBundle\Elastica\Index;

final class ElastiCoil
{
    public const SUGGEST_NAME = 'completion';
    public const SUGGEST_FIELD = 'suggest';

    public function __construct(
        private readonly Index $suggestIndex
    ) {
    }

    /**
     * Get the a suggest object for a keyword and locale.
     */
    public function getSuggest(string $q, string $locale): Suggest
    {
        $completionSuggest = (new Completion(self::SUGGEST_NAME, self::SUGGEST_FIELD))
            ->setPrefix(Util::escapeTerm($q))
            ->setParam('context', ['locale' => $locale])
            ->setSize(5);

        return new Suggest($completionSuggest);
    }

    /**
     * Return suggestions for a keyword and locale as a simple array.
     *
     * @return array<string>
     */
    public function getSuggestions(string $q, string $locale): array
    {
        $suggest = $this->getSuggest($q, $locale);
        $query = (new Query())->setSuggest($suggest);
        $suggests = $this->suggestIndex->search($query)->getSuggests();

        return $suggests[self::SUGGEST_NAME][0]['options'] ?? [];
    }
}

Quelques explications : 💡

  • Comme l'action de recherche, nous récupérons la saisie de l'utilisateur par le paramètre GET "q".
  • Ensuite nous créons un objet elastica Suggest avec le nom de la propriété du mapping à utiliser.
  • Juste en dessous, on ajoute un contexte qui va nous permettre de filtrer les mots retournés : dans ce cas on filtre selon la langue de la page en cours (en ou fr).
  • Ensuite, on extrait les options retournées par la réponse Elasticsearch.
  • Finalement, nous retournons une réponse de type JSON (JsonResponse) contenant un tableau simple avec les options à afficher à l'utilisateur.

Affichage des suggestions

Maintenant que l'action de suggestion est faite, nous pouvons mettre en place un widget autocomplete qui va l'utiliser. Vous pouvez essayer dans le formulaire ci-dessous. C'est exactement le formulaire qui nous avons utilisé dans les articles précédents (un peu de JavaScript a été ajouté pour récupérer les suggestions). Comme vous pouvez le voir, sur cette page, uniquement des mots français sont retournés (en fait pas tout à fait car j'utilise certains anglicismes dans les articles rédigés en français !). Si vous essayez sur la version anglaise, vous pourrez vérifier que seuls des mots anglais le sont. C'est la même action mais un filtre a été appliqué grâce au contexte que nous avons assigné à l'objet de suggestion Elasticsearch.

Pour afficher le widget j'utilise un composant vue.js. Juste un commentaire à propos de la route que nous utilisons. Nous n'avons pas à spécifier la langue : {{ path('suggest') }} car le composant de routage s'en charge et l'ajoute automatiquement pour nous (sur cette page c'est fr). Cet autocomplete est aussi sur la page des résultats de recherche. Voic le code de l'inclusion du composant :

Cliquez ici pour voir le code du composant Vue.js.
{# https://github.com/BosNaufal/vue-autocomplete #}
{% set placeholder = placeholder is defined ? placeholder : 'enter_one_or_several_keywords'|trans({}, 'search') %}
<autocomplete
    ref="autocomplete"
    aria-describedby="qHelp"
    url="{{ path('search_suggest') }}" {# current local is injected #}
    anchor="text" {# not used, custom render #}
    label="text"
    :on-should-render-child="autocompleteRenderChild"
    :required="true"
    id="post-q"
    name="q"
    :classes="{ wrapper: 'form-wrapper', input: 'form-control', list: 'data-list', item: 'data-list-item' }"
    placeholder="{{ placeholder }}"
    init-value="{{ app.request.query.get('q') }}"
    :options="[]"
    :min="3"
    :encode-params="true"
>
</autocomplete>

Conclusion

C'était la dernière partie de ce tutoriel Elasticsearch. C'était intéressant (mais très long !) de l'écrire en même temps que je développais ces fonctionnalités sur ce site web. Il y a encore beaucoup à faire, mais je suis déjà content avec ce qui a été mis en place 😊. Je me sers tous les jours de cette recherche pour retrouver rapidement certains articles ou snippets. Une bonne nouvelle : le bundle FOSElastica est en cours de mise à jour pour supporter elastica 7.0. Donc, dès que cette version sortira, je modifierai ce tutoriel pour utiliser la dernière version d'Elasticsearch, à savoir la 7.6. 😀

Et voilà ! J'espère que vous avez aimé. Découvrez d'autres informations en rapport à cet article avec les liens ci-dessous. Comme toujours, retours, likes et retweets sont les bienvenus. (voir la boîte ci-dessous) À tantôt ! COil. 😊

  Retour à la partie 2

  Lire la doc  Plus sur le web  Plus sur Stackoverflow

Ils m'ont donné leurs retours et m'ont aidé à corriger des erreurs et typos dans cet article, un grand merci à : greg0ire, jmsche, Nico.F (Slack Symfony). 😊

  Travaillez avec moi !


A vous de jouer !

Ces articles vous ont été utiles ? Vous pouvez m'aider à votre tour de plusieurs manières : (cf le tweet à droite pour me contacter )

  • Me remonter des erreurs ou typos.
  • Me remonter des choses qui pourraient être améliorées.
  • Aimez et retweetez !
  • Suivez moi sur Twitter
  • Inscrivez-vous au flux RSS.
  • Cliquez sur les boutons Plus sur Stackoverflow pour me faire gagner des badges "annonceur" 🏅.

Merci d'avoir tenu jusque ici et à très bientôt sur Strangebuzz ! 😉

COil