【Laravel】Laravel Scout + Elasticsearchを使った全文検索の実装方法

Elasticsearch

こんにちは、aiiro(@aiiro29)です。

今回はLaravel ScoutでElasticsearch用のカスタムエンジンを自作して、Elasticsearchを使って全文検索を実装する方法を紹介します。

カスタムエンジンを自作できるようになれば、検索クエリを自由に設定して検索ができるようになります。

ただ、Laravel Scoutのカスタムエンジンですが、どうやって実装すれば良いかわかりにくいので、備忘録として書いておきます。

実行環境はDockerで起動したElasticsearchとartisan servで起動したローカルサーバーを使用、Laravelのバージョンは5.6です。

スポンサーリンク

Laravel Scout

composerでライブラリをインストールする


composer require laravel/scout
composer require elasticsearch/elasticsearch

設定ファイルの生成と編集


php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"

config/scout.php


/*
|--------------------------------------------------------------------------
| Elasticsearch Configuration
|--------------------------------------------------------------------------
*/
return [

  // 中略
  
  'elasticsearch' => [
      'index' => env('ELASTICSEARCH_INDEX', 'scout'),
      'hosts' => [
          env('ELASTICSEARCH_HOST', 'http://localhost'),
      ],

  ]

];

プロバイダーを作成する


php artisan make:provider ElasticsearchServiceProvider

namespace App\Providers;

use App\Scout\ElasticsearchEngine;
use Elasticsearch\ClientBuilder;
use Illuminate\Support\ServiceProvider;
use Laravel\Scout\EngineManager;

class ElasticsearchServiceProvider extends ServiceProvider
{
    /**
     * Bootstrap the application services.
     *
     * @return void
     */
    public function boot()
    {
        resolve(EngineManager::class)->extend('elasticsearch', function ($app) {
            return new ElasticsearchEngine(
                config('scout.elasticsearch.index'),
                ClientBuilder::create()
                             ->setHosts(config('scout.elasticsearch.hosts'))
                             ->build()
                );
        });
    }

    /**
     * Register the application services.
     *
     * @return void
     */
    public function register()
    {
        //
    }
}

プロバイダーを登録する

config/app.php


'providers' => [

    // 中略

    /*
     * Package Service Providers...
     */
    App\Providers\ElasticsearchServiceProvider::class,

],

カスタムエンジンを作成する

app/Scout/ElasticSearchEngine.php

performSearch()でElasticsearchで実行するクエリを設定しています。

このサンプルではtitleに対してbool queryを実行しています。

その他のクエリについては、Elasticsearchの公式ページを参照して設定して下さい。

Boolean query | Elasticsearch Guide [8.6] | Elastic

namespace App\Scout;

use Elasticsearch\Client as Elastic;
use Laravel\Scout\Builder;
use Laravel\Scout\Engines\Engine;

class ElasticsearchEngine extends Engine
{

    /**
     * @var string
     */
    protected $index;

    /**
     * @var Elastic
     */
    protected $elastic;

    /**
     * ElasticsearchEngine constructor.
     *
     * @param string $index
     * @param \Elasticsearch\Client $elastic
     */
    public function __construct($index, Elastic $elastic)
    {
        $this->index = $index;
        $this->elastic = $elastic;
    }


    /**
     * Update the given model in the index.
     *
     * @param  \Illuminate\Database\Eloquent\Collection $models
     * @return void
     */
    public function update($models)
    {
        $params['body'] = [];
        $models->each(function ($model) use (&$params) {
            $params['body'][] = [
                'update' => [
                    '_id'    => $model->getKey(),
                    '_index' => $this->index,
                    '_type'  => $model->searchableAs(),
                ]
            ];
            $params['body'][] = [
                'doc'           => $model->toSearchableArray(),
                'doc_as_upsert' => true
            ];
        });
        $this->elastic->bulk($params);
    }

    /**
     * Remove the given model from the index.
     *
     * @param  \Illuminate\Database\Eloquent\Collection $models
     * @return void
     */
    public function delete($models)
    {
        $params['body'] = [];

        $models->each(function ($model) use (&$params) {
            $params['body'][] = [
                'delete' => [
                    '_id'    => $model->getKey(),
                    '_index' => $this->index,
                    '_type'  => $model->searchableAs(),
                ]
            ];
        });
        $this->elastic->bulk($params);
    }

