🔍

Laravel で Algolia を利用していくつかのモデルのデータを混ぜて検索する方法

2021/08/26に公開

オンライン家庭教師マナリンク 開発の Technote です。

今回は Laravel で Algolia を利用していくつかのモデルのデータを混ぜて検索する方法を紹介します。

multiple models

Laravel Scout

Algolia の導入自体は Laravel Scout を利用すると、モデルの更新・削除の際に検索に必要なデータの同期を自動でやってくれるのでとても簡単です。

https://laravel.com/docs/8.x/scout

ただ、今回はいくつかのモデルのデータを混ぜて index を作成したいので Laravel Scout だけでは難しいです。

Scout Extended

自分で実装すると大変そうですが、Algolia のドキュメントにもやり方があるように Scout Extended というパッケージを利用すると簡単に実装できます。

https://www.algolia.com/doc/framework-integration/laravel/advanced-use-cases/multiple-models-in-one-index/?client=php

https://github.com/algolia/scout-extended

導入方法

1. パッケージの追加

composer require algolia/scout-extended

2. Aggregator の生成

php artisan scout:make-aggregator [Aggregator名]

このコマンドで app/SearchAggregator を継承したクラスが生成されます。

ここでは AutocompleteItem で生成したとして話を進めます。

3. models に検索対象にしたいモデルを追加

namespace App\Search;

use Algolia\ScoutExtended\Searchable\Aggregator;
use App\Models\CourseFeature; // コースの特徴
use App\Models\Subject; // 科目
use App\Models\Teacher; // 先生

class AutocompleteItem extends Aggregator
{
    protected $models = [
        CourseFeature::class,
        Subject::class,
        Teacher::class,
    ];
}

4. AggregatorbootSearchable を呼び出し

Model で use された Searchable などの Trait に関しては boot[Trait名] が Laravel によって勝手に呼ばれますが、AggregatorAppServiceProvider などで bootSearchable を明示的に呼び出す必要があります。

class AppServiceProvider extends ServiceProvider
{
    public function boot()
    {
        AutocompleteItem::bootSearchable();
    }
}

$models に追加するモデルは Searchable を use すべきかどうか

https://www.algolia.com/doc/framework-integration/laravel/advanced-use-cases/multiple-models-in-one-index/?client=php#conditionally-sync-an-aggregator

ドキュメント内では特に言及はないですが、shouldBeSearchable の実装例を見ると Searchable を use しているかどうかで分岐させています。

    public function shouldBeSearchable()
    {
        // Check if the class uses the Searchable trait before calling shouldBeSearchable
        if (array_key_exists(Searchable::class, class_uses($this->model))) {
            return $this->model->shouldBeSearchable();
        }
    }

shouldBeSearchable は同期するかどうかの判定を実装するメソッドです。
例えば先生が一つもコースを作成していない場合は同期しないなどの条件を記述します。

これは Aggregator からそれぞれのモデルの shouldBeSearchable を呼ぶための実装ですが、これだけを見ると Searchable の use が必要なように思えます。

ただ、実装を追うとわかりますがそれぞれのモデルでの Searchable の use は必要ありません。

逆に Searchable を use すると Searchable での Observer (ModelObserver) と Aggregator での Observer (AggregatorObserver) をそれぞれ observe することになり、 Aggregator での index とは別にそのモデルの index も作成されてしまいます。

saved => searchable (Model) => $this->newCollection([$this])->searchable() => macro 登録された searchable が起動 => queueMakeSearchable で同期

saved => searchable (Aggregator) => AggregatorCollection::make([$this])->searchable() => macro 登録された searchable が起動 => queueMakeSearchable で同期

disableSyncingFor を使用するとそれも防ぐことができますが、そもそもそのモデルだけで検索しない場合は use しなければいいだけです。

それぞれのモデルでは Searchable を use せず、AggregatorshouldBeSearchable の実装を以下のように method_exists による判定にすれば色々とうまくいきました。

    public function shouldBeSearchable()
    {
        if (method_exists($this->model, 'shouldBeSearchable')) {
            return $this->model->shouldBeSearchable();
        }

        return false;
    }

それぞれのモデルで Searchable を use していなくても toSearchableArray で同期するデータを整形することもできます。

関連データの同期について

今回の AutocompleteItem の例での CourseFeature のコースの特徴は、紐づく公開されたコースがない場合は同期させない要件でした。

よりイメージしやすいように例えば「記事」とそれに紐づく「タグ」を考えます。
公開された記事が一つでも紐付いているタグだけを同期したい場合、タグの操作だけではなく、記事の作成、削除や公開フラグの更新などでもタグの同期処理が実行される必要があります。

https://www.algolia.com/doc/framework-integration/laravel/indexing/configure-searchable-data/?client=php#updating-relations-when-parentchild-change

ここで紹介されているように $touches を使用したり saved イベントを購読することである程度実装できます。

ただし $touches は更新日時カラムが存在しなければいけない点に注意が必要です。

また saved イベントを購読する場合にもモデルの save とタグの sync の実行順序に注意が必要です。

以下のように更新と作成を共通化している場合はうまくいきません。

function save(Article $article, $entity) {
  $article->title = $entity->getTitle();
  // ...
  $article->save(); // saved イベント発火(まだ tags は同期されていない)

  $article->tags()->sync($entity->getTags());
}

function create($entity) {
  save(new Article(), $entity);
}

function update($entity) {
  save(Article::findOrFail($entity->getId()), $entity);
}

またこの例ではタグが外される場合(≠タグの削除)も考慮する必要があるので Pivot化して中間テーブルの deleted イベントを購読するなどの工夫も必要です。

まとめ

Scout Extended を利用すると複数のモデルを混ぜた検索も簡単に導入できますが、関連データの同期なども考えると注意すべきことも少なくないです。

もっと Algolia の技術記事が増えてくれると嬉しいです。

マナリンク Tech Blog

Discussion