LoginSignup
13
15

More than 5 years have passed since last update.

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

Last updated at Posted at 2019-10-02

業務で使うことになりそうなので、チャチャっと触ってみました。

こちら様を思いっきり参考にさせていただきました、ありがとうございます!

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

1.png

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

2.png

これはインデックスが静岡県を"静" "岡" "県"で区切っているためだと思う。

デフォルトはポンコツなのでチューニングが必要になってきますね。

チューニング編は次回にします!

13
15
1

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
13
15