Symfony2 - Jobeet - Jour 17 - Recherche

1 1 1 1 1 1 1 1 1 1 Rating 0.00 (0 Votes)
Submit to DeliciousSubmit to DiggSubmit to FacebookSubmit to Google PlusSubmit to StumbleuponSubmit to TechnoratiSubmit to TwitterSubmit to LinkedIn

Au jour 14 (Symfony2 - Jobeet - Jour 14 - Flux de données), nous avons ajouté des flux de données pour que les utilisateurs de Jobeet ne soient pas obligés d'aller sur le site pour voir les offres d'emplois. Aujourd'hui nous allons améliorer l'expérience utilisateur en mettant en œuvre la dernière fonctionnalité principale du site Jobeet : le moteur de recherche.

La technologie

Aujourd'hui, nous voulons ajouter un moteur de recherche pour Jobeet. Zend Framework fournit une grande bibliothèque, appelé Zend Lucene. Au lieu de créer encore un autre moteur de recherche pour Jobeet, ce qui est une tâche assez complexe, nous allons utiliser Zend Lucene.

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

Installation et configuration du Zend Framework

La bibliothèque Zend Lucene fait partie de Zend Framework. Nous allons installer uniquement le Zend Framework dans le répertoire /vendor, à côté du framework Symfony.

D'abord, téléchargez le Zend Framework et décompressez les fichiers de sorte que vous avez un répertoire vendor/Zend/.

Les explications suivantes ont été testées avec la version 1.12.7 de Zend Framework.

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

  • Exception.php
  • Loader/
  • Loader.php
  • Search/
  • Xml/

01b-116-symfony2-jobeet-jour-17-recherche

Ensuite, ajoutez le code suivant au fichier autoload.php.

  • app/autoload.php
    // ...

    set_include_path(__DIR__.'/../vendor'.PATH_SEPARATOR.get_include_path());
    require_once __DIR__.'/../vendor/Zend/Loader/Autoloader.php';
    Zend_Loader_Autoloader::getInstance();

    return $loader;

Indexage

Le moteur de recherche Jobeet devrait être en mesure de retourner tous les emplois correspondant à des mots-clés. Avant d'être en mesure de rechercher quelque chose, un index doit être construit pour les emplois, il sera stocké dans un nouveau répertoire, vous allez créer, /web/data/.

Zend Lucene fournit deux méthodes pour récupérer un index selon si il existe déjà ou non. Créons des méthodes d'aide à la classe Job entity qui retourne un index existant ou en créer un nouveau pour nous :

  •  src/Erlem/JobeetBundle/Entity/Job.php
// ...

class Job
{
    // ...

    static public function getLuceneIndex()
    {
        if (file_exists($index = self::getLuceneIndexFile())) {
            return \Zend_Search_Lucene::open($index);
        }

        return \Zend_Search_Lucene::create($index);
    }

    static public function getLuceneIndexFile()
    {
        return __DIR__.'/../../../../web/data/job.index';
    }
}

Chaque fois qu'un emploi est créé ou mis à jour, l'index doit être mis à jour. Editez le fichier ORM pour mettre à jour l'index à chaque fois qu'un emploi est ajouté/modifié/supprimé de la base de données :

  •  src/Erlem/JobeetBundle/Resources/config/doctrine/Job.orm.yml
# ...
    # ...
    lifecycleCallbacks:
        # ...
        postPersist: [ upload, updateLuceneIndex ]
        postUpdate: [ upload, updateLuceneIndex ]
        # ...

Maintenant, exécutez la commande generate:entities, de sorte que la méthode updateLuceneIndex() soit générée par Doctrine :

php app/console doctrine:generate:entities ErlemJobeetBundle

Ensuite, modifiez la méthode updateLuceneIndex() qui fait tout le boulot :

  • src/Erlem/JobeetBundle/Entity/Job.php
class Job
{
    // ...

    public function updateLuceneIndex()
    {
        $index = self::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 supprimé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 référencer ultérieurement lors de la recherche d'emplois ainsi que les colonnes principales (position, la société, l'emplacement et la description) sont indexés, mais pas stocké dans l'index car nous allons utiliser les objets réels pour afficher les résultats.

Nous devons également créer une méthode deleteLuceneIndex() pour supprimer l'entrée de la tâche supprimée de l'index. Comme nous l'avons fait pour la mise à jour, nous allons le faire pour la suppression. Commencez par ajouter la méthode deleteLuceneIndex() à la section de post-suppression du fichier ORM :

  •  src/Erlem/JobeetBundle/Resources/config/doctrine/Job.orm.yml
# ...
    # ...
    lifecycleCallbacks:
        # ...
        postRemove: [ removeUpload, deleteLuceneIndex ]

Encore une fois, exécutez la commande pour générer des entités.

php app/console doctrine:generate:entities ErlemJobeetBundle

Maintenant, allez dans le fichier de l'entité et puis mettez en œuvre la méthode deleteLuceneIndex() :

