Поиск — важная часть приложения, и некоторые его пропускают, считая простой задачей. «Да просто добавлю несколько 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, то вы увидите страницу приветствия:

Для демки мы собираемся использовать концепцию статей. Итак, нам нужно создать модель 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). Вы должны увидеть что-то вроде этого:

Давайте реализуем конечную точку поиска. Сначала через простой 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.
Интеграция 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 (чтобы презагрузить конфиги) и попробуйте что-нибудь поискать. Вы должны увидеть что-то вроде этого:

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

Проверьте, каждый из результатов имеет тег, либо PHP, либо Javascript, либо оба. Давайте определим правило релевантности для поля заголовка:
'query' => [
'multi_match' => [
'fields' => ['title^5', 'body', 'tags'],
'query' => $query,
],
],
Здесь мы определяем, что совпадения в поле заголовка в 5 раз более актуальны, чем в других полях. Смотрим:

Первое совпадение не имеет искомых тегов, но заголовок совпадает с последним запросом, поэтому он выдается выше. Круто, нам всего лишь нужно было немного поменять конфиг.
Релевантность — весьма деликатная тема. Может потребоваться несколько встреч, обсуждений и прототипов, прежде чем вы и ваша команда сможете решить, как именно её использовать.
Завершение
Мы рассмотрели основы и способы интеграции вашего приложения Laravel с Elasticsearch. А вы знали, что у Laravel есть собственный официальный пакет полнотекстового поиска? Он называется Laravel Scout и поддерживает Algolia из коробки. Вы можете писать собственные драйверы. Вроде бы только для полнотекстового поиска. Например, если вам нужно выполнить какие-то необычные поиски с агрегацией, то вы можете написать свою собственную интеграцию.
Репозиторий, написанный на основе этой статьи: https://github.com/tonysm/laravel-elasticsearch-2019
Автор: Tony Messias
Перевод: Алексей Широков
Наш Телеграм-канал — следите за новостями о Laravel.
