Настраиваем Elasticsearch в Laravel

Использование Elasticsearch в Laravel

Поиск — важная часть приложения, и некоторые его пропускают, считая простой задачей. «Да просто добавлю несколько LIKE и готово!». И, хотя LIKE на самом деле может быть удобным, иногда нам приходится использовать более надежную поисковую систему.

Одним из самых популярных способов является использование Elasticsearch. Это очень мощный инструмент, он поставляется с множеством полезных функций и дополнительных инструментов. Здесь мы рассмотрим основы и дадим несколько ссылок на дополнительные ресурсы, если вы вдруг захотите узнать больше.

Что такое Elasticsearch?

С официального сайта :

Elasticsearch — это распределенный поисковый и аналитический движок, лежащий в основе Elastic Stack. Logstash и Beats облегчают сбор, агрегацию и обогащение ваших данных и их хранение в Elasticsearch. Kibana позволяет вам в интерактивном режиме исследовать, визуализировать и обмениваться информацией о ваших данных, а также управлять и контролировать стек. Elasticsearch — это место, где происходит волшебство индексации, поиска и анализа.

Другими словами: мы можем использовать Elasticsearch для логирования (см. Elastic Stack) и для поиска. В этой статье мы рассматриваем только использование поиска.

Основы Elasticsearch

Чтобы лучше понять этот инструмент, лучше начать с нуля, без сравнения с SQL. Прежде всего, Elasticsearch документоориентирован и поддерживает REST, поэтому его можно использовать на любом языке. Давайте углубимся в его основные понятия.

Индекс и типы

Документоориентированность подразумевает, что мы ищем, сортируем, фильтруем и т.д. Документы. Документ представлен в формате JSON и содержит информацию, которая может быть проиндексирована. Обычно мы храним (индексируем) документы с похожими структурами отображения (полями) вместе, это мы и называем индексом. Например, может быть один индекс для пользователей, другой для статей, третий для товаров.

Внутри индекса могут быть один или несколько типов. У нас обычно один тип, но иногда полезно иметь несколько. Допустим, есть сущность Contact, которая является родителем сущностей Lead и Vendor. Хотя мы могли бы хранить обе сущности в одном и том же типе «contacts» внутри индекса «contacts», но было бы лучше хранить их в отдельных типах, поэтому делаем типы «leads» and «vendors».

Стоит сказать, что Elasticsearch не имеет схемы, но не является бессхемным, это означает, что мы можем индексировать все, что захотим, и это определит типы данных, но мы не можем иметь одно и то же поле, содержащее разные типы данных. Чтобы получить качественные результаты поиска и избежать непредвиденного поведения, мы должны определить типы данных для каждого поля и придерживаться их.

Более подробно вы можете прочесть в документации.

Локальная среда

Предполагаю, что у вас еще не запущен Elasticsearch на вашем локальном компьютере. Мы собираемся использовать Docker, но не волнуйтесь, вы можете запустить его без Docker, следуя официальной документации,  я просто хотел еще немного поиграться с Docker.

Установите Docker и docker-compose на свой компьютер и запустите:

docker run -d -e "discovery.type=single-node" \
    -e "bootstrap.memory_lock=true" \
    -p 9200:9200 \
    elasticsearch:6.8.1

Если у вас нет образа Elasticsearch Docker на компьютере, то он возьмет его из реестра, поэтому в первый раз это может занять некоторое время.

  • флаг -d означает, что мы хотим запустить его отдельно, другими словами: не блокируя терминал;
  • флаг -e устанавливает некоторые переменные окружения в контейнере. Нам нужны здесь две, одна для того, чтобы указать, что это кластер с одним хостом, а другая включает SWAP, если Elasticsearch не хватает памяти;
  • параметр -p 9200:9200 говорит Docker, что мы хотим, чтобы порт 9200 привязан к нашему localhost:9200.

Если у вас есть какие-либо проблемы с настройками vm_max_map_count, то смотрите документацию.

Если всё хорошо, то вы можете сделать curl-запрос для проверки работы сервера Elasticsearch:

curl localhost:9200

{
  "name" : "BtziAML",
  "cluster_name" : "docker-cluster",
  "cluster_uuid" : "qW73OEpQSq-k7uCsc3gCnQ",
  "version" : {
    "number" : "6.8.1",
    "build_flavor" : "default",
    "build_type" : "docker",
    "build_hash" : "1fad4e1",
    "build_date" : "2019-06-18T13:16:52.517138Z",
    "build_snapshot" : false,
    "lucene_version" : "7.7.0",
    "minimum_wire_compatibility_version" : "5.6.0",
    "minimum_index_compatibility_version" : "5.0.0"
  },
  "tagline" : "You Know, for Search"
}

