Embauchez-moi

Je suis freelance ! Besoin d'un professionnel du développement web ? Pourquoi ne pas me passer un coup de fil ?

Plus d'infos sur… http://thibault.jouannic.fr

mots-cles : Ingénieur web freelance Symfony eZ Publish Solr

Intégrer Solr à Symfony

Salut la compagnie. Aujourd’hui, je vous propose un petit tutoriel ayant pour sujet : comment intégrer Solr à Symfony. Commençons par présenter nos différents combattants.

À ma gauche, Symfony, framework PHP que j’ai suffisamment présenté pour qu’il soit nécessaire de me répéter.

À ma droite, Solr (prononcez « solar »), un moteur de recherche libre, extrêmement puissant et aux multiples fonctionnalités, codé en java, basé sur la bibliothèque Lucène. Houla ! — me direz vous ! Mais s’il est en java, comment peut-il fonctionner avec notre framework préféré ? Et bien c’est parce que Solr fournit une api en HTTP/XML. On lui crache du xml, et il nous renvoie du xml (en fait, il peut même nous renvoyer du php, comme nous le verrons).

Si vous avez suivi l’excellent tutoriel jobeet, vous aurez remarqué qu’il existe déjà un tuto portant sur la mise en place d’un moteur de recherche en utilisant le composant Search du zend framework.

Ledit composant est tout simplement une réécriture en php de lucène. C’est la première méthode que j’ai employé lorsqu’il m’a fallu mettre en place un moteur de recherche pour un projet perso. L’avantage de cette méthode est qu’elle est particulièrement simple et rapide à mettre en œuvre. On installe le zend framework, on copie-colle quelques lignes du tuto, et hop ! On à un moteur étonnament puissant.

Tout allait bien tant que le nombre d’objets indexés restait raisonnable (quelques centaines). Mais quelques jours plus tard, la base contenait plus de 60000 documents et la moindre recherche entraînait un vautrage éhonté du serveur pour manque de ressource. Il m’a donc fallu trouver une solution alternative, et j’ai fini par tester Solr, qui à mon grand contentement donne d’excellents résultats.

Ainsi, avec Solr, une recherche parmi 60000 documents avec tri par pertinence ne nécessite que quelques millisecondes, contre presque une minute pour Zend.

Étrangement, il ne semble pas exister de plugin tout fait. Voici donc la méthode que j’ai utilisé pour mettre en place Solr sur un projet Symfony.

Installation de Solr

Le site de solr fournit un tutoriel assez complet sur l’installation de la bête, aussi ne détaillerai-je pas l’opération plus que nécéssaire.

Commencez par récupérer l’archive de la dernière version, et décompactez là dans /opt/solr.

L’archive contient déjà un projet exemple, que nous allons dupliquer pour nous en servir comme base.

cd /opt/solr
cp -a example monprojet

Il va nous falloir configurer le schema utilisé par le moteur pour indexer les données. Disons, pour l’exemple, que notre projet symfony est un flickr-like, et que nous voulons indexer des photos avec un titre, une description et des tags.

vi /opt/solr/monprojet/solr/conf/schema.xml

Le fichier est abondamment commenté. Grosso-modo, au début, on trouve la définition des différents types de champs indexables. Ceux définis par défaut conviendront trés bien pour aujourd’hui. Ensuite, on trouve la liste des champs proprement dite.

   <!-- nos champs -->
   <field name="id" type="string" indexed="true" stored="true" required="true" />
   <field name="title" type="text" required="true" />
   <field name="description" type="text" required="false" />
 
   <!-- il peut y avoir plusieurs tags -->
   <field name="tag" type="text" required="false" multiValued="true" />
 
    <!-- Ce champ contiendra la copie de tous les autres, pour faciliter la recherche -->
   <field name="global" type="text" required="false" multiValued="true" />
 
    <!-- le champ id représente la clé -->
    <uniqueKey>id</uniqueKey>
 
    <!-- si dans une recherche on ne spécifie aucun champ, on recherche dans tous les champs -->
    <defaultSearchField>global</defaultSearchField>
 
   <!-- On copie les champs -->
   <copyField source="title" dest="global"/>
   <copyField source="description" dest="global"/>
   <copyField source="tag" dest="global"/>

Le reste du fichier peut (doit) être laissé tel quel. Nous allons activer le format de sortie php :