    /**
     * Perform the given search on the engine.
     *
     * @param  \Laravel\Scout\Builder $builder
     * @return mixed
     */
    public function search(Builder $builder)
    {
        return $this->performSearch($builder, array_filter([
            'filters' => $this->filters($builder),
            'limit'   => $builder->limit,
        ]));
    }

    /**
     * Perform the given search on the engine.
     *
     * @param  \Laravel\Scout\Builder $builder
     * @param  int $perPage
     * @param  int $page
     * @return mixed
     */
    public function paginate(Builder $builder, $perPage, $page)
    {
        $result = $this->performSearch($builder, [
            'filters' => $this->filters($builder),
            'from'    => (($page * $perPage) - $perPage),
            'limit'   => $perPage,
        ]);

        $result['nbPages'] = $result['hits']['total'] / $perPage;

        return $result;
    }

    /**
     * Pluck and return the primary keys of the given results.
     *
     * @param  mixed $results
     * @return \Illuminate\Support\Collection
     */
    public function mapIds($results)
    {
        return collect($results['hits']['hits'])->pluck('_id')->values();
    }

    /**
     * Map the given results to instances of the given model.
     *
     * @param  \Laravel\Scout\Builder $builder
     * @param  mixed $results
     * @param  \Illuminate\Database\Eloquent\Model $model
     * @return \Illuminate\Database\Eloquent\Collection
     */
    public function map(Builder $builder, $results, $model)
    {
        if ($results['hits']['total'] === 0) {
            return collect();
        }

        $keys = collect($results['hits']['hits'])
            ->pluck('_id')->values()->all();

        $models = $model->whereIn(
            $model->getKeyName(), $keys
        )->get()->keyBy($model->getKeyName());

        return collect($results['hits']['hits'])->map(function ($hit) use ($model, $models) {
            return isset($models[$hit['_id']]) ? $models[$hit['_id']] : null;
        })->filter()->values();
    }

    /**
     * Get the total count from a raw result returned by the engine.
     *
     * @param  mixed $results
     * @return int
     */
    public function getTotalCount($results)
    {
        return $results['hits']['total'];
    }

    /**
     * @param \Laravel\Scout\Builder $builder
     * @param array $options
     * @return array|mixed
     */
    protected function performSearch(Builder $builder, $options = [])
    {
        $params = [
            'index' => $this->index,
            'type'  => $builder->index ?: $builder->model->searchableAs(),
            'body'  => [
                'query' => [
                    'bool' => [
                        'must' => [
                            'term' => [
                                'title' => "{$builder->query}",
                            ]
                        ],
                    ],
                ],
            ]
        ];

        if ($sort = $this->sort($builder)) {
            $params['body']['sort'] = $sort;
        }

        if (isset($options['filters']) && count($options['filters'])) {
            $params['body']['query']['bool']['filter'] = $options['filters'];
        }

        if ($builder->callback) {
            return call_user_func(
                $builder->callback,
                $this->elastic,
                $builder->query,
                $params
            );
        }

        return $this->elastic->search($params);
    }

    public function filters(Builder $builder)
    {
        return collect($builder->wheres)->map(function ($value, $key) {
            return [
                'term' => [
                    $key => $value
                ]
            ];
        })->values()->all();
    }

    protected function sort(Builder $builder)
    {
        if (count($builder->orders) == 0) {
            return null;
        }

        return collect($builder->orders)->map(function ($order) {
            return [$order['column'] => $order['direction']];
        })->toArray();
    }
}

環境変数を設定

.envで環境変数を設定します。

DB_CONNECTION=sqlite
DB_HOST=127.0.0.1
SCOUT_DRIVER=elasticsearch
ELASTICSEARCH_HOST=http://localhost:9200

DBを用意

sqliteファイルを作成


touch database/database.sqlite

マイグレーションファイルを作成する


php artisan make:migration create_post_table

increments('id');
            $table->integer('user_id');
            $table->string('title');
            $table->text('content');
            $table->boolean('is_public');
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('posts');
    }
}

migrateを実行する


php artisan migrate

モデルを作成する


php artisan make:model Models/Post

モデルにuse Searchableを追加します。


namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Laravel\Scout\Searchable;

class Post extends Model
{
    use Searchable;
}

Elasticsearchを起動する

次は、Dockerを使ってElasticsearchを起動します。

docker-compose.ymlを作成する

version: '2'
services:
  elasticsearch:
    image: elasticsearch:5.6
    volumes:
      - ./elasticsearch-data:/usr/share/elasticsearch/data
    ports:
      - "9200:9200"

