Caution: You are browsing the legacy symfony 1.x part of this website.

Jour 17 : La recherche

1.4 / Doctrine
Symfony version
1.2
Language ORM

Il y a deux jours, nous avons ajouté certains flux pour tenir au courant les utilisateurs de Jobeet pour les nouveaux emplois. Aujourd'hui, nous allons continuer à améliorer l'expérience utilisateur en implémentant la dernière caractéristique principale du site web Jobeet : le moteur de recherche.

La technologie

Avant de plonger la tête la première, parlons un peu de l'histoire de symfony. Nous plaidons pour un grand nombre de bonnes pratiques, comme les tests et la refactorisation, et nous essayons aussi de les appliquer framework lui-même. Par exemple, nous aimons la fameuse devise "Ne pas réinventer la roue". En fait, le framework symfony a commencé sa vie il y a quatre ans comme colle entre les deux logiciels existants Open Source : Mojavi et Propel. Et chaque fois que nous avons besoin d'affronter un nouveau problème, nous cherchons une bibliothèque existante qui fait bien le travail avant de le coder nous mêmes à partir de zéro.

Aujourd'hui, nous voulons ajouter un moteur de recherche à Jobeet, et le Zend Framework fournit une grande bibliothèque, appelée Zend Lucene, qui est un portage du projet bien connu Java Lucene. Au lieu de créer encore un autre moteur de recherche pour Jobeet, ce qui est une tâche complexe, nous allons utiliser Zend Lucene.

Sur la page de documentation de Zend Lucene, la bibliothèque est décrite comme suit:

... est un moteur de recherche de contenus principalement textuels écrit entièrement en PHP 5. Comme il stocke ses index sur le système de fichiers et qu'il ne requiert pas de base de données, il peut offrir des fonctionnalités de recherche à presque n'importe quel site écrit en PHP. Zend_Search_Lucene dispose des caractéristiques suivantes :

  • Ranked searching - les meilleurs résultats sont retournés en premier.
  • Plusieurs puissants types de requêtes : phrase, booléen, joker (wildcard), proximité, intervalle et bien d'autres.
  • Recherche par champ spécifique (par exemple titre, auteur, contenus)

note

Ce chapitre n'est pas un tutoriel sur la bibliothèque de Zend Lucene, mais comment l'intégrer dans le site Web Jobeet; ou, plus généralement, comment intégrer les bibliothèques tierces dans un projet symfony. Si vous souhaitez plus d'informations sur cette technologie, référez vous, s'il vous plaît, à la documentation de Lucene Zend.

Installation et configuration du Zend Framework

La bibliothèque Zend Lucene fait partie du Zend Framework. Nous installerons le Zend Framework dans le répertoire lib/vendor/, à côté du framework symfony lui-même.

D'abord, téléchargez le Zend Framework et décompressez les fichiers afin d'avoir un répertoire lib/vendor/Zend/.

note

Les explications suivantes ont été testé avec la version 1.9 du Zend Framework.

tip

Vous pouvez nettoyer le répertoire en enlevant tout sauf les fichiers et les répertoires suivants :

  • Exception.php
  • Loader/
  • Autoloader.php
  • Search/

Puis, ajoutez le code suivant à la classe ProjectConfiguration pour fournir un moyen simple d'enregistrer le chargeur automatique de Zend :

// config/ProjectConfiguration.class.php
class ProjectConfiguration extends sfProjectConfiguration
{
  static protected $zendLoaded = false;
 
  static public function registerZend()
  {
    if (self::$zendLoaded)
    {
      return;
    }
 
    set_include_path(sfConfig::get('sf_lib_dir').'/vendor'.PATH_SEPARATOR.get_include_path());
    require_once sfConfig::get('sf_lib_dir').'/vendor/Zend/Loader/Autoloader.php';
    Zend_Loader_Autoloader::getInstance();
    self::$zendLoaded = true;
  }
 
