技術は後からついてくる。

就活開始の半年前にエンジニアに目覚めた人

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

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

public-constructor.com

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

検索結果を表示

http://localhost:8000/post/search?q=静岡

q= の後に検索したいクエリを書いてURLを叩く

実行結果

http://localhost:8000/post/search?q=静岡 だと静岡県の結果が出ない。。。

f:id:ricken0203:20181001133632p:plain

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

f:id:ricken0203:20181001133644p:plain

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