DockerでElasticsearchコンテナを起動する


docker-compose up -d

コンテナが起動しているかどうかを確認する


docker container ls

Elasticsearchにデータを登録してみる

curlを使用してデータの追加、取得ができるかどうかを検証します。


curl -X PUT http://localhost:9200/test_index/httpd_access/1 -d '{"host": "localhost", "response": "200", "request": "/"}'

データを取得してみる


curl -X GET http://localhost:9200/test_index/_search -d '{"query": { "match_all": {} } }'

正常にデータが取得できた場合、下記のような結果が出力されます。

{
  "took": 86,
  "timed_out": false,
  "_shards": {
    "total": 5,
    "successful": 5,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 1,
    "max_score": 1,
    "hits": [
      {
        "_index": "test_index",
        "_type": "httpd_access",
        "_id": "1",
        "_score": 1,
        "_source": {
          "host": "localhost",
          "response": "200",
          "request": "/"
        }
      }
    ]
  }
}

テスト用データを生成する

ファクトリーを使ってElasticsearchに登録するテスト用のデータを生成します。

ファクトリーを作成する

database/factories/PostFactory.phpを作成します。


use App\Models\Post;
use App\User;
use Faker\Generator as Faker;

$factory->define(Post::class, function (Faker $faker) {
    return [
        'title' => $faker->text,
        'user_id' => factory(User::class)->create()->id,
        'content' => $faker->text,
        'is_public' => $faker->boolean,
    ];
});

DBからテーブルの構成を読み込んで、ファクトリーファイルを生成するライブラリを公開しています。

詳細はこちらの記事を参照ください。

artisan tinkerを使ってデータを登録

データを登録した際にElasticsearchにもデータが登録されます。


php artisan tinker

$users = factory('App\User', 5)->create();
$users = App\User::all();
$users->each(
function ($user) {
        factory('App\Models\Post', 10)->create(['user_id' => $user->id]);
    }
);

Elasticsearchに登録されたデータを確認

インデックスを確認する

curlを使って、config/scout.phpの'elasticsearch' => 'index'で指定された名称でインデックスが作成されていることを確認します。


curl -X GET "localhost:9200/_cat/indices?v"
health status index uuid pri rep docs.count docs.deleted store.size pri.store.size
yellow open scout k2rSX7HSQeWNUU8ng7NYfw 5 1 150 0 275.7kb 275.7kb

登録済みのデータを取得する


curl -X GET http://localhost:9200/scout/_search -d '{"query": { "match_all": {} } }'

正しく登録されていると、下記のようなJSONが返却されます。

{
  "took": 4,
  "timed_out": false,
  "_shards": {
    "total": 5,
    "successful": 5,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 50,
    "max_score": 1,
    "hits": [
      {
        "_index": "scout",
        "_type": "posts",
        "_id": "14",
        "_score": 1,
        "_source": {
          "title": "Perspiciatis similique aut alias suscipit ipsum doloremque optio. Illum error hic maxime id.",
          "user_id": 2,
          "content": "Sunt odit neque consequatur quod nobis et excepturi similique. Ut neque et nam ratione. Aut iure libero aut harum omnis eos. Aut eos omnis totam eaque nihil ut.",
          "is_public": false,
          "updated_at": "2018-08-14 11:33:24",
          "created_at": "2018-08-14 11:33:24",
          "id": 14
        }
      },
    }
  }

  (中略)

}

artisanでElasticsearchのデータを操作する

artisan scoutコマンドを使ってElasticsearchにデータをインポート、削除することが可能です。

コマンド実行時は、下記のようにモデル名を指定します。

モデルのデータを削除する


php artisan scout:flush "App\Models\Post"

モデルのデータをインポートする


php artisan scout:import "App\Models\Post"

リクエストパラメータを使って検索してみる

ルーティング内部で直接検索を実行して結果を返却してみます。

検索処理の呼び出し

routes/web.php


Route::get('/post/search', function () {
    return App\Models\Post::search(\request('q'))->paginate();
});

ローカルサーバーを起動


php artisan serv

検索結果を確認する

ブラウザから下記のURLにアクセスしてデータが表示されるかどうかを確認します。

もし表示されない場合は、Elasticsearchにリクエストパラメータのデータが登録されているかどうかを確認してみて下さい。

http://localhost:8000/post/search?q=alias

この記事が役に立ったと感じたら、シェアして頂けると幸いです。