  •  src/Erlem/JobeetBundle/Entity/Job.php
class Job
{
    // ...

    public function deleteLuceneIndex()
    {
        $index = self::getLuceneIndex();

        foreach ($index->find('pk:'.$this->getId()) as $hit) {
            $index->delete($hit->id);
        }
    }
}

Comme l'indice est modifiée à partir de la ligne de commande, ainsi que sur le web, vous devez modifier les permissions du répertoire de l'index en fonction de votre configuration :

chmod -R 777 web/data

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

php app/console doctrine:fixtures:load

Le résultat :

Careful, database will be purged. Do you want to continue Y/N ?Y
  > purging database
  > loading [1] Erlem\JobeetBundle\DataFixtures\ORM\LoadCategoryData
  > loading [2] Erlem\JobeetBundle\DataFixtures\ORM\LoadJobData
  > loading [3] Erlem\JobeetBundle\DataFixtures\ORM\LoadAffiliateData
  > loading [4] Erlem\JobeetBundle\DataFixtures\ORM\LoadUserData

La recherche

L'implémentation de la recherche est assez facile. Tout d'abord, créer une route :

  • src/Erlem/JobeetBundle/Resources/config/routing/job.yml
# ...

erlem_job_search:
    pattern: /search
    defaults: { _controller: "ErlemJobeetBundle:Job:search" }

Et l'action correspondante :

  • src/Erlem/JobeetBundle/Controller/JobController.php
namespace Erlem\JobeetBundle\Controller;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Erlem\JobeetBundle\Entity\Job;
use Erlem\JobeetBundle\Form\JobType;

class JobController extends Controller
{
    // ...

    public function searchAction(Request $request)
    {
        $em = $this->getDoctrine()->getManager();
        $query = $this->getRequest()->get('query');

        if(!$query) {
            return $this->redirect($this->generateUrl('erlem_job'));
        }

        $jobs = $em->getRepository('ErlemJobeetBundle:Job')->getForLuceneQuery($query);

        return $this->render('ErlemJobeetBundle:Job:search.html.twig', array('jobs' => $jobs));
    }
}

A l'intérieur de searchAction(), l'utilisateur transmet à l'action de l'index de JobController si la requête d'interrogation n'existe pas ou est vide.

Le template est également très simple :

  •  src/Erlem/JobeetBundle/Resources/views/Job/search.html.twig
{% extends 'ErlemJobeetBundle::layout.html.twig' %}

{% block stylesheets %}
    {{ parent() }}
    <link rel="stylesheet" href={{ asset('bundles/erlemjobeet/css/jobs.css') }} type="text/css" media="all" />
{% endblock %}

{% block content %}
    <div id="jobs">
        {% include 'ErlemJobeetBundle:Job:list.html.twig' with {'jobs': jobs} %}
    </div>
{% endblock %}

La recherche elle-même délégue à la méthode getForLuceneQuery() :

  •  src/Erlem/JobeetBundle/Repository/JobRepository.php
namespace Erlem\JobeetBundle\Repository;

use Doctrine\ORM\EntityRepository;
use Erlem\JobeetBundle\Entity\Job;

class JobRepository extends EntityRepository
{
    // ...

    public function getForLuceneQuery($query)
    {
        $hits = Job::getLuceneIndex()->find($query);

        $pks = array();
        foreach ($hits as $hit)
        {
          $pks[] = $hit->pk;
        }

        if (empty($pks))
        {
          return array();
        }

        $q = $this->createQueryBuilder('j')
            ->where('j.id IN (:pks)')
            ->setParameter('pks', $pks)
            ->andWhere('j.is_activated = :active')
            ->setParameter('active', 1)
            ->setMaxResults(20)
            ->getQuery();

        return $q->getResult();
    }
}

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

Pour le faire fonctionner, mettez à jour le layout :

  •  src/Erlem/JobeetBundle/Resources/views/layout.html.twig
<!-- ... -->
    <!-- ... -->
	<div class="search">
	    <h2>Ask for a job</h2>
	    <form action={{ path('erlem_job_search') }} method="get">
		<input type="text" name="query" value='{{ app.request.get('query') }}' id="search_keywords" />
		<input type="submit" value="search" />
		<div class="help">
		    Enter some keywords (city, country, position, ...)
		</div>
	    </form>
	</div>
    <!-- ... -->
<!-- ... -->