  // ...
}

Indexation

Le moteur de recherche Jobeet devrait être en mesure de restituer tous les emplois correspondants à des mots-clés entrés par l'utilisateur. Avant d'être en mesure de faire quoi que ce soit pour la recherche, un index doit être construit pour les emplois; pour Jobeet, il sera stocké dans le répertoire data/.

Zend Lucene fournit deux méthodes pour récupérer un index selon si celle-ci existe déjà ou non. Nous allons créer une méthode helper dans la classe JobeetJobTable qui retourne un index existant ou en crée un nouveau pour nous :

// lib/model/doctrine/JobeetJobTable.class.php
static public function getLuceneIndex()
{
  ProjectConfiguration::registerZend();
 
  if (file_exists($index = self::getLuceneIndexFile()))
  {
    return Zend_Search_Lucene::open($index);
  }
  else
  {
    return Zend_Search_Lucene::create($index);
  }
}
 
static public function getLuceneIndexFile()
{
  return sfConfig::get('sf_data_dir').'/job.'.sfConfig::get('sf_environment').'.index';
}

La méthode save()

Chaque fois qu'un emploi est créé, modifié ou supprimé, l'index doit être mis à jour. Modifiez JobeetJob pour mettre à jour l'index à chaque fois qu'un emploi est sérialisé dans la base de données :

public function save(Doctrine_Connection $conn = null)
{
  // ...
 
  $ret = parent::save($conn);
 
  $this->updateLuceneIndex();
 
  return $ret;
}

Et créez la méthode updateLuceneIndex() qui fait tout le boulot :

// lib/model/doctrine/JobeetJob.class.php
public function updateLuceneIndex()
{
  $index = JobeetJobTable::getLuceneIndex();
 
  // remove existing entries
  foreach ($index->find('pk:'.$this->getId()) as $hit)
  {
    $index->delete($hit->id);
  }
 
  // don't index expired and non-activated jobs
  if ($this->isExpired() || !$this->getIsActivated())
  {
    return;
  }
 
  $doc = new Zend_Search_Lucene_Document();
 
  // store job primary key to identify it in the search results
  $doc->addField(Zend_Search_Lucene_Field::Keyword('pk', $this->getId()));
 
  // index job fields
  $doc->addField(Zend_Search_Lucene_Field::UnStored('position', $this->getPosition(), 'utf-8'));
  $doc->addField(Zend_Search_Lucene_Field::UnStored('company', $this->getCompany(), 'utf-8'));
  $doc->addField(Zend_Search_Lucene_Field::UnStored('location', $this->getLocation(), 'utf-8'));
  $doc->addField(Zend_Search_Lucene_Field::UnStored('description', $this->getDescription(), 'utf-8'));
 
  // add job to the index
  $index->addDocument($doc);
  $index->commit();
}

Comme Zend Lucene n'est pas en mesure de mettre à jour une entrée existante, elle est d'abord enlevée si le poste existe déjà dans l'index.

L'indexation de l'emploi lui-même est simple : la clé primaire est stockée pour un référencement ultérieur lors de la recherche d'emplois et les colonnes principales (position, company, location et description) sont indexeés, mais pas stockeés dans l'index car nous allons utiliser les objets réels pour afficher les résultats.

Transactions de Doctrine

Et si il y a un problème lors de l'indexation d'un emploi ou si l'emploi n'est pas enregistré dans la base de données ? Doctrine et Zend Lucene lèveront une exception. Mais, dans certaines circonstances, nous pourrions avoir un emploi enregistré dans la base de données sans l'indexation correspondante. Pour éviter cela, on peut envelopper les deux mises à jour dans une transaction et faire rollback en cas d'erreur :

