Edited at

とりあえずLaravel Scout+Elasticsearchを動かしてみる


概要

時代も令和になったしElasticsearchLaravel Scoutを使ってとりあえず動かしてみます


導入

ほぼほぼ 【Laravel】Laravel Scout + Elasticsearchを使った全文検索の実装方法 | Public Constructor の記事を参考にさせてもらいました。

のでみんなも↑を参考にしたほうがいいです


Laravelプロジェクト作成

ここは割愛させてもらいます。(ちなみに自分はVagrant立ててLaravelプロジェクト作りました)


Laravel Scoutのインストール


Laravel Scoutとは


Laravel Scout(スカウト、斥候)は、Eloquentモデルへ、シンプルなドライバベースのフルテキストサーチを提供します。モデルオブサーバを使い、Scoutは検索インデックスを自動的にEloquentレコードと同期します。

現在、ScoutはAlgoliaドライバを用意しています。カスタムドライバは簡単に書けますので、独自の検索を実装し、Scoutを拡張できます。


参考:Laravel Scout 5.8 Laravel


インストール

composer require laravel/scout

その後、ArtisanコマンドでScoutの設定ファイルを生成

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

以下設定ファイル


config\scout.php

<?php

return [

// ~~~[略]~~~

/*
|--------------------------------------------------------------------------
| Elasticsearch Configuration
|--------------------------------------------------------------------------
*/

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



Elasticsearchのインストール

composer require elasticsearch/elasticsearch


プロパイダーの生成と登録

php artisan make:provider ElasticsearchServiceProvider


app\Providers\ElasticsearchServiceProvider.php

<?php

namespace App\Providers;

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

class ElasticsearchServiceProvider extends ServiceProvider
{
/**
* Register services.
*
* @return void
*/

public function register()
{
//
}

/**
* Bootstrap 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()
);
});
}
}


config/app.php内に追加


config/app.php

    'providers' => [

// ~~略~~

/*
* Application Service Providers...
*/

App\Providers\AppServiceProvider::class,
App\Providers\AuthServiceProvider::class,
// App\Providers\BroadcastServiceProvider::class,
App\Providers\EventServiceProvider::class,
App\Providers\RouteServiceProvider::class,

App\Providers\ElasticsearchServiceProvider::class, // 追加
],



カスタムエンジンの作成

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


app\Scout\ElasticSearchEngine.php.php

<?php

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;
}

public function flush($model)
{
//
}

/**
* 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' => [
[
'query_string' => [
'query' => "{$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();
}
}


参考にしたサイトで多かったのが'query' => "*{$builder->query}*"みたいな書き方ですが、アスタリスク*で囲んでしまうと日本語がうまくヒットしなくなる為ここでは削除してます。

が、恐らくこれだけでは日本語対応として不十分(挙動がおかしい)なのでKuromoji(日本語形態素解析エンジン)をさらに導入する必要があるみたいです。

難しいことはわからんのでここでは割愛)


環境変数の設定

SCOUT_DRIVER=elasticsearch

ELASTICSEARCH_HOST=http://localhost:9200


Elasticsearchの起動

Dockerを使って動かすのですが筆者環境がWindows10 Homeの為、VagrantにDockerをインストールするところから書いておきます

参考にさせてもらった記事は以下


Dockerのインストール

$ sudo apt update

$ sudo apt install -y apt-transport-https gnupg-agent
$ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
$ sudo apt-key fingerprint 0EBFCD88
$ sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"
$ sudo apt-get install -y docker-ce


Dockerの確認

$ sudo docker run hello-world


docker-composeのインストール

インストールするバージョンはその時の最新がいいかなと

$ sudo curl -L https://github.com/docker/compose/releases/download/1.24.0/docker-compose-`uname -s`-`uname -m` -o /usr/local/bin/docker-compose

$ chmod +x /usr/local/bin/docker-compose
$ sudo chmod +x /usr/local/bin/docker-compose


docker-composeの確認

$ sudo docker-compose --version

docker-compose version 1.24.0, build 0aa59064


docker-compose.ymlの設定


docker-compose.yml

version: '2'

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


Docker起動

$ docker-compose up -d

Pulling elasticsearch (elasticsearch:5.6)...
5.6: Pulling from library/elasticsearch
e29bb969ec00: Pull complete
d3b7302036fe: Pull complete
1b2a32d4e033: Pull complete
de323434a943: Pull complete
c3aac3b444f7: Pull complete
48f01b742a52: Pull complete
c43be56a6ac1: Pull complete
4cc2e2f77cf6: Pull complete
5d02da94626f: Pull complete
berc5c69e802: Pull complete
7237b149e653: Pull complete
922f2f777e75: Pull complete
233a8534ee14: Pull complete
c2907d377ed9: Pull complete
Digest: sha256:d0e119779df7wwe9d473452691d2d0b8783c1343er70d77f989727019c1297495
Status: Downloaded newer image for elasticsearch:5.6
Recreating code_elasticsearch_1 ... done


Dockerの状態確認

$ docker container ls

CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
8f35168d9434 elasticsearch:5.6 "/docker-entrypoint.…" About a minute ago Up About a minute 0.0.0.0:9200->9200/tcp, 9300/tcp code_elasticsearch_1


モデル作成

ここではApp\Models以下にStoreモデルを作成し、データを登録していきます

php artisan make:model Models/Store

class内でSearchableuseする


app\Models\Store.php

<?php

namespace App\Models;

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

class Store extends Model
{
use Searchable;

/**
* The attributes that are mass assignable.
*
* @var array
*/