Подобный ответ означает, что можно продолжать дальше. Если вы не получаете ответа, возможно, ваш сервер Elasticsearch всё еще загружается. Проверьте, работает ли сам контейнер с помощью docker ps (так и должно быть).

Демо-приложение

Для начала нам нужные ДАННЫЕ для использования Elasticsearch, поэтому в этом примере у нас есть команда seeder, которая заполняет базу данных и, одновременно, индексирует все данные в Elasticsearch (через Наблюдателя — Observer). Я покажу это позже, сначала давайте посмотрим, как мы можем интегрировать это с нашими моделями Eloquent.

Вы можете создать приложение Laravel с помощью composer или с помощью установщика Laravel, например так:

laravel new es-laravel-example

Теперь у вас есть готовое приложение Laravel. Но, так как мы собираемся использовать Elasticsearch в этом приложении, давайте установим его

composer require elasticsearch/elasticsearch

Генерируем каркас аутентификации

php artisan make:auth

Если вы запустите php artisan serve и зайдёте в браузере на localhost:8000, то вы увидите страницу приветствия:

Использование ElasticSearch в Laravel

Для демки мы собираемся использовать концепцию статей. Итак, нам нужно создать модель Article и ее миграцию. Мы можем сделать это, выполнив: php artisan make:model -mf Article, где флаг -m указывает создать миграцию для этой модели, а -f создает фабрику моделей.

Нужно настроить миграцию, в папке database/migrations/ должен быть новый файл с именем create_articles_table и префиксом DateTime.
Откройте его и установите поля, которые мы будем использовать, например, так:

Schema::create('articles', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->string('title');
            $table->text('body');
            $table->json('tags');
            $table->timestamps();
        });

Поскольку мы используем поле tags как массив, нам необходимо настроить под это нашу модель.
Откройте модель Article.php и пропишите:

namespace App;

use Illuminate\Database\Eloquent\Model;

class Article extends Model
{
    protected $casts = [
        'tags' => 'json',
    ];
}

Нужно создать сидер для нашей модели, для генерирования данных. Запустите php artisan make:seeder ArticlesTableSeeder и откройте его. Внутри метода run() нужно добавить эти две строки:

use Illuminate\Database\Seeder;

class ArticlesTableSeeder extends Seeder
{
    public function run()
    {
        DB::table('articles')->truncate();
        factory(App\Article::class)->times(50)->create();
    }
}

После создания ArticlesTableSeeder зарегистрируйте его в database/seeds/DatabaseSeeder.php:

public function run()
{
    $this->call(ArticlesTableSeeder::class);
}

Сидер использует функциональность ларавельной Model Factory для того, чтобы создать 50 фейковых статей. Но мы это еще не настроили. Откройте файл database/factories/ArticleFactory.php и добавьте новую запись:

/* @var $factory \Illuminate\Database\Eloquent\Factory */

use App\Article;
use Faker\Generator as Faker;

$factory->define(Article::class, function (Faker $faker) {
    $tags = collect(['php', 'ruby', 'java', 'javascript', 'bash'])
        ->random(2)
        ->values()
        ->all();

    return [
        'title' => $faker->sentence(),
        'body' => $faker->text(),
        'tags' => $tags,
    ];
});

Добавим шаблон и маршрут, чтобы мы можно было выводить список статей:

@extends('layouts.app')

@section('content')
    <div class="container">
        <div class="card">
            <div class="card-header">
                Articles <small>({{ $articles->count() }})</small>
            </div>
            <div class="card-body">
                @forelse ($articles as $article)
                    <article class="mb-3">
                        <h2>{{ $article->title }}</h2>

                        <p class="m-0">{{ $article->body }}</body>

                        <div>
                            @foreach ($article->tags as $tag)
                                <span class="badge badge-light">{{ $tag}}</span>
                            @endforeach
                        </div>
                    </article>
                @empty
                    <p>No articles found</p>
                @endforelse
            </div>
        </div>
    </div>
@stop

Мы используем директиву blade, которая выполняет цикл с $articles и выводит список статей.

Пришло время запустить наш сидер. Но мы еще не настроили нашу базу данных, давайте используем SQLite только потому, что она проще. Запустите:

touch database/database.sqlite

В файле .env чтобы изменить учетные данные БД на:

DB_CONNECTION=sqlite

