Laravel ScoutのElasticsearchエンジンのパッケージを作ってみる

こんにちはみなさん

プロダクトを作っているときに、こいつは他のプロダクトでも普遍的に使えるなぁってやつが出てきて、そういうのを各プロダクトで実装するのは無駄なので、はなっから外部パッケージにしちゃえってするのが楽だと思ってます。

もちろん、外部パッケージにすると管理するリポジトリが増えるので、極力メンテが必要のないものを外部に逃すのが吉かと思われます。

というわけで、全文検索するのだけど、プロダクトにボコっと入れるのが嫌なので、外部パッケージを作って適当に公開するまでのストーリーを実際にやってみました。

# 三行まとめ

- Laravel Scout の ElasticSearch 用の公式パッケージがないので自作する
- せっかくだから外部パッケージにして公開できるようにする
- 導入して動かしてみた

# 問題設定

前回、[中間テーブルに外部キーを設定しない場合のLaravelにおける性能劣化を検証する](https://qiita.com/niisan-tokyo/items/4db18a19a4cfc0302cbd) というネタをやったのですが、リレーションが深くなるたびに、クエリがめんどくさくなるわ、インデックス貼れてなくて検索性能がガタ落ちしたりと、データが複雑化すると得てしてそれに伴いクエリの構築も複雑化して、ややこしいって思うようになります。
それがさらに文字列の部分一致検索なんてのになるともう、どうしていいやら。

そこで、複雑なリレーションを持つテーブルの検索をするために全文検索を仲介させるという技をやってみましょう。
方針としてはこんな風にしてみます。

- 多対多のリレーションを持つテーブルの部分一致検索をする
- 全文検索エンジンのパッケージとしてLaravel Scoutを使う
- Laravel ScoutのエンジンにElasticSearchがないので、自作したパッケージを作る
- 使ってみる

# Laravel Scout の ElasticSearch のエンジンを作る
パッケージの作り方は以下を参考にする。
https://laravel.com/docs/5.8/packages
Scoutの拡張は以下を参考
https://laravel.com/docs/5.8/scout#custom-engines

## リポジトリを作る

https://github.com/niisan-tokyo/laravel-scoute-elasticsearch

とりあえず。

### composer.json の 設定
LaravelにはService Discovery というのがあって、configで利用するサービスプロバイダーを指定しなくても、自動でサービスを読み込んでくれる機能があります。
これを今回作るパッケージでも有効にするためには以下の設定を`composer.json`に入れます。

```json
    "extra": {
        "laravel": {
            "providers": [
                "Niisan\\Laravel\\Scout\\ServiceProvider"
            ]
        }
    }
```

### ServiceProviderの設定
次はServiceProviderでパッケージの導入をします。

```src/Scout/ServiceProvider.php
<?php
// ~~~
    public function boot()
    {
        resolve(EngineManager::class)->extend('elasticsearch', function () {
            $config = config('scout.elasticsearch');
            return new ElasticSearchEngine(
                ClientBuilder::create()->setHosts($config['hosts'])->build()
            );
        });
    }
```
これで、`config/scout.php`に`elasticsearch`をドライバーに登録できて、全文検索に`ElasticSearchEngine` を使うように指定できました。

### ElasticSearchエンジンの作成
最後にメインとなるエンジンの実装をします。
公式では`Laravel\Scout\Engines\Engine` を継承した上で、以下のメソッドを実装するように支持されています。

```php
use Laravel\Scout\Builder;

abstract public function update($models);
abstract public function delete($models);
abstract public function search(Builder $builder);
abstract public function paginate(Builder $builder, $perPage, $page);
abstract public function mapIds($results);
abstract public function map($results, $model);
abstract public function getTotalCount($results);
abstract public function flush($model);
```
何をどう実装すればいいのか、具体的によくわからんところですが、公式によると、「`Algoria`の実装がそばにあるから、それ参考にしてね！」って書いてあります。

>You may find it helpful to review the implementations of these methods on the Laravel\Scout\Engines\AlgoliaEngine class. This class will provide you with a good starting point for learning how to implement each of these methods in your own engine

幸いなことに、`laravel-scout-elastic`って検索するといっぱい出てくるので、これを参考にすれば実装できます。
これを使えばいいんじゃないかって思ったりもしたんだけど、何故かエラーが出て動かなかったりしたので、自作してます。

実装をここに書くと長いので、省略。リンクだけ貼っておきます。
https://github.com/niisan-tokyo/laravel-scoute-elasticsearch/blob/master/src/Scout/Engines/ElasticSearchEngine.php

オリジナリティを出したというか、動かなくて出さざるを得なかったところは、以下のコードです

```ElasticSearchEngine.php
    private function performSearch(Builder $builder, $options = [])
    {
        $search = explode(' ', str_replace('　', ' ', $builder->query['search']));

        $query = [
            'query' => [
                'query_string' => [
                    'query' => '*' . implode('* AND *', $search) . '*'
                ]
            ]
        ];
//~~~
    }
```


# 動かしてみる
作ったら動かします。
[前回のインデックスの検証で使った環境](https://qiita.com/niisan-tokyo/items/4db18a19a4cfc0302cbd)を使いまわします。

## docker-composeで環境を構築する
検証環境はdocker-composeでさっさと作っちゃいます。

```docker-compose.yml
version: '3'

services:
  workspace:
    build: workspace/
    volumes: 
      - ../scout-elastic:/var/www
    tty: true
    ports:
      - 8080:8080

  elasticsearch:
    image: elasticsearch:7.1.1
    environment:
      discovery.type: single-node

  mysql:
    image: mysql:5.7
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: homestead
      MYSQL_PASSWORD: secret
      MYSQL_USER: homestead

```
workspaceは、例によってPHP-CLIにcomposer突っ込んだ環境になっていて、

```Dockerfile
FROM composer as composer

FROM php

RUN apt update && apt install -y git curl unzip && \
    docker-php-ext-install mysqli pdo_mysql

COPY --from=composer /usr/bin/composer /usr/bin/composer

RUN composer global require hirak/prestissimo

WORKDIR /var/www

CMD ["sleep", "infinity"]
```
こんな感じで定義しています。
マルチステージビルドでcomposerがかんたんにインストールできてこっそり喜んでます。

## パッケージのインストール
自分で作ったパッケージを、一旦packagistに登録します。
登録の仕方は[前書いたので省略します](https://qiita.com/niisan-tokyo/items/a6a3ee984f0ccb6c734d)。
パッケージ名は`niisan-tokyo/laravel-scout-elastic`となりました。
余談ですが、`laravel-scout-elastic`っていうパッケージが現時点で他に21個もありました。
車輪の再発明ですなぁ。。。

それはいいとして、インストールします。

```
composer require composer require niisan-tokyo/laravel-scout-elastic
```
この時点でLaravel Scoutも一緒に入ってきていると思います。
サービスディスカバリが働いていれば、導入後に以下の表示が出ます。

```
Discovered Package: beyondcode/laravel-dump-server
Discovered Package: fideloper/proxy
Discovered Package: laravel/scout
Discovered Package: laravel/tinker
Discovered Package: nesbot/carbon
Discovered Package: niisan-tokyo/laravel-scout-elastic
Discovered Package: nunomaduro/collision
```
自分のパッケージもしっかり見つけてくれてますね


## 設定する
一旦以下のコマンドで`config/scout.php`を作っておきます。

```
php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"
```
で、作成された設定ファイルで、以下のように設定を書き足します。

```config/scout.php
    'elasticsearch' => [
        'hosts' => explode(',', env('SCOUT_ELASTIC_HOST', '127.0.0.1')),
    ],
```
`.env`で以下の環境変数を追加して設定完了です。

```.env
SCOUT_DRIVER=elasticsearch
SCOUT_ELASTIC_HOST=elasticsearch:9200
```
## 実装する
検索の実装をしていきます。
まず、モデルをこんなふうに書きます。

```Student.php
<?php

namespace App\Entities;

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

class Student extends Model
{

    use Searchable;

    public function items()
    {
        return $this->belongsToMany(Item::class);
    }

    public function toSearchableArray()
    {
        return [
            'name' => $this->first_name . $this->last_name,
            'item_name' => $this->items->pluck('name')->values()->all()
        ];
    }
}
```
この状態では、まだインデックスが作られていないので、バッチでインデックスを作っていきます。

```
php artisan scout:import "App\Entities\Student"
```
これで準備完了です。

実際の検索はコントローラやルーターで

```IndexController.php
    public function search(Request $req)
    {
        $search = $req->only('search');
        $query = ($search) ? Student::search($search): Student::query();

        return $query->get()->load('items');
    }
```
こんな感じで書いておけばオッケーです。

## 試してみる
ルーティングを`/student/search`にして、いくつか試してみます。

```
$ curl http://localhost:8080/student/search?search=oni  
[{"id":559,"first_name":"vO3eRq","last_name":"Oe6ZdK","created_at":null,"updated_at":null,"items":[{"id":1000,"name":"onigiri","created_at":null,"updated_at":null,"pivot":{"student_id":559,"item_id":1000}},{"id":140,"name":"vmY9Qh","created_at":null,"updated_at":null,"pivot":{"student_id":559,"item_id":140}}]}]
$ curl http://localhost:8080/student/search?search=John Doe 
[{"id":1000,"first_name":"John","last_name":"Doe","created_at":null,"updated_at":null,"items":[{"id":205,"name":"q8h6ZO","created_at":null,"updated_at":null,"pivot":{"student_id":1000,"item_id":205}},{"id":745,"name":"QLHUoV","created_at":null,"updated_at":null,"pivot":{"student_id":1000,"item_id":745}}]}]
```
こんな感じでした。

## 注意点
Laravel Scoutの内部を見たわけですが、その動きは以下のとおりです。

- 全文検索する
- 検索結果からidを抜き出す
- そのidを使ってRDBからデータを取り出す

ここで、**ページネーションもやっています**。ということは他の絞り込みとかが入ると、ページネーションがうまく動かなくなるかもしれないということです。
検索は全部Scoutにやらせて、RDBはデータの整合性を保つために存在させるという感じになるかもしれません。

# まとめ
というわけで、Laravel ScoutのElasticSearchエンジンのパッケージを作って公開して導入する一連の流れをやりました。
まだ、Laravel Scout自体がどのような動きをするのかを完全につかめているわけではないですが、その点については後で掘り下げていこうかと考えています。

今回はそんなところです。

# 参考
[Laravel Scout](https://laravel.com/docs/5.8/scout)
[Laravelのパッケージ開発](https://laravel.com/docs/5.8/packages)
[先駆者様の記録](https://qiita.com/sola-msr/items/64d57d3970b715c795f5)
[パッケージ公開している人の例](https://github.com/ErickTamayo/laravel-scout-elastic)