vi /opt/solr/monprojet/solr/conf/solrconfig.xml
<!-- décommentez les lignes suivantes -->
<queryResponseWriter name="php" class="org.apache.solr.request.PHPResponseWriter"/>
<queryResponseWriter name="phps" class="org.apache.solr.request.PHPSerializedResponseWriter"/

Et puis, pour faire bonne mesure, nous allons configurer jetty (le serveur java inclus dans l’install) pour n’écouter que sur localhost (0.0.0.0 par défaut, pas trés sécure).

vi /opt/solr/monprojet/etc/jetty.xml
   <Call name="addConnector">
      <Arg>
          <New class="org.mortbay.jetty.bio.SocketConnector">
            <!-- Ajoutez cette ligne -->
            <Set name="Host"><SystemProperty name="jetty.host" default="localhost"/></Set>
            <Set name="port"><SystemProperty name="jetty.port" default="8983"/></Set>
            <Set name="maxIdleTime">50000</Set>
            <Set name="lowResourceMaxIdleTime">1500</Set>
          </New>
      </Arg>
    </Call>

Et voilà ! Nous sommes maintenant prêts à lancer le bouzin :

cd /opt/solr/monprojet
java -jar start.jar

Pour info, solr place son index dans /opt/solr/monprojet/solr/data/index. Si l’index n’existe pas, il le créé automatiquement au démarrage. Ce qui signifie que si vous souhaitez supprimer votre index pour repartir à zéro, il vous suffit de virer le répertoire en question, et relancer solr.

Pour vérifier que Solr est bien lancé, connectez vous sur http://localhost:8983/solr/admin/ (si c’est une installation locale).

L’intégration dans Symfony

Sherlock Holmes outside Baker Street underground station
Creative Commons License photo credit : gregwake

Ok, passons à l’intégration dans Symfony. Il nous faut plusieurs choses :

  • D’abord, quand nous ajoutons une photo dans la base, nous voulons qu’elle soit automatiquement indexée.
  • Quand nous la supprimons, nous voulons qu’elle soit… désindexée (bravo ceux qui suivent).
  • Nous voulons avoir un beau moteur de recherche intégré à notre site (avec pagination des résultats, ça serait cool)
  • En bonus, étant donné que nous partons d’une base existante, une petite tâche qui permettrait d’indexer d’un coup toutes les photos du site ne serait pas du luxe.

Ça fait pas mal de choses, non ? Et bien, soyez heureux, car dans mon immense bonté, ces choses, je vais vous les servir sur un plateau ! Oui Monsieur ! Et gratuitement en plus ! C’est-y-pas la belle vie, ça ?

Indexation, desindexation, réindexation

La communication avec Solr se fait via des transferts de XML en HTTP. Rien de bien complexe, mais il existe déjà des classes toutes faites pour faciliter les choses, alors ne nous privons pas. La bibliothèque dont je parle peut être téléchargée ici.

Les fichiers de l’archives peuvent être placées dans /var/www/monprojet/lib/vendor/solr (par exemple). Au passage, j’ai viré tous les fichiers inutiles pour ne conserver que l’essentiel.

cd /var/www/monprojet
ls lib/vendor/solr/
# Document.php  Response.php  Service  Service.php
symfony cc

Nous allons appliquer une petite modif, pour permettre à la librairie d’utiliser le formatage des réponses en php par solr.

vi lib/vendor/solr/Service.php
// Modifiez ici :
const SOLR_VERSION = '1.3';
// et ici :
const SOLR_WRITER = 'phps';

Parfait. Passons à l’intégration proprement dite. J’utilise Doctine pour le projet en question. Je vous donne donc le code correspondant à cet ORM, mais j’imagine que le travail d’adaptation pour faire fonctionner ça avec Propel est assez minime.

Nous allons éditer les fichiers du modèle pour activer l’indexation :

// lib/model/doctrine/PhotoTable.class.php
 
// Cette fonction renvoie une instance de connexion vers Solr
public function getSolrService()
{
    $host = sfConfig::get('app_solr_host', 'localhost');
    $port = sfConfig::get('app_solr_port', '8983');
    $url = sfConfig::get('app_solr_url', '/solr');
    $solr = new Apache_Solr_Service($host, $port, $url);
 
    if(!$solr->ping())
        throw new Exception('Search is not available right now.');
 
    return $solr;
}
 