Удалите все остальные строки с DB_*. Остановим через php artisan serve (если он все еще работает) и запустим снова для перезагрузки конфигов. Пришло время начать миграцию и заполнить нашу базу данных. Запустите php artisan migrate --seed. Откройте приложение в своем браузере (на localhost: 8000). Вы должны увидеть что-то вроде этого:

Использование ElasticSearch в Laravel

Давайте реализуем конечную точку поиска. Сначала через простой SQL. Интерфейс репозитория для извлечения данных будет примерно таким:

namespace App\Articles;

use Illuminate\Database\Eloquent\Collection;

interface ArticlesRepository
{
    public function search(string $query = ''): Collection;
}

Реализация Eloquent будет такая:

namespace App\Articles;

use App\Article;
use Illuminate\Database\Eloquent\Collection;

class EloquentRepository implements ArticlesRepository
{
    public function search(string $query = ''): Collection
    {
        return Article::query()
            ->where('body', 'like', "%{$query}%")
            ->orWhere('title', 'like', "%{$query}%")
            ->get();
    }
}

Забиндим интерфейс в AppServiceProvider следующим образом:

namespace App\Providers;

use App\Articles;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     *
     * @return void
     */
    public function register()
    {
        $this->app->bind(
            Articles\ArticlesRepository::class,
            Articles\EloquentRepository::class
        );
    }
}

Круто. Теперь добавим поле поиска в шаблон:

@extends('layouts.app')

@section('content')
    <div class="container">
        <div class="card">
            <div class="card-header">
                Articles <small>({{ $articles->count() }})</small>
            </div>
            <div class="card-body">
                <form action="{{ url('search') }}" method="get">
                    <div class="form-group">
                        <input
                            type="text"
                            name="q"
                            class="form-control"
                            placeholder="Search..."
                            value="{{ request('q') }}"
                        />
                    </div>
                </form>
                @forelse ($articles as $article)
                    <article class="mb-3">
                        <h2>{{ $article->title }}</h2>

                        <p class="m-0">{{ $article->body }}</body>

                        <div>
                            @foreach ($article->tags as $tag)
                                <span class="badge badge-light">{{ $tag}}</span>
                            @endforeach
                        </div>
                    </article>
                @empty
                    <p>No articles found</p>
                @endforelse
            </div>
        </div>
    </div>
@stop

Должно получиться так:

Использование ElasticSearch в Laravel

Работает! Отлично! И теперь, наконец, можно реализовать поиск через Elasticsearch.

Интеграция Elasticsearch

Поскольку Elasticsearch говорит о REST, то будем подключаться к моделям Eloquent, которые нужно проиндексировать и отправим HTTP-запросы в API Elasticsearch. Концепции, описанные здесь, я взял из Laracon Talk. Используется Laravel, но концепции могут быть применены к любому языку/фреймворку.

В этом примере мы будем использовать Наблюдателей (Model Observers). У нас есть обычная модель Eloquent, в нашем случае — Article. Мы можем написать трейт и наблюдателя, которые будут обрабатывать индексацию для всех наших моделей (тех, которые используют этот трейт), поэтому у нас будет что-то вроде:

namespace App\Search;

use App\Article;
use Elasticsearch\Client;

class ElasticsearchObserver
{
    /** @var \Elasticsearch\Client */
    private $elasticsearch;

    public function __construct(Client $elasticsearch)
    {
        $this->elasticsearch = $elasticsearch;
    }

    public function saved($model)
    {
        $this->elasticsearch->index([
            'index' => $model->getSearchIndex(),
            'type' => $model->getSearchType(),
            'id' => $model->getKey(),
            'body' => $model->toSearchArray(),
        ]);
    }

    public function deleted($model)
    {
        $this->elasticsearch->delete([
            'index' => $model->getSearchIndex(),
            'type' => $model->getSearchType(),
            'id' => $model->getKey(),
        ]);
    }
}

Нужно привязать этого наблюдателя ко всем нашим моделям, которые мы хотим индексировать в Elasticsearch. Сделаем это, введя новый трейт для поиска. Он также предоставит методы, которые использует наблюдатель.

trait Searchable
{
    public static function bootSearchable()
    {
        // Это облегчает переключение флага поиска.
        // Будет полезно позже при развертывании 
        // новой поисковой системы в продакшене
        if (config('services.search.enabled')) {
            static::observe(ElasticsearchObserver::class);
        }
    }

    public function getSearchIndex()
    {
        return $this->getTable();
    }

    public function getSearchType()
    {
        if (property_exists($this, 'useSearchType')) {
            return $this->useSearchType;
        }

        return $this->getTable();
    }

