Intégrer Solr à Symfony

105/365... hum! it's elemental Mr. Watson!

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

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
    // https://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

Pipe and Chin

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 !