こんにちは、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の公式ページを参照して設定して下さい。
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
この記事が役に立ったと感じたら、シェアして頂けると幸いです。