UUUMエンジニアブログ

UUUMのエンジニアによる技術ブログです

SymfonyでElasticsearchを簡単に扱えるFOSElasticaBundleの紹介

nazoです。

最近は全文検索エンジンの選択肢も増えましたが、弊社ではElasticsearchを使っています。 Symfony(2.x)でElasticsearchを使う上で、FOSElasticaBundleという便利なBundleがあるので、紹介したいと思います。

概要

そもそもPHPにはElasticaという、Elasticsearchを使う上で便利なライブラリがあるのですが、これをSymfonyに合わせてBundle化したものがFOSElasticaBundleです。

特長

  • EntityとElasticsearchをほぼ完全に連動させることができます。Entityが保存されたら同時にElasticsearchに保存、ということができるようになります。Elasticsearch上にデータを保存することを意識する必要がなくなります。
  • ElasticsearchとEntityのマッピングをYAMLで簡単に定義することができるので、多少のElasticsearchの知識があれば、簡単にデータをElasticsearch上にマッピングすることができます。もちろんtokenizerやanalyzerの設定もできます。
  • Doctrine以外のデータソースにも対応しています(あまり使うことはないと思いますが)。

インストール

composer require friendsofsymfony/elastica-bundleして、 app/AppKernel.phpnew FOS\ElasticaBundle\FOSElasticaBundle(), を足します。(他のBundleと同じ)

設定

ざっくりと以下のようなEntityを用意します。

<?php

namespace AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * Post
 *
 * @ORM\Table(name="post")
 * @ORM\Entity(repositoryClass="AppBundle\Repository\PostRepository")
 */
class Post
{
    /**
     * @var int
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @var string
     *
     * @ORM\Column(name="title", type="string", length=255)
     */
    private $title;

    /**
     * @var string
     *
     * @ORM\Column(name="body", type="text")
     */
    private $body;
(以下略)

app/config/config.yml に、以下の設定を追加します。

fos_elastica:
    clients:
        default:
            url: "http://localhost:9200"
    indexes:
        symfony:
            settings:
                index:
                    analysis:
                        tokenizer:
                            kuromoji:
                                type: kuromoji_tokenizer
                        analyzer:
                            kuromoji_analizer:
                                type: custom
                                tokenizer: kuromoji
                                filter:
                                    - kuromoji_baseform
                                    - lowercase
            types:
                post:
                    mappings:
                        id:
                            type: integer
                            index: not_analyzed
                        title:
                            type: string
                            index: analyzed
                            analyzer: kuromoji_analizer
                        body:
                            type: string
                            index: analyzed
                            analyzer: kuromoji_analizer
                    persistence:
                        driver: orm
                        model: AppBundle\Entity\Post
                        listener: ~
                        provider: ~
                        finder: ~
                        elastica_to_model_transformer:
                            ignore_missing: true

データの投入

適当にSQLでデータを足します。

INSERT INTO post(title, body) values('こんにちは', '本日は晴天なり'), ('UUUM', 'YouTuberの会社です');

SQLから入れた場合は当然 Elasticsearch にはデータが入りません。 ここで fos:elastica:populate コマンドを実行すると、「インデックスの作成」と「データの投入」を同時に行ってくれます。初期設定や開発中に便利です。

% app/console fos:elastica:populate
 100/100 [============================] 100%
Populating symfony/postRefreshing symfony
Refreshing symfony

Elasticsearch側にデータが入ったか確認してみましょう。

% curl http://localhost:9200/symfony/_search\?pretty\=true
{
  "took" : 2,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "failed" : 0
  },
  "hits" : {
    "total" : 2,
    "max_score" : 1.0,
    "hits" : [ {
      "_index" : "symfony",
      "_type" : "post",
      "_id" : "1",
      "_score" : 1.0,
      "_source":{"id":1,"title":"こんにちは","body":"本日は晴天なり"}
    }, {
      "_index" : "symfony",
      "_type" : "post",
      "_id" : "2",
      "_score" : 1.0,
      "_source":{"id":2,"title":"UUUM","body":"YouTuberの会社です"}
    } ]
  }
}

問題なさそうです。

画面から投入してみる

データを入れる画面を適当に作りデータを入れます。

  • Form/PostType.php -> app/console doctrine:generate:form AppBundle:Post で作る
  • Resources/views/Default/add.html.twig
<html>
  <body>
    <h1>Add Post</h1>
    {{ form_start(form) }}
      {{ form_widget(form) }}
      <input type="submit" value="add" />
    {{ form_end(form) }}
  </body>
</html>
  • Controller/DefaultController.php
use AppBundle\Form\PostType;
use AppBundle\Entity\Post;

...

    /**
     * @Route("/add", name="add")
     * @Template
     */
    public function addAction(Request $request)
    {
        $form = $this->createForm(PostType::class, new Post());
        if ($request->isMethod('post')) {
            $form->handleRequest($request);
            if ($form->isValid()) {
                $post = $form->getData();
                $this->getDoctrine()->getManager()->persist($post);
                $this->getDoctrine()->getManager()->flush();
                return $this->redirectToRoute('index');
            }
        }
        return ['form' => $form->createView()];
    }

