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.
This work is licensed under the Creative Commons Attribution-Share Alike 3.0 Unported License license.