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


# 概要

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

# 導入

ほぼほぼ [【Laravel】Laravel Scout + Elasticsearchを使った全文検索の実装方法 | Public Constructor](https://public-constructor.com/laravel-scout-with-elasticsearch/) の記事を参考にさせてもらいました。
のでみんなも↑を参考にしたほうがいいです

## Laravelプロジェクト作成

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

## Laravel Scoutのインストール

### Laravel Scoutとは

> Laravel Scout（スカウト、斥候）は、Eloquentモデルへ、シンプルなドライバベースのフルテキストサーチを提供します。モデルオブサーバを使い、Scoutは検索インデックスを自動的にEloquentレコードと同期します。
> 
> 現在、ScoutはAlgoliaドライバを用意しています。カスタムドライバは簡単に書けますので、独自の検索を実装し、Scoutを拡張できます。

参考：[Laravel Scout 5.8 Laravel](https://readouble.com/laravel/5.8/ja/scout.html)

## インストール

```
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（日本語形態素解析エンジン）](https://www.atilika.org/)をさらに導入する必要があるみたいです。
（~~難しいことはわからんので~~ここでは割愛）


## 環境変数の設定

```.env
SCOUT_DRIVER=elasticsearch
ELASTICSEARCH_HOST=http://localhost:9200
```


# Elasticsearchの起動

Dockerを使って動かすのですが筆者環境が**Windows10 Home**の為、VagrantにDockerをインストールするところから書いておきます
参考にさせてもらった記事は以下

- [AzureVM(ubuntu 18)にdocker＆docker-composeをインストール - Qiita](https://qiita.com/quz/items/e40220b6d9eb8cab40bc)

## 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内で`Searchable`を`use`する

```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](https://github.com/laravel/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](https://public-constructor.com/laravel-scout-with-elasticsearch/)

```
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85815/d0155bea-7f77-b988-f4d2-1ee9ddcd4ce5.jpeg)

(※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