概要
時代も令和になったしElasticsearch
をLaravel Scout
を使ってとりあえず動かしてみます
導入
ほぼほぼ 【Laravel】Laravel Scout + Elasticsearchを使った全文検索の実装方法 | Public Constructor の記事を参考にさせてもらいました。
のでみんなも↑を参考にしたほうがいいです
Laravelプロジェクト作成
ここは割愛させてもらいます。(ちなみに自分はVagrant立ててLaravelプロジェクト作りました)
Laravel Scoutのインストール
Laravel Scoutとは
Laravel Scout(スカウト、斥候)は、Eloquentモデルへ、シンプルなドライバベースのフルテキストサーチを提供します。モデルオブサーバを使い、Scoutは検索インデックスを自動的にEloquentレコードと同期します。
現在、ScoutはAlgoliaドライバを用意しています。カスタムドライバは簡単に書けますので、独自の検索を実装し、Scoutを拡張できます。
インストール
composer require laravel/scout
その後、ArtisanコマンドでScoutの設定ファイルを生成
php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"
以下設定ファイル
<?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
<?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内に追加
'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()
です。
<?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
の設定
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内でSearchable
をuse
する
<?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
以下マイグレーション内容
<?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
<?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送信して検索結果を取得してみます
ルート設定
Route::get('stores/search', function () {
return App\Models\Store::search(\request('q'))->paginate();
});
検索URLを叩いてJSONが返ってくるか確認
上記tinkerでデータを登録した際のデータが取得できるか以下のキーワードで検索します。
http://localhost/stores/search?q=コーポ石田
以下が検索した結果↓
(※Chromeのdeveloperツールから検索結果を見た状態です)
無事取得できることを確認できました。
おわり
-
Elasticsearch
わかってわからん
参考URL
- https://public-constructor.com/laravel-scout-with-elasticsearch/
- https://www.84kure.com/blog/2017/07/19/laravel-elasticsearch%e3%81%a8laravel-scout%e3%81%a7%e5%85%a8%e6%96%87%e6%a4%9c%e7%b4%a2-3-kuromoji%e3%82%bb%e3%83%83%e3%83%88%e3%82%a2%e3%83%83%e3%83%97/
- https://www.imooc.com/article/29654
- https://learnku.com/articles/20311
- https://www.twblogs.net/a/5cb75d5abd9eee0eff459c48
- https://learnku.com/articles/4351/application-of-laravel-scout-package-in-elasticsearch
- https://www.dvy.com.cn/2017/11/21/4521.html
- https://appdividend.com/2018/06/30/laravel-elasticsearch-tutorial-example/
- https://qiita.com/shin_hayata/items/41c07923dbf58f13eec4
- https://qiita.com/r548/items/3622048a622d9c0acc05
- https://qiita.com/aiiro/items/0049249513c030345223