Hace dos días, hemos añadido algunos feeds para mantener a los usuarios Jobeet actualizados con los nuevos puestos de trabajo. Hoy, vamos a seguir mejorando la experiencia del usuario mediante la implementación de la última característica principal del sitio web Jobeet: el motor de búsqueda.
La Tecnología
Antes de saltar de cabeza, en primer lugar, hablemos un poco sobre la historia de Symfony. Abogamos por un montón de buenas prácticas, como pruebas y refactoring, y también tratamos de aplicarlas al framework mismo. Por ejemplo, nos gusta el famoso lema "No reinventar la rueda".
Como cuestión de hecho, el framework Symfony comenzó su vida hace cuatro años como la unión entre dos existentes softwares Open-Source: Mojavi y Propel. Y cada vez que necesitamos hacer frente a un nuevo problema, buscamos una biblioteca que haga el trabajo mucho antes de hacer la codificación uno mismo desde cero.
Hoy, queremos añadir un motor de búsqueda para Jobeet, y el Zend Framework ofrece una gran biblioteca, llamada Zend Lucene, la cual es un port del bien conocido proyecto Java Lucene. En lugar de crear un nuevo motor de búsqueda para Jobeet, lo cual es una tarea compleja, vamos a utilizar Zend Lucene.
En la página de la documentación de Zend Lucene, la biblioteca se describe de la siguiente manera:
... un motor de búsqueda textual de propósito general escrito íntegramente en PHP 5. Como almacena sus índices en el sistema de archivos y no requiere de un servidor de bases de datos, éste puede añadir capacidades de búsqueda a casi cualquier sitio web PHP. Zend_Search_Lucene soporta las siguientes características:
- Búsqueda por Ranking - mostrará al principio los mejores resultados
- Muchos tipos de consultas poderosas: consultas de tipo textual, booleaneas, wildcard por proximidad, rangos y muchas otras
- Búsqueda por un campo específico (e.g., título, autor, contenidos)
note
Este capítulo no es un tutorial sobre la biblioteca Zend Lucene, sino como integrarla en el sitio web Jobeet; o más en general, la forma de integrar bibliotecas de terceros en un proyecto symfony. Si deseas más información sobre esta tecnología, por favor visita la Documentación de Zend Lucene.
Zend Lucene ya se ha instalado ayer como parte de la instalación del Zend Framework que hicimos para el envío de mensajes de correo electrónico.
Indexación
El motor de búsqueda de Jobeet debería ser capaz de devolver todos los puestos que corresponden a palabras clave introducidas por el usuario. Antes de ser capaz de buscar cualquier cosa, un índice debe ser construído para los puestos de trabajo; para Jobeet, se almacenarán en el directorio data/
.
Zend Lucene proporciona dos métodos para recuperar un índice en función de si ya existe uno o no. Vamos a crear un método helper en la clase JobeetJobPeer
que devuelve un índice existente o crea uno nuevo para nosotros:
[php] // lib/model/JobeetJobPeer.php public function getLuceneIndex() { ProjectConfiguration::registerZend(); if (file_exists($index = $this->getLuceneIndexFile())) { return Zend_Search_Lucene::open($index); } else { return Zend_Search_Lucene::create($index); } } public function getLuceneIndexFile() { return sfConfig::get('sf_data_dir').'/job.'.sfConfig::get('sf_environment').'.index'; }
El Método save()
Cada vez que se crea un puesto de trabajo, es actualizado o borrado, el índice debe ser actualizado.
Edita JobeetJob
para actualizar el índice cada vez que un trabajo es guardado en la base de datos:
// lib/model/JobeetJob.php public function save(PropelPDO $con = null) { // ... $ret = parent::save($con); $this->updateLuceneIndex(); return $ret; }
Y crear el método updateLuceneIndex()
que hace el trabajo:
// lib/model/JobeetJob.php public function updateLuceneIndex() { $index = JobeetJobPeer::getLuceneIndex(); // remove an existing entry if ($hit = $index->find('pk:'.$this->getId())) { $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 URL to identify it in the search results $doc->addField(Zend_Search_Lucene_Field::UnIndexed('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(); }
Como Zend Lucene no es capaz de actualizar una entrada existente, ésta se elimina primero si el puesto de trabajo (job) ya existe en el índice.
La Indexación de los puestos de trabajo en sí es muy sencilla: la clave primaria se almacena para futuras referencias cuando hacemos búsquedas de puestos de trabajo y las principales columnas (position
, company
,
location
, y description
) se indexan pero no se almacena en el índice ya que vamos a utilizar los objetos reales para mostrar los resultados.
Transacciones Propel
¿Qué pasa si hay un problema cuando procede la indexación de un puesto de trabajo (job) o si el puesto de trabajo (job) no se guarda en la base de datos? Ambas herramientas Propel y Zend Lucene arrojarán una excepción. Pero en algunas circunstancias, podríamos tener un puesto de trabajo (job) guardado en la base de datos sin la correspondiente indexación. Para evitar que esto ocurra, podemos envolver los dos actualizaciones en una transacción y anularlas en caso de haber un error:
// lib/model/JobeetJob.php public function save(PropelPDO $con = null) { // ... if (is_null($con)) { $con = Propel::getConnection(JobeetJobPeer::DATABASE_NAME, Propel::CONNECTION_WRITE); } $con->beginTransaction(); try { $ret = parent::save($con); $this->updateLuceneIndex(); $con->commit(); return $ret; } catch (Exception $e) { $con->rollBack(); throw $e; } }
delete()
También tenemos que sobreescribir el método delete()
para eliminar la entrada del puesto de trabajo (job) eliminado a partir del índice:
// lib/model/JobeetJob.php public function delete(PropelPDO $con = null) { $index = JobeetJobPeer::getLuceneIndex(); if ($hit = $index->find('pk:'.$this->getId())) { $index->delete($hit->id); } return parent::delete($con); }
Borrado Masivo
Siempre que cargues los datos con la tarea propel:data-load
, Symfony elimina todos los registros jobs existentes llamando al método JobeetJobPeer::doDeleteAll()
. Vamos a redefinir el comportamiento por defecto para eliminar también el índice:
// lib/model/JobeetJobPeer.php public static function doDeleteAll($con = null) { if (file_exists($index = self::getLuceneIndexFile())) { sfToolkit::clearDirectory($index); rmdir($index); } return parent::doDeleteAll($con); }
Búsqueda
Ahora que tenemos todo en su lugar, puedes recargar los datos para indexarlos:
$ php symfony propel:data-load --env=dev
La tarea se ejecuta con la opción --env
ya que el índice es dependiente del entorno y el entorno por defecto para las tareas es cli
.
tip
Para usuarios tipo Unix: ya que el índice se modifica desde la línea de comandos y también desde el web, debe cambiar el índice de permisos de directorio según tu configuración: comprobar que tanto desde la línea de comandos y desde el servidor web el usuario pueda escribir el índice del directorio.
note
Puedes tener algunas advertencias acerca de la clase ZipArchive
si no tienes
la extension zip
compilada en tu PHP. Es un fallo conocido de la clase
Zend_Loader
.
Implementar la búsqueda en el frontend es pan comido. En primer lugar, crea una ruta:
job_search: url: /search param: { module: job, action: search }
Y la acción correspondiente:
// apps/frontend/modules/job/actions/actions.class.php class jobActions extends sfActions { public function executeSearch(sfWebRequest $request) { if (!$query = $request->getParameter('query')) { return $this->forward('job', 'index'); } $this->jobs = JobeetJobPeer::getForLuceneQuery($query); } // ... }
La plantilla es también muy sencilla:
// 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 búsqueda en sí misma se delega en el método getForLuceneQuery()
:
// lib/model/JobeetJobPeer.php static public function getForLuceneQuery($query) { $hits = self::getLuceneIndex()->find($query); $pks = array(); foreach ($hits as $hit) { $pks[] = $hit->pk; } $criteria = new Criteria(); $criteria->add(self::ID, $pks, Criteria::IN); $criteria->setLimit(20); return self::doSelect(self::addActiveJobsCriteria($criteria)); }
Después de obtener todos los resultados del índice Lucene, vamos a filtrar los puestos de trabajo inactivos, y limitar el número de resultados a 20
.
Para que funcione, actualiza el layout:
// 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 define un lenguaje de consulta poderoso que soporta operaciones como Booleans, wildcards, fuzzy search,y mucho más. Todo está documentado en el Zend Lucene manual
Las Pruebas Unitarias
¿Qué tipo de Pruebas unitarias tenemos que crear para probar el motor de búsqueda? Evidentemente, no probaremos la biblioteca Zend Lucene en sí, sino su integración con la clase JobeetJob
.
Añade las siguientes pruebas al final del archivo JobeetJobTest.php
y no te olvides de actualizar el número de pruebas al principio del archivo a 7
:
// test/unit/model/JobeetJobTest.php $t->comment('->getForLuceneQuery()'); $job = create_job(array('position' => 'foobar', 'is_activated' => false)); $job->save(); $jobs = JobeetJobPeer::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 = JobeetJobPeer::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 = JobeetJobPeer::getForLuceneQuery('position:foobar'); $t->is(count($jobs), 0, '::getForLuceneQuery() does not return deleted jobs');
Probamos que ningun puesto de trabajo inactivo, o borrado aparezca en los resultados de la búsqueda; también comprobamos que los puestos se corresponden al criterio dado para aparecer en los resultados.
Las Tareas
Finalmente, tenemos que crear una tarea de limpieza para el índice de todos los registros obsoletos (cuando expira un puesto, por ejemplo,) y optimizar el índice de vez en cuando. Como ya tenemos una tarea de limpieza, vamos a actualizarla para añadirle esas características:
// lib/task/JobeetCleanupTask.class.php protected function execute($arguments = array(), $options = array()) { $databaseManager = new sfDatabaseManager($this->configuration); // cleanup Lucene index $index = JobeetJobPeer::getLuceneIndex(); $criteria = new Criteria(); $criteria->add(JobeetJobPeer::EXPIRES_AT, time(), Criteria::LESS_THAN); $jobs = JobeetJobPeer::doSelect($criteria); foreach ($jobs as $job) { if ($hit = $index->find('pk:'.$job->getId())) { $hit->delete(); } } $index->optimize(); $this->logSection('lucene', 'Cleaned up and optimized the job index'); // Remove stale jobs $nb = JobeetJobPeer::cleanup($options['days']); $this->logSection('propel', sprintf('Removed %d stale jobs', $nb)); }
La tarea remueve todos los puestos de trabajo vencidos del índice y, a continuación, se lo optimiza gracias al método nativo optimize()
de Zend Lucene.
Nos vemos mañana
Hoy, hemos implementado un completo motor de búsqueda con muchas características en menos de una hora. Cada vez que desees añadir una nueva función para tus proyectos, debes comprobar que todavía no se haya resuelto en algún otro lugar. En primer lugar, comprobar si es algo que no se haya implementado de forma nativa en el framework Symfony. Luego, busca en los plugins symfony. Y no olvides visitar las bibliotecas del Zend Framework y los ezComponent también.
Mañana, vamos a utilizar algunos JavaScripts para mejorar la capacidad de respuesta del motor de búsqueda mediante la actualización de los resultados en tiempo real cuando el usuario escribe en el cuadro de búsqueda. Por supuesto, esta será la ocasión para hablar acerca de cómo utilizar AJAX con Symfony.
Feedback
tip
Este capítulo ha sido traducido por Roberto Germán Puentes Díaz. Si encuentras algún error que deseas corregir o realizar algún comentario, no dudes en enviarlo por correo a puentesdiaz [arroba] gmail.com
This work is licensed under the Creative Commons Attribution-Share Alike 3.0 Unported License license.