Laravel(5.5) + Scout + Elasticsearchで全文検索 (導入編)

業務で使うことになりそうなので、チャチャっと触ってみました。  
こちら様を思いっきり参考にさせていただきました、ありがとうございます！

[https://public-constructor.com/laravel-scout-with-elasticsearch/:embed:cite]

## Laravelと必要なライブラリを準備
ターミナルでComposerのcreate-projectコマンドを実行  
ここでは仮に物件探しのサイトを作るということで  
プロジェクト名をfind_houseとする。

```terminal
// ver5.5を指定　　
$ composer create-project --prefer-dist laravel/laravel [project_name] "5.5.*"

// 動作確認など
$ php artisan serve
$ cd find_house
```

### ライブラリをインストール

```terminal
$ composer require laravel/scout
$ composer require elasticsearch/elasticsearch
```

### Scoutの設定ファイル生成

```terminal
// config配下にscout.phpを生成
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'),
      ],
  ]
];
```

### プロバイダー作成

```terminal
php artisan make:provider ElasticsearchServiceProvider
```

app/Providers/ElasticsearchServiceProvider.php

```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

```php
'providers' => [

    // 中略

    /*
     * Package Service Providers...
     */
    App\Providers\ElasticsearchServiceProvider::class,
],
```

## カスタムエンジンを作成
performSearch()でElasticsearchで実行するクエリを設定する。  
ここではaddressに対してbool queryを発行する。

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


    /**
     * 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で作ります。

```terminal
$ mysql -u root

MariaDB> CREATE DATABASE `findhome`
MariaDB> CHARACTER SET utf8mb4
MariaDB> COLLATE utf8mb4_unicode_ci;
```

### migrationファイル作成

```terminal
$ php artisan make:migration create_shops_table
```

### migrate実行

```terminal
$ php artisan migrate
```

## モデル作成
```=
$ php artisan make:model Models/Shop
```

### 作成したモデルに use Searchable を追加

```php
namespace App\Models;

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

class Post extends Model
{
    use Searchable;
}
```

## Elasticsearchを起動

Dockerを使います。

### ルート配下にdocker-compose.ymlを作成

```yaml
version: '2'
services:
  elasticsearch:
    image: elasticsearch:5.6
    volumes:
      - ./elasticsearch-data:/usr/share/elasticsearch/data
    ports:
      - "9200:9200"
```

### DockerでElasticsearchコンテナを起動する

```terminal
// コンテナ起動
$ docker-compose up -d

// 起動しているか確認
$ docker container ls
```

### Elasticsearchの動作確認

* curlを使用してデータを追加する

```terminal
$ curl -X PUT http://localhost:9200/test_index/httpd_access/1 -d '{"host": "localhost", "response": "200", "request": "/"}'
```

* 追加したデータの取得

```terminal
$ curl -X GET http://localhost:9200/test_index/_search -d '{"query": { "match_all": {} } }'
```

期待される結果

```json
{
  "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に登録するテスト用のデータを生成する

```terminal
$ php artisan make:factory ShopFactory
```

database/factories/ShopFactory.phpを追加する
ここでは、fakerを使用してダミーデータを作成する(faker便利...)

```php
<?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'で指定された名称で  
インデックスが作成されていることを確認する

```terminal
$ 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
```

### 登録済みのデータを取得する
```terminal
$ curl -X GET http://localhost:9200/scout/_search -d '{"query": { "match_all": {} } }'
```

```json
{
  "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でのデータ操作(今回はやる必要なし)
モデルデータの削除とインポートも簡単にできる。

```terminal
// モデルデータの削除
$ php artisan scout:flush "App\Models\Shop"\n

//　モデルデータのインポート
$ php artisan scout:import "App\Models\Shop"\n
```

## リクエストパラメータを使って検索

### ルーティング

routes/web.php

```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=静岡
だと静岡県の結果が出ない。。。

<img width="512" alt="1.png" src="https://qiita-image-store.s3.amazonaws.com/0/159449/01beac39-16ad-752f-6765-521fb4da0664.png">

http://localhost:8000/post/search?q=静
で検索すると出る。

<img width="385" alt="2.png" src="https://qiita-image-store.s3.amazonaws.com/0/159449/6145d0ea-f531-89d1-7e5e-c91e48c95c7d.png">

これはインデックスが静岡県を"静" "岡" "県"で区切っているためだと思う。  
デフォルトはポンコツなのでチューニングが必要になってきますね。  
チューニング編は次回にします！