    public function toSearchArray()
    {
        // Наличие пользовательского метода
        // преобразования модели в поисковый массив
        // позволит нам настраивать данные
        // которые будут доступны для поиска
        // по каждой модели.
        return $this->toArray();
    }
}

Зарегистрируем Наблюдателя в нашей модели:

namespace App;

use App\Search\Searchable;
use Illuminate\Database\Eloquent\Model;

class Article extends Model
{
    use Searchable;

    protected $casts = [
        'tags' => 'json',
    ];
}

Теперь, когда мы создаем, обновляем или удаляем сущность, используя нашу модель Eloquent Article, это запускает Elasticsearch Observer для обновления своих данных в Elasticsearch. Обратите внимание, что это происходит синхронно во время HTTP-запроса, лучше использовать очереди и обработчик Elasticsearch, который асинхронно индексирует данные, чтобы не замедлять пользовательский запрос.

Репозиторий Elasticsearch

Теперь мы можем cкормить Elasticsearch наши данные. Оставим SQL-реализацию поиска как резервную, на случай сбоя серверов Elasticsearch. Создадим другую реализацию интерфейса репозитория:

namespace App\Articles;

use App\Article;
use Elasticsearch\Client;
use Illuminate\Support\Arr;
use Illuminate\Database\Eloquent\Collection;

class ElasticsearchRepository implements ArticlesRepository
{
    /** @var \Elasticsearch\Client */
    private $elasticsearch;

    public function __construct(Client $elasticsearch)
    {
        $this->elasticsearch = $elasticsearch;
    }

    public function search(string $query = ''): Collection
    {
        $items = $this->searchOnElasticsearch($query);

        return $this->buildCollection($items);
    }

    private function searchOnElasticsearch(string $query = ''): array
    {
        $model = new Article;

        $items = $this->elasticsearch->search([
            'index' => $model->getSearchIndex(),
            'type' => $model->getSearchType(),
            'body' => [
                'query' => [
                    'multi_match' => [
                        'fields' => ['title^5', 'body', 'tags'],
                        'query' => $query,
                    ],
                ],
            ],
        ]);

        return $items;
    }

    private function buildCollection(array $items): Collection
    {
        $ids = Arr::pluck($items['hits']['hits'], '_id');

        return Article::findMany($ids)
            ->sortBy(function ($article) use ($ids) {
                return array_search($article->getKey(), $ids);
            });
    }
}

Мы выбрали поиск Elasticsearch, а затем выполнили поиск findMany SQL с полученными элементами. В предыдущих версиях этой статьи я рассмотрел еще один вариант, где мы гидратировали (hydrated) экземпляры модели из проиндексированных данных. Но я считаю, что смешанный подход Elasticsearch + SQL проще и менее подвержен ошибкам, так как мы можем выбрать индексирование только доступных для поиска данных вместо всех атрибутов модели.

Хитрость в переключении репозитория заключается в замене привязки в ServiceProvider следующим образом:

namespace App\Providers;

use App\Articles;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     *
     * @return void
     */
    public function register()
    {
        $this->app->bind(Articles\ArticlesRepository::class, function () {
            // Это полезно, если мы хотим выключить наш кластер
            // или при развертывании поиска на продакшене
            if (! config('services.search.enabled')) {
                return new Articles\EloquentRepository();
            }

            return new Articles\ElasticsearchRepository(
                $app->make(Client::class)
            );
        });
    }
}

Всякий раз, когда мы запрашиваем интерфейсный объект ArticlesRepository из контейнера IoC, он фактически выдает экземпляр ElasticsearchRepository, если тот включен, в противном случае — Eloquent версию.

Нужно настроить клиент Elasticsearch, забиндить его в AppServiceProvider или создать новый. В нашем случае, используем существующий AppServiceProvider:

namespace App\Providers;

use App\Articles;
use Elasticsearch\Client;
use Elasticsearch\ClientBuilder;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     *
     * @return void
     */
    public function register()
    {
        $this->app->bind(Articles\ArticlesRepository::class, function () {
            // Это полезно, если мы хотим выключить наш кластер
            // или при развертывании поиска на продакшене
            if (! config('services.search.enabled')) {
                return new Articles\EloquentRepository();
            }

            return new Articles\ElasticsearchRepository(
                $app->make(Client::class)
            );
        });

        $this->bindSearchClient();
    }

    private function bindSearchClient()
    {
        $this->app->bind(Client::class, function ($app) {
            return ClientBuilder::create()
                ->setHosts($app['config']->get('services.search.hosts'))
                ->build();
        });
    }
}