 Faites une recherche sur Designer par exemple puis cliquez sur Search.

02-116-symfony2-jobeet-jour-17-recherche

Le résultat de la recherche :

03-116-symfony2-jobeet-jour-17-recherche

Tests unitaires

Quel genre de tests unitaires avons-nous besoin de créer pour tester le moteur de recherche ? Nous n'allons évidemment pas tester la bibliothèque Zend Lucene elle-même, mais son intégration avec la classe Job.

Ajouter le test suivant à la fin du fichier JobRepositoryTest.php :

  •  src/Erlem/JobeetBundle/Tests/Repository/JobRepositoryTest.php
// ... 
use Erlem\JobeetBundle\Entity\Job;

class JobRepositoryTest extends WebTestCase
{
    // ...

    public function testGetForLuceneQuery()
    {
        $em = static::$kernel->getContainer()
            ->get('doctrine')
            ->getManager();

        $job = new Job();
        $job->setType('part-time');
        $job->setCompany('Sensio');
        $job->setPosition('FOO6');
        $job->setLocation('Paris');
        $job->setDescription('WebDevelopment');
        $job->setHowToApply('Send resumee');
        $job->setEmail('jobeet[at]example.com');
        $job->setUrl('http://sensio-labs.com');
        $job->setIsActivated(false);

        $em->persist($job);
        $em->flush();

        $jobs = $em->getRepository('ErlemJobeetBundle:Job')->getForLuceneQuery('FOO6');
        $this->assertEquals(count($jobs), 0);

        $job = new Job();
        $job->setType('part-time');
        $job->setCompany('Sensio');
        $job->setPosition('FOO7');
        $job->setLocation('Paris');
        $job->setDescription('WebDevelopment');
        $job->setHowToApply('Send resumee');
        $job->setEmail('jobeet[at]example.com');
        $job->setUrl('http://sensio-labs.com');
        $job->setIsActivated(true);

        $em->persist($job);
        $em->flush();

        $jobs = $em->getRepository('ErlemJobeetBundle:Job')->getForLuceneQuery('position:FOO7');
        $this->assertEquals(count($jobs), 1);
        foreach ($jobs as $job_rep) {
            $this->assertEquals($job_rep->getId(), $job->getId());
        }

        $em->remove($job);
        $em->flush();

        $jobs = $em->getRepository('ErlemJobeetBundle:Job')->getForLuceneQuery('position:FOO7');

        $this->assertEquals(count($jobs), 0);
    }
}

Nous testons qu'un emploi non activé ou un emploi supprimé ne se présente pas 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.

Lancez la commande :

phpunit -c app src/Erlem/JobeetBundle/Tests/Repository/JobRepositoryTest

Tâches

Finalement, nous avons besoin de mettre à jour la tâche JobeetCleanup qui permet de nettoyer l'index à partir des entrées obsolètes (lorsqu'un emploi prend fin par exemple) et d'optimiser l'indice de temps en temps :

  • src/Erlem/JobeetBundle/Command/JobeetCleanupCommand.php
// ...
use  Erlem\JobeetBundle\Entity\Job;

class JobeetCleanupCommand extends ContainerAwareCommand
{
    // ...

    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $days = $input->getArgument('days');

        $em = $this->getContainer()->get('doctrine')->getManager();

        // cleanup Lucene index
        $index = Job::getLuceneIndex();

        $q = $em->getRepository('ErlemJobeetBundle:Job')->createQueryBuilder('j')
          ->where('j.expires_at < :date')
          ->setParameter('date',date('Y-m-d'))
          ->getQuery();

        $jobs = $q->getResult();
        foreach ($jobs as $job)
        {
          if ($hit = $index->find('pk:'.$job->getId()))
          {
            $index->delete($hit->id);
          }
        }

        $index->optimize();

        $output->writeln('Cleaned up and optimized the job index');

        // Remove stale jobs
        $nb = $em->getRepository('ErlemJobeetBundle:Job')->cleanup($days);

        $output->writeln(sprintf('Removed %d stale jobs', $nb));
    }
}

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

Le long de cette journée, nous avons mis en place un moteur de recherche complet avec de nombreuses fonctionnalités en moins d'une heure. Chaque fois que vous voulez ajouter une nouvelle fonctionnalité à vos projets, vérifier qu'elle n'a pas encore été résolue ailleurs.

Demain, nous utilisiserons le JavaScript pour améliorer la réactivité du moteur de recherche en mettant à jour les résultats en temps réel comme les types d'utilisateur dans la boîte de recherche. Bien sûr, ce sera l'occasion de parler de la façon d'utiliser AJAX avec symfony.


Symfony2 - Jour 16 - Les e-mails
[Sommaire] Symfony2 - Jour 18 - AJAX >


Pour ce tutoriel, je me suis inspiré du tutoriel Symfony2 Jobeet du site IntelligentBee

Submit to DeliciousSubmit to DiggSubmit to FacebookSubmit to Google PlusSubmit to StumbleuponSubmit to TechnoratiSubmit to TwitterSubmit to LinkedIn