protected $fillable = [
'name',
'address',
];
}



テーブル作成

php artisan make:migration create_stores_table --create=stores

以下マイグレーション内容


database\migrations\2019_06_20_201447_create_stores_table.php

<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateStoresTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/

public function up()
{
Schema::create('stores', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('name', 200)->comment('店名');
$table->string('address', 255)->comment('店住所');
$table->timestamps();
});
}

/**
* Reverse the migrations.
*
* @return void
*/

public function down()
{
Schema::dropIfExists('stores');
}
}



Factory作成

php artisan make:factory StoreFactory


database\factories\StoreFactory.php

<?php

use App\Models\Store;
use Faker\Generator as Faker;

$factory->define(Store::class, function (Faker $faker) {
return [
'name' => $faker->company,
'address' => $faker->address,
];
});



tinker(laravel用REPL)からテストデータ登録

$ php artisan tinker

Psy Shell v0.9.9 (PHP 7.3.5-1+ubuntu18.04.1+deb.sury.org+1 — cli) by Justin Hileman
>>> factory(App\Models\Store::class, 50)->create();
factory(App\Models\Store::class, 50)->create();
=> Illuminate\Database\Eloquent\Collection {#3228
all: [
App\Models\Store {#3232
name: "株式会社 吉田",
address: "3242295 高知県木村市西区山本町藤本9-8-10 コーポ石田107号",
updated_at: "2019-06-20 00:26:39",
created_at: "2019-06-20 00:26:39",
id: 1,
},

// ~~略~~

店の名前が人の名前ぽいのはスルーします

Elasticsearchにも登録されるので登録されてるのかを次で確認


Elasticsearchのデータ確認


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


引用:【Laravel】Laravel Scout + Elasticsearchを使った全文検索の実装方法 | Public Constructor

curl -X GET "localhost:9200/_cat/indices?v"

以下のコマンドで登録済みのデータを確認する

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

実行すると登録されたデータが取得できると思います。

また以下のコマンドでElasticsearchに対して操作(削除・登録)ができます。

検索インデックスからモデルの全レコードを削除

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

全レコードを検索インデックスへ取り込む

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

あとからElasticsearchを入れたときなどは上記コマンドを実行する必要があります


検索キーワードを投げて検索結果を確認する

URLに対して直接検索キーワードをGET送信して検索結果を取得してみます


ルート設定


routes\web.php

Route::get('stores/search', function () {

return App\Models\Store::search(\request('q'))->paginate();
});


検索URLを叩いてJSONが返ってくるか確認

上記tinkerでデータを登録した際のデータが取得できるか以下のキーワードで検索します。

http://localhost/stores/search?q=コーポ石田

以下が検索した結果↓

result.jpg

(※Chromeのdeveloperツールから検索結果を見た状態です)

無事取得できることを確認できました。


おわり



  • Elasticsearchわかってわからん


参考URL