Laravel(5.5) + Scout + Elasticsearchで全文検索 (導入編)
業務で使うことになりそうなので、チャチャっと触ってみました。
こちら様を思いっきり参考にさせていただきました、ありがとうございます!
Laravelと必要なライブラリを準備
ターミナルでComposerのcreate-projectコマンドを実行
ここでは仮に物件探しのサイトを作るということで
プロジェクト名をfind_houseとする。
// ver5.5を指定 $ composer create-project --prefer-dist laravel/laravel [project_name] "5.5.*" // 動作確認など $ php artisan serve $ cd find_house
ライブラリをインストール
$ composer require laravel/scout $ composer require elasticsearch/elasticsearch
Scoutの設定ファイル生成
// config配下にscout.phpを生成 php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"
config/scout.php に以下を追加
return [ // 中略 /* |-------------------------------------------------------------------------- | Elasticsearch Configuration |-------------------------------------------------------------------------- */ 'elasticsearch' => [ 'index' => env('ELASTICSEARCH_INDEX', 'scout'), 'hosts' => [ env('ELASTICSEARCH_HOST', 'http://localhost'), ], ] ];
プロバイダー作成
php artisan make:provider ElasticsearchServiceProvider
app/Providers/ElasticsearchServiceProvider.php
<?php namespace App\Providers; use Illuminate\Support\ServiceProvider; use Laravel\Scout\EngineManager; use App\Scout\ElasticsearchEngine; use Elasticsearch\ClientBuilder; 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,
],
カスタムエンジンを作成
performSearch()でElasticsearchで実行するクエリを設定する。
ここではaddressに対してbool queryを発行する。
app/Scout/ElasticSearchEngine.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; } /** * 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' => [ 'address' => "{$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に以下を追加
.env
DB_CONNECTION=mysql DB_HOST=127.0.0.1 DB_PORT=3306 DB_DATABASE=findhome DB_USERNAME=root DB_PASSWORD= SCOUT_DRIVER=elasticsearch ELASTICSEARCH_HOST=http://localhost:9200
DB作成
.envではfindhomeというデータベースを指定したので作る。
今回はmysqlで作ります。
$ mysql -u root MariaDB> CREATE DATABASE `findhome` MariaDB> CHARACTER SET utf8mb4 MariaDB> COLLATE utf8mb4_unicode_ci;
migrationファイル作成
$ php artisan make:migration create_shops_table
migrate実行
$ php artisan migrate
モデル作成
$ php artisan make:model Models/Shop
作成したモデルに use Searchable を追加
namespace App\Models; use Illuminate\Database\Eloquent\Model; use Laravel\Scout\Searchable; class Post extends Model { use Searchable; }
Elasticsearchを起動
Dockerを使います。
ルート配下に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に登録するテスト用のデータを生成する
$ php artisan make:factory ShopFactory
database/factories/ShopFactory.phpを追加する ここでは、fakerを使用してダミーデータを作成する(faker便利...)
<?php use App\Models\Shop; use Faker\Generator as Faker; $factory->define(Shop::class, function (Faker $faker) { return [ 'name' => $faker->streetName.$faker->randomElement(['マンション', 'ハウス', 'レーベン', 'レジデンス']), 'phone_number' => $faker->phoneNumber, 'address' => $faker->address, 'latitude' => $faker->latitude, 'longitude' => $faker->longitude, 'nearest_station' => $faker->randomElement(['東京', '表参道', '横浜', '錦糸町']), 'open' => $faker->randomElement(['9:00', '10:00', '11:00', '12:00']), 'close' => $faker->randomElement(['19:00', '20:00', '21:00', '22:00', '23:00']), 'wifi' => $faker->randomElement(['あり', 'なし']), ]; });
artisan tinkerを使ってデータを登録
$ php artisan tinker Psy Shell v0.9.8 (PHP 7.1.22 — cli) by Justin Hileman >>> factory(App\Models\Shop::class, 100)->create() // 100個のダミーデータを作成
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 IpDiHQkoRlSUjNU_JSjPow 5 1 103 0 131.4kb 131.4kb
登録済みのデータを取得する
$ curl -X GET http://localhost:9200/scout/_search -d '{"query": { "match_all": {} } }'
{ "took": 21, "timed_out": false, "_shards": { "total": 5, "successful": 5, "skipped": 0, "failed": 0 }, "hits": { "total": 103, "max_score": 1.0, "hits": [{ "_index": "scout", "_type": "shops", "_id": "14", "_score": 1.0, "_source": { "id": 14, "name": "中島町レジデンス", "phone_number": "090-4364-2947", "address": "1163282 静岡県廣川市東区石田町三宅8-7-8 ハイツ吉本102号", "latitude": 29.18, "longitude": 8.64, "nearest_station": "横浜", "open": "09:00:00", "close": "22:00:00", "wifi": "なし", "created_at": "2018-09-30 02:30:44", "updated_at": "2018-09-30 02:30:44" } }, { "_index": "scout", "_type": "shops", "_id": "19", "_score": 1.0, "_source": { "id": 19, "name": "青田町ハウス", "phone_number": "090-3740-0865", "address": "9533165 茨城県山口市東区小林町笹田8-1-10", "latitude": -63.52, "longitude": -147.08, "nearest_station": "横浜", "open": "11:00:00", "close": "21:00:00", "wifi": "なし", "created_at": "2018-09-30 02:30:44", "updated_at": "2018-09-30 02:30:44" } }, { "_index": "scout", "_type": "shops", "_id": "22", "_score": 1.0, "_source": { "id": 22, "name": "井高町レーベン", "phone_number": "01816-2-8868", "address": "3132555 高知県渚市東区青山町小泉7-7-1 ハイツ杉山102号", "latitude": -41.58, "longitude": -164.57, "nearest_station": "表参道", "open": "12:00:00", "close": "21:00:00", "wifi": "なし", "created_at": "2018-09-30 02:30:44", "updated_at": "2018-09-30 02:30:44" } }, { "_index": "scout", "_type": "shops", "_id": "24", "_score": 1.0, "_source": { "id": 24, "name": "中村町マンション", "phone_number": "09-6701-8562", "address": "1783857 神奈川県笹田市北区山田町中村8-5-2 コーポ松本104号", "latitude": 32.39, "longitude": -125.44, "nearest_station": "東京", "open": "09:00:00", "close": "20:00:00", "wifi": "なし", "created_at": "2018-09-30 02:30:44", "updated_at": "2018-09-30 02:30:44" } }] } }
artisanでのデータ操作(今回はやる必要なし)
モデルデータの削除とインポートも簡単にできる。
// モデルデータの削除 $ php artisan scout:flush "App\Models\Shop"\n // モデルデータのインポート $ php artisan scout:import "App\Models\Shop"\n
リクエストパラメータを使って検索
ルーティング
routes/web.php
Route::get('/post/search', function () {
return App\Models\Shop::search(\request('q'))->paginate();
});
ローカルサーバ起動
$ php artisan serve
検索結果を表示
http://localhost:8000/post/search?q=静岡
q= の後に検索したいクエリを書いてURLを叩く
実行結果
http://localhost:8000/post/search?q=静岡 だと静岡県の結果が出ない。。。
http://localhost:8000/post/search?q=静 で検索すると出る。
これはインデックスが静岡県を"静" "岡" "県"で区切っているためだと思う。
デフォルトはポンコツなのでチューニングが必要になってきますね。
チューニング編は次回にします!