// lib/model/doctrine/Photo.class.php
 
// Un appel à cette fonction indexe la photo
public function updateLuceneIndex()
{
    $solr = $this->getTable()->getSolrService();
 
    $document = new Apache_Solr_Document();
    $document->addField('id', $this->getId());
 
    // On donne un poids plus important au titre
    $document->addField('title', $this->getTitle(), 1.2);
    $document->addField('description', $this->getDescription());
 
    // getTags() renvoie un bête tableau
    foreach($this->getTags() as $tag)
        $document->setMultiValue('tag', $tag);
 
    $solr->addDocument($document);
    $solr->commit();
}
 
// Réindexation après une création / modification
public function postSave($event)
{
    $this->updateLuceneIndex();
}
 
// Désindexation après une suppression
protected function postDelete($event)
{
    $solr = $this->getTable()->getSolrService();
    $solr->deleteById($this->getId());
}

Insérez / modifiez quelques photos, qui devraient s’indexer correctement. Pour le vérifier, passons au moteur de recherche.

Le moteur de recherche

Il existe grosso-modo deux méthodes : première solution, les champs à afficher dans les résultats du moteur de recherche sont stockés directement dans l’index (stored=  »true  » dans le schema.xml), auquel cas il vous suffit de les cracher tels quels. La deuxième solution, celle qui est utilisée dans le tutoriel jobeet et que je vous présente ici, consiste à utiliser solr pour récupérer les ids, et à lancer une requête via votre ORM derrière.

J’ai choisi la deuxième solution car je partais d’une base existante, et je voulais modifier mon code le moins possible, mais le premier choix est certainement meilleur en terme de perfs. À vous de voir.

// app/frontend/module/photo/actions/actions.class.php
 
public function executeSearch(sfWebRequest $request)
{
    // On récupère la requête soumise
    // Pour la syntaxe, voir là bas :
    // http://lucene.apache.org/java/2_3_2/queryparsersyntax.html
    $q = $request->getParameter('q');
 
    // À partir de cette requête, on forge un objet Doctrine_Query
    $query = Doctrine::getTable('Photo')->getSearchQuery($q);
 
    // Ensuite, vous en faites ce que vous voulez
    // De la pagination, par exemple
    // http://www.miximum.fr/tutos/186-la-pagination-avec-doctrine
}
 
// lib/model/doctrine/PhotoTable.class.php
public function getSearchQuery($queryString, $maxHits = 0)
{
    // He ! On peut déjà limiter le nombre de résultats retournés. Cool, non ?!
    if($maxHits == 0)
        $maxHits = sfConfig::get('app_solr_max_hits', 256);
 
    $offset = 0;
    $solr = $this->getSolrService();
    $response = $solr->search($queryString, $offset, $maxHits);
 
    // Solr nous renvoie directement du php, vous vous rappelez ?
    $response = unserialize($response->getRawResponse());
 
    // On se retrouve donc avec une liste de résultas sous forme d'un tableau d'ids
    $pks = array();
    foreach($response['response']['docs'] as $doc)
    {
        $pks[] = $doc['id'];
    }
 
    $query = $this->createQuery('p');
        ->select('p.*');
 
    if(empty($pks))
        // pas de résultat
        $query->whereIn('p.id', -1);
    else
    {
        $query->whereIn('p.id', $pks);
        // Ça, c'est pour conserver le tri par pertinence. Voir là bas :
        // http://groups.google.com/group/symfony-users/browse_thread/thread/92adb0332dfe1065/ee7b8c0d27208368?lnk=gst&q=zend+search+sort#ee7b8c0d27208368
        $query->addSelect('FIELD(p.id,' . implode(',', $pks) . ') AS field');
        $query->orderBy('field');
    }
    return $query;
}

Et nous voilà avec un moteur viable. Libre à vous de le perfectionner à votre convenance ensuite.

Réindexation complète du site

Vos documents s’indexent maintenant automatiquement, et vous disposez d’un moteur de recherche. Pour le moment, cependant, les photos préexistentes qui n’ont pas été modifiées ne sont pas réindexées. Je vous donne donc une tâche qui vous permettra de résoudre ce problème, et d’indexer d’un coup toutes les photos du site.

