![]() |
|
Practical symfonyДень 17: Поиск |
|
You are currently reading "Practical symfony" which is licensed under the Creative Commons Attribution-Share Alike 3.0 Unported License license.
save() delete() 
|
This work is licensed under a Creative Commons Attribution-Share Alike 3.0 Unported License. |
Два дня назад мы добавили несколько новостных лент (feeds) чтобы пользователи Jobeet могли оставаться в курсе событий. Сегодня мы продолжим улучшать пользовательский интерфейс с помощью внедрения последней основной функциональности для веб-сайта Jobeet: поискового движка.
Перед тем как мы начнем, давайте поговорим немного об истории Symfony. Мы поддерживаем много лучших практик разработки, таких как тесты и рефакторинг, мы также стараемся применять их к самому фреймворку. Например нам нравится известный лозунг "Не изобретать велосипед". На самом деле фреймворк Symfony начал свою жизнь четыре года назад как клей между двумя существующими проектами с открытым исходным кодом: Mojavi и Propel. Каждый раз когда нам нужно было решить новую проблему, мы искали существующую библиотеку которая хорошо выполняет эту работу перед тем как писать ее самостоятельно с нуля.
Сегодня мы хотим добавить поисковый движок в Jobeet, и Zend Framework предоставляет хорошую библиотеку под названием Zend Lucene, которая портирована с хорошо известного проекта Java Lucene. Вместо создания еще одного поискового движка в рамках Jobeet, что является достаточно сложной задачей, мы будем использовать Zend Lucene.
На странице с документацией Zend Lucene библиотека описана так:
... текстовый поисковый движок общего назначения, полностью написанный на PHP 5. Поскольку индексы хранятся в файловой системе и не требуется сервер базы данных можно добавить возможности поиска практически в любой веб-сайт написанный на PHP. Zend_Search_Lucene поддерживает следующую функциональность:
- Ранжированный поиск - лучшие результаты возвращаются первыми
- Много мощных типов запросов: запросы по фразам, булевы запросы, запросы с использованием символов обобщения (wildcard), приблизительные запросы (proximity), запросы по диапазону и много других
- Поиск по указанному полю (например, название, автор, содержимое)
NOTE Эта глава не является учебником по библиотеке Zend Lucene, а показывает как интегрировать ее в веб-сайт Jobeet; или в более общем случае как интегрировать библиотеки сторонних производителей в проект на Symfony. Если Вы хотите узнать больше об этой технологии, пожалуйста обратитесь к документации по Zend Lucene.
Библиотека Zend Lucene это часть фреймворка Zend.
Мы просто установим фреймворк Zend в директорию lib/vendor/,
в которой установлен и сам фреймворк Symfony.
Сначала скачаем фреймворк Zend и разархивируем
файлы в директорию lib/vendor/Zend/.
Следующие действия были протестированы для версии 1.9 фреймворка Zend.
TIP Вы можете очистить директорию, удалив все, кроме следующих файлов и директорий:
Exception.phpLoader/Autoloader.phpSearch/
Затем, добавьте следующий код в класс ProjectConfiguration, чтобы обеспечить
простой способ, чтобы зарегистрировать автозагрузчик 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; } // ... }
Поисковый движок Jobeet должен возвращать все вакансии, соответствующие ключевым словам,
которые ввел пользователь. Для того, чтобы иметь возможность что-то найти,
нужно построить индекс вакансий; для Jobeet, он будет храниться в папке data/.
Zend Lucene предоставляет два метода для получения индекса в зависимости от того,
существует он или нет. Давайте создадим метод-помощник в классе JobeetJobTable,
который возвращает существующий индекс или создает новый для нас:
// 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'; }
save()Каждый раз при создании, обновлении или удалении вакансии индекс должен быть обновлен.
Исправим так, чтобы класс JobeetJob обновлял индекс при сохранении вакансии в базу данных:
public function save(Doctrine_Connection $conn = null) { // ... $ret = parent::save($conn); $this->updateLuceneIndex(); return $ret; }
И создадим метод updateLuceneIndex(), который будет выполнять эту работу:
// lib/model/doctrine/JobeetJob.class.php public function updateLuceneIndex() { $index = JobeetJobTable::getLuceneIndex(); // удалить существующие записи foreach ($index->find('pk:'.$this->getId()) as $hit) { $index->delete($hit->id); } // не индексировать истекшие и не активированные вакансии if ($this->isExpired() || !$this->getIsActivated()) { return; } $doc = new Zend_Search_Lucene_Document(); // сохраняем первичный ключ вакансии для идентификации ее в результатах поиска $doc->addField(Zend_Search_Lucene_Field::Keyword('pk', $this->getId())); // индексируем поля вакансии $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')); // добавляем работу в индекс $index->addDocument($doc); $index->commit(); }
Поскольку Zend Lucene не может обновить существующую запись, мы сначала удаляем ее если вакансия уже проиндексирована.
Индексирование само по себе достаточно простая работа: первичный ключ сохранен для будущих ссылок
на найденные вакансии, а основные поля (position, company, location, и description)
проиндексированы, но не хранятся в индексе поскольку мы будем использовать реальные
объекты для отображения результатов.
Что делать, если возникнет проблема во время индексирования вакансии или если вакансия не будет сохранена в базу данных? Doctrine и Zend Lucene вызовут исключение. При некоторых обстоятельствах мы можем сохранить вакансию в базу данных без соответствующего индексирования. Чтобы предотвратить это, мы можем выполнять эти два обновления в транзакции и откатить изменения в случае ошибки:
// 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()Так же мы должны перегрузить метод delete() для удаления индекса при удалении вакансии:
// 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); }
Сейчас, когда у нас все готово, Вы можете перезагрузить начальные данные для их индексирования:
$ php symfony doctrine:data-load
Для пользователей Unix: поскольку индекс модифицируется из командной строки и из веб, Вы должны изменить соответсвующим образом права доступа для папки с индексами в зависимости от вашей конфигурации: проверьте что и пользователь командной строки, и пользователь веб-сервера могут писать в папку индекса.
NOTE У Вас могут появиться некоторые предупреждения о классе
ZipArchiveесли Ваш PHP не был скомпилирован с расширениемzip. Это известный баг классаZend_Loader.
Внедрение поиска на frontend - это очень просто. Сначала создайте маршрут:
job_search:
url: /search
param: { module: job, action: search }
И соответсвующее действие в контроллере:
// 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); } // ... }
La nouvelle méthode
forwardUnless()redirige l'utilisateur vers l'actionindexdu modulejobsi la variablequeryde 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'); }
Шаблон тоже достаточно простой:
// apps/frontend/modules/job/templates/searchSuccess.php <?php use_stylesheet('jobs.css') ?> <div id="jobs"> <?php include_partial('job/list', array('jobs' => $jobs)) ?> </div>
Сам поиск делегируется методу getForLuceneQuery():
// 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(); }
После того, как мы получим все результаты от индекса Lucene, мы отфильтруем неактивные вакансии
и ограничим количество результатов до 20.
Что бы заставить это работать, мы обновим шаблон:
// 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>
В библиотеке Zend Lucene определен язык богатых запросов, который поддерживает такие операции, как булевы, поиск с использованием символов обобщения (типа "?" и "*"), нечеткий поиск, и другие. Это все задокументировано в справочнике по Zend Lucene
Какие модульные тесты наv нужно создать для тестирования поискового движка? Мы не будем
тестировать саму библиотеку Zend Lucene, а только интеграцию с классом JobeetJob.
Добавьте следующие тесты в конец файла JobeetJobTest.php и не забудьте обновить
количество тестов в начале файла до 7:
// 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');
Мы тестируем то, что неактивные или удаленные вакансии не показываются в результатах поиска; так же мы тестируем и то, что вакансии, соответсвующие данным критериям показываются в результатах поиска.
Со временем нам придется создать задачу для очистки индекса от устаревших записей (например когда у вакансии истек срок действия) и оптимизировать индексы. Поскольку у нас уже есть задача для очистки, давайте добавим к ней эту функциональность:
// lib/task/JobeetCleanupTask.class.php protected function execute($arguments = array(), $options = array()) { $databaseManager = new sfDatabaseManager($this->configuration); // очистка индекса Lucene $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'); // Удаляем устаревшие вакансии $nb = Doctrine_Core::getTable('JobeetJob')->cleanup($options['days']); $this->logSection('doctrine', sprintf('Removed %d stale jobs', $nb)); }
Задача удалила все устаревшие вакансии из индекса и затем оптимизировала индекс
благодаря встроенному в Zend Lucene методу optimize().
Сегодня мы внедрили поисковый движок с огромной функциональностью менее, чем за час. Каждый раз, когда Вы хотите добавить новую функциональность в Ваш проект, проверьте, что это еще не было создано кем-то другим. Сначала проверьте, не встроена ли она во фреймворк Symfony . Затем проверьте в плагинах Symfony. И не забудьте проверить библиотеки Zend Framework и ezComponent тоже.
Завтра мы будем использовать немного скромного JavaScript для улучшения отклика поискового движка, который будет обновлять результаты поиска в реальном времени, когда пользователь набирает запрос в поле поиска. Конечно это будет повод поговорить о том как использовать AJAX в Symfony.
If you find a typo or an error, please register and open a ticket.
If you need support or have a technical question, please post to the official user mailing-list.