業務で使うことになりそうなので、チャチャっと触ってみました。
こちら様を思いっきり参考にさせていただきました、ありがとうございます!
[https://public-constructor.com/laravel-scout-with-elasticsearch/:embed:cite]
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
検索結果を表示
q= の後に検索したいクエリを書いてURLを叩く
実行結果
http://localhost:8000/post/search?q=静岡
だと静岡県の結果が出ない。。。
http://localhost:8000/post/search?q=静
で検索すると出る。
これはインデックスが静岡県を"静" "岡" "県"で区切っているためだと思う。
デフォルトはポンコツなのでチューニングが必要になってきますね。
チューニング編は次回にします!