Il devrait⁻être possible, normalement, d’exporter les données sous forme de xml via je ne sais quel dump, pour les envoyer directement à Lucène. Auquel cas, l’indexation ne prendrais que quelques secondes. Perso, j’ai bricolé vite fait un truc qui parcourt tous les objets, et qui les réindexe un par un, ce qui est beaucoup moins efficace (mais c’est fait à la va vite).

De plus, il est impossible, si votre base contient un grand nombre d’objet, de tous les récupérer et de boucler dessus, car le garbage collector de php n’est pas encore assez puissant pour le permettre (vivement la 5.3). J’ai donc également un script en shell, qui lance plusieurs fois de suite la symfony task.

# lib/task/reindex.sh
#! /bin/bash
 
# on vide l'index d'abord
php symfony video:reset-index --env=prod
 
result=0
i=0
while [ $result -eq 0 ] ; do
    let offset=$i*50
    php symfony photo:reindex --env=prod 50 $offset
    result=$?
    let i=$i+1
done
// lib/task/resetIndexTask.class.php
// ...
// Supprime tous les documents de l'index
protected function execute($arguments = array(), $options = array())
{
    // initialize the database connection
    $databaseManager = new sfDatabaseManager($this->configuration);
    $connection = $databaseManager->getDatabase($options['connection'] ? $options['connection'] : null)->getConnection();
 
    $this->logSection('lucene', 'Reset index');
 
    $solr = Doctrine::getTable('Photo')->getSolrService();
    $solr->deleteByQuery('*:*');
    $solr->commit();
}
 
// lib/task/indexTask.class.php
 
// ...
protected function configure()
{
    $this->addArguments(array(
                new sfCommandArgument('limit', sfCommandArgument::REQUIRED, 50),
                new sfCommandArgument('offset', sfCommandArgument::REQUIRED, 50),
    ));
    // ...
}
 
protected function execute($arguments = array(), $options = array())
{
    // initialize the database connection
    $databaseManager = new sfDatabaseManager($this->configuration);
    $connection = $databaseManager->getDatabase($options['connection'] ? $options['connection'] : null)->getConnection();
 
    $nbPhotos =  Doctrine::getTable('Photo')->createQuery('p')
        ->count();
 
    $offset = $arguments['offset'];
    $limit = $arguments['limit'];
    $log = sprintf('indexing from %d to %d on %d photos', $offset, $offset + $limit, $nbPhotos);
    $this->logSection('lucene', $log);
 
    $q = Doctrine::getTable('Photo')->createQuery('p')
        ->limit($limit)
        ->offset($offset);
    $photos = $q->execute();
 
    foreach($photos as $photo)
    {
        $photo->updateLuceneIndex();
        echo '.';
    }
 
    $this->printLoading($nbPhotos, $limit, $offset);
 
    return ($offset + $limit < $nbPhotos) ? 0 : 1;
}
 
protected function printLoading($nbPhotos, $limit, $offset)
{
    printf(" %.2f %%\n", (($limit + $offset) / $nbPhotos) * 100);
}

Vous pouvez lancer la bête comme ça :

su - www-data -c "cd /var/www/monprojet && ./lib/task/reindex.sh"

Méga Bonus

Pfiou ! Ben voilà, ça sera tout, me direz vous ! Et bien non, messieurs dames ! Car ici, on aime les lecteurs, alors on leur en donne toujours plus. En bonus, donc, voici les scripts debian de gestion du démon solr (pour éviter de le lancer à la main à chaque reboot) ! Note : je les ai honteusement empruntés au projet eZ Publish.

# /etc/init.d/solr
 
#! /bin/sh
 
set -e
 
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
DESC="Solr indexing server"
NAME=solr
SOLR_HOME=/opt/solr/monprojet
DAEMON=$SOLR_HOME/$NAME.sh
PIDFILE=/var/run/$NAME.pid
SCRIPTNAME=/etc/init.d/$NAME
 
# Gracefully exit if the package has been removed.
test -x $DAEMON || exit 0
 
# Read config file if it is present.
if [ -r /etc/default/$NAME ]
then
    . /etc/default/$NAME
fi
 
#
#   Function that starts the daemon/service.
#
d_start() {
    start-stop-daemon --start --pidfile $PIDFILE \
                --chdir $SOLR_HOME --background --make-pidfile \
        --exec $DAEMON
}
 
