こんにちはみなさん
プロダクトを作っているときに、こいつは他のプロダクトでも普遍的に使えるなぁってやつが出てきて、そういうのを各プロダクトで実装するのは無駄なので、はなっから外部パッケージにしちゃえってするのが楽だと思ってます。
もちろん、外部パッケージにすると管理するリポジトリが増えるので、極力メンテが必要のないものを外部に逃すのが吉かと思われます。
というわけで、全文検索するのだけど、プロダクトにボコっと入れるのが嫌なので、外部パッケージを作って適当に公開するまでのストーリーを実際にやってみました。
三行まとめ
- Laravel Scout の ElasticSearch 用の公式パッケージがないので自作する
- せっかくだから外部パッケージにして公開できるようにする
- 導入して動かしてみた
問題設定
前回、中間テーブルに外部キーを設定しない場合のLaravelにおける性能劣化を検証する というネタをやったのですが、リレーションが深くなるたびに、クエリがめんどくさくなるわ、インデックス貼れてなくて検索性能がガタ落ちしたりと、データが複雑化すると得てしてそれに伴いクエリの構築も複雑化して、ややこしいって思うようになります。
それがさらに文字列の部分一致検索なんてのになるともう、どうしていいやら。
そこで、複雑なリレーションを持つテーブルの検索をするために全文検索を仲介させるという技をやってみましょう。
方針としてはこんな風にしてみます。
- 多対多のリレーションを持つテーブルの部分一致検索をする
- 全文検索エンジンのパッケージとして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
リポジトリを作る
とりあえず。
composer.json の 設定
LaravelにはService Discovery というのがあって、configで利用するサービスプロバイダーを指定しなくても、自動でサービスを読み込んでくれる機能があります。
これを今回作るパッケージでも有効にするためには以下の設定をcomposer.json
に入れます。
"extra": {
"laravel": {
"providers": [
"Niisan\\Laravel\\Scout\\ServiceProvider"
]
}
}
ServiceProviderの設定
次はServiceProviderでパッケージの導入をします。
<?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
を継承した上で、以下のメソッドを実装するように支持されています。
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
オリジナリティを出したというか、動かなくて出さざるを得なかったところは、以下のコードです
private function performSearch(Builder $builder, $options = [])
{
$search = explode(' ', str_replace(' ', ' ', $builder->query['search']));
$query = [
'query' => [
'query_string' => [
'query' => '*' . implode('* AND *', $search) . '*'
]
]
];
//~~~
}
動かしてみる
作ったら動かします。
前回のインデックスの検証で使った環境を使いまわします。
docker-composeで環境を構築する
検証環境はdocker-composeでさっさと作っちゃいます。
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突っ込んだ環境になっていて、
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に登録します。
登録の仕方は前書いたので省略します。
パッケージ名は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"
で、作成された設定ファイルで、以下のように設定を書き足します。
'elasticsearch' => [
'hosts' => explode(',', env('SCOUT_ELASTIC_HOST', '127.0.0.1')),
],
.env
で以下の環境変数を追加して設定完了です。
SCOUT_DRIVER=elasticsearch
SCOUT_ELASTIC_HOST=elasticsearch:9200
実装する
検索の実装をしていきます。
まず、モデルをこんなふうに書きます。
<?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"
これで準備完了です。
実際の検索はコントローラやルーターで
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自体がどのような動きをするのかを完全につかめているわけではないですが、その点については後で掘り下げていこうかと考えています。
今回はそんなところです。