LoginSignup
20
18

More than 3 years have passed since last update.

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

Last updated at Posted at 2019-06-19

概要

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

導入

ほぼほぼ 【Laravel】Laravel Scout + Elasticsearchを使った全文検索の実装方法 | Public Constructor の記事を参考にさせてもらいました。
のでみんなも↑を参考にしたほうがいいです

Laravelプロジェクト作成

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

Laravel Scoutのインストール

Laravel Scoutとは

Laravel Scout(スカウト、斥候)は、Eloquentモデルへ、シンプルなドライバベースのフルテキストサーチを提供します。モデルオブサーバを使い、Scoutは検索インデックスを自動的にEloquentレコードと同期します。

現在、ScoutはAlgoliaドライバを用意しています。カスタムドライバは簡単に書けますので、独自の検索を実装し、Scoutを拡張できます。

参考:Laravel Scout 5.8 Laravel

インストール

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

環境変数の設定

SCOUT_DRIVER=elasticsearch
ELASTICSEARCH_HOST=http://localhost:9200

Elasticsearchの起動

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

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内でSearchableuseする

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

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

(※Chromeのdeveloperツールから検索結果を見た状態です)

無事取得できることを確認できました。

おわり

  • Elasticsearchわかってわからん

参考URL

20
18
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
20
18