#
#   Function that stops the daemon/service.
#
d_stop() {
    start-stop-daemon --stop --quiet --pidfile $PIDFILE \
        --name java
        rm -f $PIDFILE
}
case "$1" in
  start)
    echo -n "Starting $DESC: $NAME"
    d_start
    echo "."
    ;;
  stop)
    echo -n "Stopping $DESC: $NAME"
    d_stop
    echo "."
    ;;
  restart|force-reload)
    echo -n "Restarting $DESC: $NAME"
    d_stop
    sleep 1
    d_start
    echo "."
    ;;
  *)
    echo "Usage: $SCRIPTNAME {start|stop|restart|force-reload}" >&2
    exit 1
    ;;
esac
 
exit 0
# /opt/solr/monprojet/solr.sh
 
#!/bin/bash
 
for JAVA in "$JAVA_HOME/bin/java" "/usr/bin/java" "/usr/local/bin/java"
do
  if [ -x $JAVA ]
  then
    break
  fi
done
 
if [ ! -x $JAVA ]
then
  echo "Unable to locate java. Please set JAVA_HOME environment variable."
  exit
fi
 
# start solr
exec $JAVA -jar start.jar

Et on active tout ça par un :

update-rc.d solr defaults
/etc/init.d/solr start

Ouf et re-ouf ! Ça devrait suffire pour le moment. Vous voilà maintenant avec une installation basique d’un excellent moteur de recherche, capable d’indexer des dizaines de milliers de documents, et de faire de recherches dedans avec des performances déconcertantes. Quand à moi, je vais allez me passer les doigts sous l’eau froide. À la prochaine !