// lib/model/doctrine/JobeetJob.class.php
public function save(Doctrine_Connection $conn = null)
{
  // ...
 
  $conn = $conn ? $conn : JobeetJobTable::getConnection();
  $conn->beginTransaction();
  try
  {
    $ret = parent::save($conn);
 
    $this->updateLuceneIndex();
 
    $conn->commit();
 
    return $ret;
  }
  catch (Exception $e)
  {
    $conn->rollBack();
    throw $e;
  }
}

delete()

Nous avons besoin aussi de surcharger la méthode delete() pour supprimer l'entrée de l'emploi supprimé de l'index :

// lib/model/doctrine/JobeetJob.class.php
public function delete(Doctrine_Connection $conn = null)
{
  $index = JobeetJobTable::getLuceneIndex();
 
  foreach ($index->find('pk:'.$this->getId()) as $hit)
  {
    $index->delete($hit->id);
  }
 
  return parent::delete($conn);
}

Recherche

Maintenant que nous avons tout en place, vous pouvez recharger les données de test pour les indexer :

$ php symfony doctrine:data-load

tip

Pour les utilisateurs Unix : comme l'index est modifié à partir de la ligne de commande et aussi à partir du web, vous devez changer en conséquence les droits du répertoire de l'index en fonction de votre configuration : vérifiez que la ligne de commande utilisateur et le serveur web peuvent écrire dans le répertoire de l'index.

note

Vous pouvez avoir quelques avertissements sur la classe ZipArchive si vous n'avez pas l'extension zip compilé dans votre PHP. C'est un bug connu de la classe Zend_Loader.

L'implémentation de la recherche dans le frontend, c'est du gâteau. Tout d'abord, créer une route :

job_search:
  url:   /search
  param: { module: job, action: search }

Et l'action correspondante :

// apps/frontend/modules/job/actions/actions.class.php
class jobActions extends sfActions
{
  public function executeSearch(sfWebRequest $request)
  {
    $this->forwardUnless($query = $request->getParameter('query'), 'job', 'index');
 
    $this->jobs = Doctrine_Core::getTable('JobeetJob') ->getForLuceneQuery($query);
  }
 
  // ...
}

note

La nouvelle méthode forwardUnless() redirige l'utilisateur vers l'action index du module job si la variable query de l'URL n'existe pas ou est vide.

Cette méthode n'est en fait qu'un simple alias pour le code suivant:

if (!$query = $request->getParameter('query')) { $this->forward('job', 'index'); }

Le Template est également assez simple :

// apps/frontend/modules/job/templates/searchSuccess.php
<?php use_stylesheet('jobs.css') ?>
 
<div id="jobs">
  <?php include_partial('job/list', array('jobs' => $jobs)) ?>
</div>

La recherche est elle-même déléguée à la méthodegetForLuceneQuery() :

// lib/model/doctrine/JobeetJobTable.class.php
public function getForLuceneQuery($query)
{
  $hits = self::getLuceneIndex()->find($query);
 
  $pks = array();
  foreach ($hits as $hit)
  {
    $pks[] = $hit->pk;
  }
 
  if (empty($pks))
  {
    return array();
  }
 
  $q = $this->createQuery('j')
    ->whereIn('j.id', $pks)
    ->limit(20);
 
  $q = $this->addActiveJobsQuery($q);
 
  return $q->execute();
}

Après avoir obtenu tous les résultats de l'index de Lucene, nous filtrons les emplois inactifs et limitons le nombre de résultats à 20.

Pour le faire fonctionner, mettez à jour la mise en page :

// apps/frontend/templates/layout.php
<h2>Ask for a job</h2>
<form action="<?php echo url_for('job_search') ?>" method="get">
  <input type="text" name="query" value="<?php echo $sf_request->getParameter('query') ?>" id="search_keywords" />
  <input type="submit" value="search" />
  <div class="help">
    Enter some keywords (city, country, position, ...)
  </div>
</form>

note

Zend Lucene définit un langage de requête riche qui prend en charge des opérations comme les booléens, les caractères génériques, la recherche floue, et bien plus encore. Tout est documenté dans le manuel de Zend Lucene

