17. 17日目: 検索

  • この記事は Symfony 1.4 向けのオリジナルの Jobeet Tutorial の一部分です。
14 日目では、最新の投稿で Jobeet ユーザーのジョブの更新を維持するため、いくつかのフィードを追加しました。
今日は検索エンジンという最新の機能を実装してユーザーエクスペリエンスを向上させます。

17.1. テクノロジー

今日は、 Jobeet に検索エンジンを追加いたします。有名な Java Lucene プロジェクトから移植された、 Zend Framework 提供の Zend Lucene と呼ばれるすばらしいライブラリを使用します。
Jobeet のためのさらにもうひとつの検索エンジンを作成するのは非常に複雑なタスクなため、代わりに、 Zend Lucene を使用します。
今日は、 Zend Lucene ライブラリのチュートリアルではなく、 それを Jobeet の Web サイトに統合する方法のチュートリアルです。
より一般的には、サードパーティ製のライブラリをどのように symfony プロジェクトに統合するかのチュートリアルです。
この技術についての詳細情報が必要な場合は、Zend Lucene のドキュメントを参照してください。

17.2. インストールと Zend Framework の設定

Zend Lucene ライブラリは、 Zend Framework の一部です。
vendor/ ディレクトリに、 Symfony フレームワーク自身と一緒に、 Zend Framework をインストールするだけです。
まず、Zend Framework をダウンロードし、 vendor/Zend/ ディレクトリを持つように、ファイルを解凍します。
Zend Framework 2.* バージョンは Lucene ライブラリが統合されていないため、このチュートリアルでは、それらのいずれかもダウンロードしないことに注意してください。

Note

以下の説明は、Zend Framework を1.12.9バージョンでテストされています。

_images/Day-17-zend.jpg

次のファイルとディレクトリ以外のすべてを削除することで、ディレクトリをクリーンアップすることができます。

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

その後、簡単に Zend autoloader を登録するため 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;

17.3. インデックス化

Jobeet の検索エンジンは、ユーザーが入力したキーワードにマッチするすべての求人情報を返すことができるべきです。
何でも検索できるようになるには、ジョブに対して、Jobeet に対してインデックスが構築される必要があり、作成する新しいディレクトリ ( /web/data/ )に保存されます。
Zend Lucene はインデックスがすでに存在するかどうかに応じて取得するために 2 つのメソッドを提供します。
既存のインデックスを返すか、もしくは、新しいインデックスを作成する、ジョブエンティティクラスのヘルパーメソッドを作成してみましょう。

src/Ibw/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';
    }
}
ジョブが作成または更新されるたびに、インデックスを更新する必要があります。
ジョブがデータベースにシリアライズされるたびにインデックスを更新するように ORM ファイルを編集します。

src/Ibw/JobeetBundle/Resources/config/doctrine/Job.orm.yml

# ...
    # ...
    lifecycleCallbacks:
        # ...
        postPersist: [ upload, updateLuceneIndex ]
        postUpdate: [ upload, updateLuceneIndex ]
        # ...

ここで、 generate:entities コマンド実行し、 Doctrineによって updateLuceneIndex() メソッドがジョブ·クラスの内部に生成されるようにします。

$ php app/console doctrine:generate:entities IbwJobeetBundle

次に、実際の作業を行う updateLuceneIndex() メソッドを編集します。

src/Ibw/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();
    }
}
Zend Lucene は既存エントリを更新することができないため、インデックス内にジョブがすでに存在する場合は、はじめに削除します。
ジョブ自体のインデックス作成は簡単です。主キーはジョブ検索するときに将来の参照用に保存されます。
メインカラム(位置、会社、場所、および説明)はインデックス化されますが、表示する際に本物のオブジェクトを使うのでインデックスに保存はされません。
また、削除されたジョブエントリをインデックスから削除するため、 deleteLuceneIndex() メソッドを作成する必要があります。
更新と同様の処理を削除でも行います。
ORM ファイルの postRemove セクションに deleteLuceneIndex() メソッドを追加します。

src/Ibw/JobeetBundle/Resources/config/doctrine/Job.orm.yml

# ...
    # ...
    lifecycleCallbacks:
        # ...
        postRemove: [ removeUpload, deleteLuceneIndex ]
再度、エンティティを生成するコマンドを実行します。
ここで、エンティティファイルに移動し、 deleteLuceneIndex() メソッドを実装します。

src/Ibw/JobeetBundle/Entity/Job.php