20 Commentaires

  1. Posté le 13/05/2009 à 22:56 | Permalien

    Sympa ! Effectivement que Sphynx et Lucene en plugin Symfony. Tu nous ponds un plugin ? ;-)

    Tu saurais dire comment le serveur d’indexation consomme en ressources système ?

  2. Daniel
    Posté le 14/05/2009 à 05:12 | Permalien

    Please post it in english, this is very useful

  3. Posté le 14/05/2009 à 09:19 | Permalien

    Ah oui depuis le temps que je cherchais une vrai alternative en moteur de recherche.

    Merci : )

  4. Posté le 14/05/2009 à 10:03 | Permalien

    tient ça me rappelle des trucs (ça commence par eZ et ça finit par Find :p) Je suis étonné que Zend_Lucene ait des problèmes avec seulement 60 000 contenus !

    @Oncle Tom : le principal problème est que Solr nécessite toute l’artillerie Java, ce qui fait qu’il faut avoir pas mal de RAM sur le serveur surtout si tu as beaucoup de contenu… Mais à côté de ça, c’est hyper puissant. On est à des années lumières de ce que tu pourras faire à coup de SELECT LIKE (pertinence, facette, pondération, …)

  5. Posté le 14/05/2009 à 10:12 | Permalien

    Excellent tutorial. On en voit pas beaucoup des comme ça pour symfony, chapeau. Y’aurait-il moyen d’obtenir une version PDF de ton tutoriel ou pas ?

  6. Posté le 15/05/2009 à 15:01 | Permalien

    Intéressant, comme tu utilises Doctrine, as-tu testé la Behavior Searchable de doctrine ?

    Et si oui qu’en penses-tu ?

    Un grand merci pour cet article, fort intéressant (je dois avouer que j’ai pas encore tout lu)

  7. Posté le 15/05/2009 à 15:22 | Permalien

    @elbouillon : le behavior Searchable de Doctrine ne permet d’indexer que du contenu textuel alors que Solr est capable d’indexer du contenu présent dans des documents Word, PDF, texte… Par conséquent, tu peux indexer l’ensemble des contenus de ton application et appliquer des tris par pertinence beaucoup plus facilement qu’avec une base de données.

  8. Posté le 17/05/2009 à 00:26 | Permalien

    @hugo merci pour cette réponse, je comprend mieux l’intérêt maintenant

  9. mike
    Posté le 17/05/2009 à 14:07 | Permalien

    Bonjour,

    Excellent article, même si je couche plutôt avec CakePHP que Synfony (les coups & les douleurs…).

    Ca n’est pas l’objet de l’article, et j’en suis conscient, mais quel est ton avis entre Solr, Sphynx, mngosearch, xipian et les autres moteurs du genre ?
    Un article à ce sujet contenterait certainement bcp de monde… :)

  10. Posté le 17/05/2009 à 17:39 | Permalien

    Damien > 60000 contenus, c’est peu, mais dans mon cas particulier, les recherches sont trés peu discriminantes. C’est à dire que chaque recherche retourne rarement moins que plusieurs centaines de résultats. Ceci explique peut-être cela.

    Mike > Solr est un moteur de recherche multi-fonctionalité, tandis que sphynx est spécialisé dans la recherche full-text pour de gros thésaurus (il me semble). Pour les autres, je ne sais pas.

  11. Posté le 15/06/2009 à 23:09 | Permalien

    Très intéressant, je m’étais confronté au même problème avec Zend_Search_Lucene avec un index conséquent. Solr tient mieux la monté en charge, c’est certain mais nécessite un environnement Java, à reserver donc pour des sites importants. Pour des petits site ou des index très spécialisé, avec peu d’articles, la solution de Zend me parrait plutot pas mal.

  12. Posté le 22/07/2009 à 09:37 | Permalien

    Je suis tombé sur ce tutoriel complètement par hasard alors que je vais justement avoir besoin d’intégrer Solr à symfony d’ici peu… Alors… merci ! :)

  13. Posté le 12/08/2009 à 13:16 | Permalien

    Hello, je me suis beaucoup inspiré de ton tutoriel et j’ai pondu un plugin : uvmcSolrSearchPlugin. J’ai transformé 2/3 trucs afin qu’il réponde mieux à mes besoins. Merci en tout cas ! ;-)

  14. Posté le 30/08/2009 à 23:57 | Permalien

    J’y crois pas, j’ai cherché plusieurs heures comment intégrer Lucene à Symfony sans tomber sur ce blog. Maintenant que j’ai installé Solr et que je cherche «  solr reload schema  » dans google je trouve ce que je cherchais il y a trois jours. Elle est pas belle la vie ?

    Merci pour ce post très riche, très complet, il y a du travail derrière.

  15. Posté le 29/09/2009 à 11:55 | Permalien

    Bonjour,

    Merci pour ce tuto, je suis en train de le suivre.
    Est-il possible de faire des recherches par mots-clés et avec des critères (prix, catégorie). Je compte intégrer ce système pour des recherches de produit sur un gros site e-commerce.
    Merci beaucoup

  16. Posté le 29/09/2009 à 16:43 | Permalien

    Ok je commence à comprendre le fonctionnement donc évidement la recherche par critère est possible.
    Je suis en train aussi d’intégrer une indexation et une recherche avec des documents en 7 langues (dont arabe, chinois, japonais et russe !). J’espère que ça fonctionnera bien, si ça fonctionne pas bien dans les alphabets non latin c’est pas trop grave.

    C’est vraiment un super tutos, merci bp !

  17. Janneaa
    Posté le 26/10/2009 à 13:14 | Permalien

    Thanks for this tutorial ! I was able to read most of it with Google Translate.

    However I’m having problems with displaying the search results. All I get back is a Doctrine_Query object. What should I do with it to display the results properly ?

    Thanks,
    Janneaa

  18. Posté le 28/10/2009 à 13:30 | Permalien

    Une petite question, j’aimerai rechercher sur mon site et sur d’autres sites. Pour cela, j’ai déjà un crawler qui crache du xml au format Solr. Ensuite l’index est créé via ces xml, mais donc pour indexer ma base de donnée, si j’ai bien compris, je ne suis pas obliger de passer par ces fichiers xml ?

  19. Posté le 15/01/2010 à 09:39 | Permalien

    Petit tuto bien sympa…
    J’ai intégré SolR a http://www.direct-affaires.fr (petites annonces donc demandes en recherches très fortes) seul problème avec les scripts de commnication que tu utilises est qu’il manque l’implémentation de la récupération des facettes (chose qui m’a été utile).
    En tout cas, cette architecture permet de faire des projets avec un très grand nombre de résultat sans que les perfs en soient impactées…. c’est le top

  20. Posté le 15/01/2010 à 10:37 | Permalien

    Tiens, ça me dit quelque chose ce site :)

Envie de vous exprimer ?

Votre email n'est jamais affiché. Votre commentaire ne sera pas affiché non plus s'il est bourré de fautes ou de liens publicitaires. Vous êtes prévenu.

*
*