Tests unitaires

Quel genre de tests unitaires avons-nous besoin de créer pour tester le moteur de recherche? De toute évidence, nous ne testerons pas la bibliothèque Zend Lucene elle-même, mais son intégration avec la classe JobeetJob.

Ajouter les tests suivants à la fin du fichier JobeetJobTest.php et n'oubliez pas de mettre à jour le nombre de tests à 7 au début du fichier :

// test/unit/model/JobeetJobTest.php
$t->comment('->getForLuceneQuery()');
$job = create_job(array('position' => 'foobar', 'is_activated' => false));
$job->save();
$jobs = Doctrine_Core::getTable('JobeetJob')->getForLuceneQuery('position:foobar');
$t->is(count($jobs), 0, '::getForLuceneQuery() does not return non activated jobs');
 
$job = create_job(array('position' => 'foobar', 'is_activated' => true));
$job->save();
$jobs = Doctrine_Core::getTable('JobeetJob')->getForLuceneQuery('position:foobar');
$t->is(count($jobs), 1, '::getForLuceneQuery() returns jobs matching the criteria');
$t->is($jobs[0]->getId(), $job->getId(), '::getForLuceneQuery() returns jobs matching the criteria');
 
$job->delete();
$jobs = Doctrine_Core::getTable('JobeetJob')->getForLuceneQuery('position:foobar');
$t->is(count($jobs), 0, '::getForLuceneQuery() does not return deleted jobs');

Nous testons un emploi non activé ou une suppression non présente dans les résultats de la recherche, nous vérifions également que les emplois correspondants aux critères donnés s'affichent dans les résultats.

Tâches

Finalement, nous avons besoin de créer une tâche de nettoyage de l'index à partir des vieilles entrées (lorsqu'un emploi prend fin par exemple) et optimiser l'index de temps en temps. Comme nous avons déjà une tâche de nettoyage, nous allons la mettre à jour pour ajouter ces fonctionnalités :

// lib/task/JobeetCleanupTask.class.php
protected function execute($arguments = array(), $options = array())
{
  $databaseManager = new sfDatabaseManager($this->configuration);
 
  // cleanup Lucene index
  $index = JobeetJobTable::getLuceneIndex();
 
  $q = Doctrine_Query::create()
    ->from('JobeetJob j')
    ->where('j.expires_at < ?', date('Y-m-d'));
 
  $jobs = $q->execute();
  foreach ($jobs as $job)
  {
    if ($hit = $index->find('pk:'.$job->getId()))
    {
      $index->delete($hit->id);
    }
  }
 
  $index->optimize();
 
  $this->logSection('lucene', 'Cleaned up and optimized the job index');
 
  // Remove stale jobs
  $nb = Doctrine_Core::getTable('JobeetJob')->cleanup($options['days']);
 
  $this->logSection('doctrine', sprintf('Removed %d stale jobs', $nb));
}

La tâche supprime de l'index tous les emplois expirés, puis l'optimise grâce à la méthode optimize de Zend Lucene.

À demain

Aujourd'hui, nous avons implémenté un moteur de recherche complet avec de nombreuses fonctionnalités en moins d'une heure. Chaque fois que vous souhaitez ajouter une nouvelle fonctionnalité à vos projets, vérifier qu'elle n'a pas encore été faite ailleurs. Tout d'abord, vérifier si quelque chose n'est pas implémenté nativement dans le framework symfony. Ensuite, vérifiez les plugins de symfony. Et n'oubliez pas de consulter les bibliothèques du Zend Framework et les aussi ezComponent.

Demain, nous emploierons quelques Javascript discrets pour améliorer la réactivité du moteur de recherche en mettant à jour les résultats en temps réel pendant que l'utilisateur tape dans la boîte de recherche. Bien sûr, ce sera l'occasion de parler de la façon d'utiliser AJAX avec symfony.