Теперь, когда код почти готов, нам нужно закончить конфигурацию. Возможно, вы заметили использование вспомогательного метода config в некоторых местах. Он загружает данные файлов конфигурации. Вот, что у меня в config/services.php:

return [
    // ...
    'search' => [
        'enabled' => env('ELASTICSEARCH_ENABLED', false),
        'hosts' => explode(',', env('ELASTICSEARCH_HOSTS')),
    ],
];

Здесь мы говорим Laravel проверить переменные окружения, чтобы взять текущие настройки. Мы устанавливаем их локально в файле .env:

ELASTICSEARCH_ENABLED=true
ELASTICSEARCH_HOSTS="localhost:9200"

Для случая с несколькими хостами мы делаем список, разделенный запятой, и в конфиге его разбиваем на отдельные хосты. Но в данный момент пока это не используется. Если у вас запущено через php-сервер, не забудьте перезапустить его, чтобы подгрузить новые настройки. Теперь нужно заполнить Elasticsearch нашими данными. Для этого нам понадобится специальная artisan команда. Создайте её через php artisan make:command ReindexCommand (см. код ниже). Эта команда также понадобиться нам позже, если нужно будет менять схемы индексов Elasticsearch, то мы сбросили бы текущие индексы и переиндекисровали каждый фрагмент данных (или использовали алиасы для нулевого времени простоя).

Создаем интерфейсную команду:

php artisan make:command ReindexCommand --command="search:reindex"

Откройте её и отредактируйте:

namespace App\Console\Commands;

use App\Article;
use Elasticsearch\Client;
use Illuminate\Console\Command;

class ReindexCommand extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'search:reindex';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'Indexes all articles to Elasticsearch';

    /** @var \Elasticsearch\Client */
    private $elasticsearch;

    /**
     * Create a new command instance.
     *
     * @return void
     */
    public function __construct(Client $elasticsearch)
    {
        parent::__construct();

        $this->elasticsearch = $elasticsearch;
    }

    /**
     * Execute the console command.
     *
     * @return mixed
     */
    public function handle()
    {
        $this->info('Indexing all articles. This might take a while...');

        foreach (Article::cursor() as $article)
        {
            $this->elasticsearch->index([
                'index' => $article->getSearchIndex(),
                'type' => $article->getSearchType(),
                'id' => $article->getKey(),
                'body' => $article->toSearchArray(),
            ]);

            $this->output->write('.');
        }

        $this->info('\nDone!');
    }
}

Теперь мы можем запустить эту команду, чтобы скормить наши данные серверу Elasticsearch:

php artisan search:reindex
Indexing all articles. This might take a while...
..................................................
Done!

Перезапуститесь: php artisan serve (чтобы презагрузить конфиги) и попробуйте что-нибудь поискать. Вы должны увидеть что-то вроде этого:

Использование ElasticSearch в Laravel

Мы могли бы достичь аналогичного результата и через простой SQL. Да, мы могли бы. Но в Elasticsearch есть и другие вкусняшки. Например, допустим, что вас больше интересует поиск по заголовку, чем по любому другому полю, плюс важен поиск по тегам, например:

Использование ElasticSearch в Laravel

Проверьте, каждый из результатов имеет тег, либо PHP, либо Javascript, либо оба. Давайте определим правило релевантности для поля заголовка:

'query' => [
    'multi_match' => [
        'fields' => ['title^5', 'body', 'tags'],
        'query' => $query,
    ],
],

Здесь мы определяем, что совпадения в поле заголовка в 5 раз более актуальны, чем в других полях. Смотрим:

Использование ElasticSearch в Laravel

Первое совпадение не имеет искомых тегов, но заголовок совпадает с последним запросом, поэтому он выдается выше. Круто, нам всего лишь нужно было немного поменять конфиг.

Релевантность — весьма деликатная тема. Может потребоваться несколько встреч, обсуждений и прототипов, прежде чем вы и ваша команда сможете решить, как именно её использовать.

Завершение

Мы рассмотрели основы и способы интеграции вашего приложения Laravel с Elasticsearch. А вы знали, что у Laravel есть собственный официальный пакет полнотекстового поиска? Он называется Laravel Scout и поддерживает Algolia из коробки. Вы можете писать собственные драйверы. Вроде бы только для полнотекстового поиска. Например, если вам нужно выполнить какие-то необычные поиски с агрегацией, то вы можете написать свою собственную интеграцию.

Репозиторий, написанный на основе этой статьи: https://github.com/tonysm/laravel-elasticsearch-2019

Автор: Tony Messias
Перевод: Demiurge Ash