画面が出来上がるので、画面からデータを入れてみましょう。

f:id:nazone:20160201164751p:plain

Elasticsearch側から見てみます。

% curl http://localhost:9200/symfony/_search\?pretty\=true
{
  "took" : 1,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "failed" : 0
  },
  "hits" : {
    "total" : 3,
    "max_score" : 1.0,
    "hits" : [ {
      "_index" : "symfony",
      "_type" : "post",
      "_id" : "1",
      "_score" : 1.0,
      "_source":{"id":1,"title":"こんにちは","body":"本日は晴天なり"}
    }, {
      "_index" : "symfony",
      "_type" : "post",
      "_id" : "2",
      "_score" : 1.0,
      "_source":{"id":2,"title":"UUUM","body":"YouTuberの会社です"}
    }, {
      "_index" : "symfony",
      "_type" : "post",
      "_id" : "3",
      "_score" : 1.0,
      "_source":{"id":3,"title":"UUUMネットワーク 開始から1年でチャンネル月間総再生回数が2億回を突破!","body":"UUUM株式会社(本社:東京都港区、代表取締役:鎌田 和樹)は、当社が提供するYouTuberを支援するサポートサービス「UUUMネットワーク」において、2015年12月のネットワーク所属チャンネルの月間総再生回数が2億回を突破したことをお知らせ致します。"}
    } ]
  }
}

このように、Entityの操作だけでElasticsearchにデータが入っていることが確認できました。

検索

検索は普通にElasticsearchのクエリをPHPのarrayで入力するのが基本になります。

  • Controller/DefaultController.php
    /**
     * @Route("/", name="index")
     * @Template
     */
    public function indexAction(Request $request)
    {
        $keyword = $request->query->get('keyword', '');

        $query = [
            'sort' => [
                'id' => [
                    'order' => 'desc',
                ],
            ],
            'query' => [
                'bool' => [
                    'must' => [
                        [
                            'query_string' => [
                                'fields' => ['title', 'body'],
                                'query' => $keyword,
                            ],
                        ],
                    ]
                ]
            ]
        ];
        $posts = $this->get('fos_elastica.finder.symfony.post')->find($query);

        return ['posts' => $posts, 'keyword' => $keyword];
    }
  • Resources/views/Default/index.html.twig
<html>
  <body>
    <h1>Posts</h1>

    <form action="{{ path('index') }}" method="get">
      <input type="text" name="keyword" value="{{ keyword }}" />
      <input type="submit" value="search" />
    </form>

    {% for post in posts %}
    <h2>{{ post.title }}</h2>
    <p>{{ post.body | nl2br }}</p>
    {% endfor %}

    <p><a href="{{ path('add') }}">Add Post</a></p>
  </body>
</html>

f:id:nazone:20160201164844p:plain

このように、Elasticsearchのクエリを理解していれば、簡単にクエリを作ることができると思います。

ページネーション

KnpPaginatorBundleと組み合わせたページネーションにも対応しています。

  • Controller/DefaultController.php
    /**
     * @Route("/", name="index")
     * @Template
     */
    public function indexAction(Request $request)
    {
        $keyword = $request->query->get('keyword', '');

        $query = [
            'sort' => [
                'id' => [
                    'order' => 'desc',
                ],
            ],
            'query' => [
                'bool' => [
                    'must' => [
                        [
                            'query_string' => [
                                'fields' => ['title', 'body'],
                                'query' => $keyword,
                            ],
                        ],
                    ]
                ]
            ]
        ];
        $paginator = $this->get('knp_paginator');
        $pagination = $paginator->paginate(
            $this->get('fos_elastica.finder.symfony.post')->createPaginatorAdapter($query),
            $request->query->getInt('page', 1),
            5
        );

        return ['pagination' => $pagination, 'keyword' => $keyword];
    }
  • Resources/views/Default/index.html.twig
<html>
  <body>
    <h1>Posts</h1>

    <form action="{{ path('index') }}" method="get">
      <input type="text" name="keyword" value="{{ keyword }}" />
      <input type="submit" value="search" />
    </form>

    <p>{{ knp_pagination_render(pagination) }}</p>

    {% for post in pagination %}
    <h2>{{ post.title }}</h2>
    <p>{{ post.body | nl2br }}</p>
    {% endfor %}

    <p><a href="{{ path('add') }}">Add Post</a></p>
  </body>
</html>

f:id:nazone:20160201164857p:plain

簡単ですね。

まとめ

AWSでも最近Elasticsearch Serviceが始まり、Elasticsearchを使う場面は増えてきたと思われますが、このようなBundleを便利に活用することによって、気軽に検索を実装することができます。

検索が重要なところでは、検索APIとのつなぎ込みをあまり気にすることなく、また、データベース側のインデックスや高速化を考えることからも開放されるので、Symfonyを使っている場合は検討してみてください。

UUUMでは、大量のYouTuberのデータを分析するエンジニアも募集しています。詳しくは以下をご覧下さい。

www.wantedly.com