class Job
{
    // ...

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

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

インデックスはコマンドラインからも Web からも更新されるので、構成に応じてインデックディレクトリのパーミッションを変更する必要があります。

$ chmod -R 777 web/data

これで、すべてそろいましたので、インデックス対象のフィクスチャーデータをリロードします。

$ php app/console doctrine:fixtures:load

17.4. 検索

検索を実装することは、とても簡単です。まず、ルートを作成します。

src/Ibw/JobeetBundle/Resources/config/routing/job.yml

# ...

ibw_job_search:
    pattern: /search
    defaults: { _controller: "IbwJobeetBundle:Job:search" }

そして、対応するアクション。

src/Ibw/JobeetBundle/Controller/JobController.php

namespace Ibw\JobeetBundle\Controller;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Ibw\JobeetBundle\Entity\Job;
use Ibw\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('ibw_job'));
        }

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

        return $this->render('IbwJobeetBundle:Job:search.html.twig', array('jobs' => $jobs));
    }
}
searchAction() の内部では、ユーザーは、クエリリクエストが存在しないか空の場合は、 JobController の index アクションに転送されます。
テンプレートには、非常に簡単です。

src/Ibw/JobeetBundle/Resources/views/Job/search.html.twig

{% extends 'IbwJobeetBundle::layout.html.twig' %}

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

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

検索自体は getForLuceneQuery() メソッドに委譲されています。

src/Ibw/JobeetBundle/Repository/JobRepository.php

namespace Ibw\JobeetBundle\Repository;

use Doctrine\ORM\EntityRepository;
use Ibw\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();
    }
}
Lucene インデックスからすべての結果を取得した後、非アクティブなジョブをフィルタリングし、結果の数を 20 に制限します。
動作させるために layout.html.twig を更新します。

src/Ibw/JobeetBundle/Resources/views/layout.html.twig

<!-- ... -->
    <!-- ... -->
        <div class="search">
            <h2>Ask for a job</h2>
            <form action="{{ path('ibw_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>
    <!-- ... -->
<!-- ... -->

17.5. 単体テスト

検索エンジンをテストするためにどのような単体テストを作成すべきでしょうか?
明らかに Zend Lucene ライブラリそのもののテストではなく、ジョブ·クラスとの統合をテストすることです。
JobRepositoryTest.php ファイルの最後に次のテストを追加します。

src/Ibw/JobeetBundle/Tests/Repository/JobRepositoryTest.php

// ...
use Ibw\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@example.com');
        $job->setUrl('http://sensio-labs.com');
        $job->setIsActivated(false);

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

        $jobs = $em->getRepository('IbwJobeetBundle: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@example.com');
        $job->setUrl('http://sensio-labs.com');
        $job->setIsActivated(true);

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

        $jobs = $em->getRepository('IbwJobeetBundle: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('IbwJobeetBundle:Job')->getForLuceneQuery('position:FOO7');

        $this->assertEquals(count($jobs), 0);
    }
}
非アクティブなジョブ、または、削除されたジョブは、検索結果に表示されないことをテストします。
与えられた条件に一致するジョブが結果に表示されることも確認してください。

17.6. タスク

最終的には、 JobeetCleanup タスクを更新して、古いエントリ(たとえばジョブが期限切れになったときに)のインデックスをクリーンアップし、随時インデックスを最適化する必要があります。

src/Ibw/JobeetBundle/Command/JobeetCleanupCommand.php

// ...
use  Ibw\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('IbwJobeetBundle: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('IbwJobeetBundle:Job')->cleanup($days);

        $output->writeln(sprintf('Removed %d stale jobs', $nb));
    }
}
このタスクはインデックスからすべての期限切れのジョブを削除し、Zend Lucene の組み込みの optimize() メソッドによって最適化します。
今日は、 1 時間も経たないうちに、多くの機能を備えた完全な検索エンジンを実装しました。
プロジェクトに新しい機能を追加したいときはいつも、まだどこかに解決されていないものがあるか確認してください。
明日は、検索ボックスにユーザーの種類などのリアルタイムで結果を更新することで、検索エンジンのレスポンスを強化するために控えめな( unobtrusive ) JavaScript を使います。
もちろん、これは Symfony で AJAX を使用する方法について話をすることになります。

See also

Symfony2日本語ドキュメント

豊富な日本語ドキュメントがありますので合わせて読み進めてみましょう。

Note

Creative Commons License

このチュートリアルは、クリエイティブ・コモンズ・ライセンス 表示 - 継承 3.0 非移植 (CC BY-SA 3.0) のもとでライセンスされています。 翻訳の元にしたオリジナルはこちらです。 Symfony2 Jobeet http://www.intelligentbee.com/blog/tag/symfony2-jobeet/.