Интеграция Magento 2 и Elastic Search

18 июн. 2018 г.

В нашем проекте DIY price мы используем Magento 2 в качестве ecommerce движка. Сначала пытались делать проект полностью на magento но в итоге пришли к headless режиму. Frontend написан на VueJS, отделен от magento и взаимодействует с ней по Rest API. Такой подход приносит определенные сложности - многое нужно продумывать самому, нехватает некоторых методов api и т.д. В то же время упрощает работу frontend разработчиков, дает больше гибкости. Настраивать тему magento под свои нужды иногда может быть непростирой задачей. В конечном итоге headless архитектура оправдывает себя.

В качестве поискового движка планировали использовать Elastic Search. Причем в нашем случае мы можем даже обращаться к нему напрямую, тем самым разгружая magento. К тому же мы используем Magento Enterprice и его поддержка заявлена из коробки. Правда начинали мы разработку на Comunity Edition и искали возможности интеграции уже в этот момент.

Magento 2 EE + Elastic Search

Для начала рассмотрим интеграцию, которую Mаgento EE поддерживает из коробки. Elastic Search в Magento 2 EE из коробки работает. Нужен Elastic Search версии 2.4.6 Последняя версия Elastic на данный момент - 6.1. С ней интеграция не работает. С 5.4.6 не завелось. C Elastic Search 2.4 работает нормально. Подробнее настройку интеграции стандартными средствами планирую написать в отдельную статью.

Загрузка и обновление данных в API Elastic Search работает без нареканий. Поиск по каталогу в стандартной теме Luma не заработал(Ошибки). Но наверное можно разобраться. В целом решение рабочее но нам не очень подходило т.к. у нас кастомный фронтенд. И очень кастомные структуры данных. Не подойдет стандартная реализация и тем, у кого нет денег на Magento EE.

Свое изобретение

В принципе ничто не мешает написать кастомный модуль, который будет делать все, что угодно. В том числе и загружать данные в Elastic Search. Модуль должен уметь загружать данные из Magento в Elastic Search

  • Загружать все имеющиеся продукты и категории
  • Загружать продукты и категории по мере изменения
  • Загружать рейтинг продукта(по отзывам)
  • Обновлять рейтинг продукта при добавлении/изменении отзыва
  • В продукте должна быть информация о категории(Название,ID) - нужно для поиска по категориям

Также было условие, что продуктов много(~400 000) и требование, чтобы загрузка шла асинхронно либо в фоновом режиме.

Установка Elastic Search

Elastic Search развернут в docker, как и большинство наших сервисов. В итоге есть линк такого типа: http://172.18.0.1:9200

Нам нужно чтобы плагин знал, куда обращаться. Редактируем конфиг /app/etc/env.php Добавляем секцию elastic_search, в нее добавляем api host и порт. Возможно в будушем захотим добавить другие параметры

'elastic_search' =>
  array (
    'connection' =>
        array (
          'host' => '172.18.0.1',
          'port' => '9200',
        ),
  ),

Консольные команды

Управлять плагином будем с помощью консольных команд. Потом можно запускать их по cron c заданной периодичностью.

Регистрируем команды в файле /app/code/DIY/ElasticSuite/etc/di.xml

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <type name="Magento\Framework\Console\CommandList">
        <arguments>
            <argument name="commands" xsi:type="array">
                <item name="elastic-test" xsi:type="object">DIY\ElasticSuite\Console\Test</item>
                <item name="elastic-info" xsi:type="object">DIY\ElasticSuite\Console\Info</item>

                <item name="elastic-push-categories" xsi:type="object">DIY\ElasticSuite\Console\PushCategories</item>
                <item name="elastic-push-products" xsi:type="object">DIY\ElasticSuite\Console\PushProducts</item>

                <item name="elastic-update-categories" xsi:type="object">DIY\ElasticSuite\Console\UpdateCategories</item>
                <item name="elastic-update-products" xsi:type="object">DIY\ElasticSuite\Console\UpdateProducts</item>
            </argument>
        </arguments>
    </type>
</config>

Сами консольные команды хранятся в папке console и очень напоминают консольные команды Symfony. Т.к. основаны на них). Название команды указывается в самой команде. Пример консольной команды /app/code/DIY/ElasticSuite/Console/Test.php

<?php
namespace DIY\ElasticSuite\Console;

use DIY\ElasticSuite\Integration\ElasticClient;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Magento\Framework\App\Config\Storage\WriterInterface;

/**
 * Class Test
 * @package DIY\ElasticSuite\Console
 */
class Test extends Command
{
    private $configWriter;

    private $elasticService;

    private $elasticClient;

    /**
     * Test constructor.
     * @param WriterInterface $configWriter
     * @param \Magento\Framework\App\State $state
     * @param \DIY\ElasticSuite\Integration\ElasticService $elasticService
     */
    public function __construct(
        WriterInterface $configWriter,
        \Magento\Framework\App\State $state,
        \DIY\ElasticSuite\Integration\ElasticService $elasticService,
        \DIY\ElasticSuite\Integration\ElasticClient $elasticClient
    ) {
        parent::__construct();
        $this->configWriter = $configWriter;
        $this->elasticService = $elasticService;
        $this->elasticClient = $elasticClient;
    }

    protected function configure()
    {
        $this->setName('elastic-suite:test');
        $this->setDescription('Elastic Suite Test');
        parent::configure();
    }

    /**
     * @param InputInterface $input
     * @param OutputInterface $output
     */
    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $data = $this->elasticClient->testConnection();
        print_r($data);
    }
}

Создадим 5 консольных команд.

  • Test - проверка соединения
  • PushCategories - Загрузка всех категорий
  • PushProducts - Загрузка всех продуктов
  • UpdateCategories - Загрузка обновленных категорий
  • UpdateProducts - Загрузка обновленных продуктов

Преобразование данных

Обновление будем делать с помощью Observer, встроеных в Magento. Создаем 4 observer на обновление продуктов и категорий. Настроим конфиг /app/code/DIY/ElasticSuite/etc/events.xml

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Event/etc/events.xsd">
    <event name="catalog_category_save_after">
        <observer name="elastic_category_save" instance="DIY\ElasticSuite\Observer\CategorySaveObserver" />
    </event>
    <event name="catalog_category_delete_after">
        <observer name="elastic_category_delete" instance="DIY\ElasticSuite\Observer\CategoryDeleteObserver" />
    </event>
    <event name="catalog_product_save_after">
        <observer name="elastic_product_save" instance="DIY\ElasticSuite\Observer\ProductSaveObserver" />
    </event>
    <event name="catalog_product_delete_after">
        <observer name="elastic_product_delete" instance="DIY\ElasticSuite\Observer\ProductDeleteObserver" />
    </event>
</config>

Пример класса observer - /app/code/DIY/ElasticSuite/Observer/ProductSaveObserver.php

<?php

// app/code/DIY/ElasticSuite/Observer/ProductSaveObserver.php

namespace DIY\ElasticSuite\Observer;

use DIY\ElasticSuite\Queue\CategoryQueueItem;
use DIY\ElasticSuite\Queue\ProductQueueItem;
use DIY\OrdersWebhooks\Helpers\WebhookHelper;
use Magento\Framework\Event\ConfigInterface;
use Magento\Framework\Event\InvokerInterface;

/**
 * Class ProductSaveObserver
 * @package DIY\ElasticSuite\Observer
 */
class ProductSaveObserver implements \Magento\Framework\Event\ObserverInterface
{
    protected $scopeConfig;

    protected $queueManager;

    const EVENT_NAME = 'catalog_product_save_after';

    /**
     * ProductSaveObserver constructor.
     * @param \DIY\ElasticSuite\Queue\QueueManager $queueManager
     */
    public function __construct(
        \DIY\ElasticSuite\Queue\QueueManager $queueManager
    ) {
        $this->queueManager = $queueManager;
    }

    /**
     * @param \Magento\Framework\Event\Observer $observer
     * @return $this
     */
    public function execute(\Magento\Framework\Event\Observer $observer)
    {
        $product = $observer->getEvent()->getProduct();
        $sku = $product->getData('sku');
        $item = new ProductQueueItem(ProductQueueItem::EVENT_SAVE, $sku);
        $this->queueManager->addItem($item);
        return $this;
    }
}

Для отзывов в magento нет стандартных observer, поэтому будем использовать interceptor. Работа interceptor подробно описана в документации и статье на habrahabr.

В итоге работает примерно так же, как и observer. Пример:

<?php

// app/code/DIY/ElasticSuite/Plugin/ReviewObserver.php

namespace DIY\ElasticSuite\Plugin;

use DIY\ElasticSuite\Queue\ProductQueueItem;
use Magento\Review\Model\Review;

/**
 * Class ReviewObserver
 * @package DIY\ElasticSuite\Plugin
 */
class ReviewObserver
{
    const ENTITY_PRODUCT = 1;

    private $queueManager;

    /**
     * ReviewObserver constructor.
     * @param \DIY\ElasticSuite\Queue\QueueManager $queueManager
     */
    public function __construct(
        \DIY\ElasticSuite\Queue\QueueManager $queueManager
    ) {
        $this->queueManager = $queueManager;
    }

    /**
     * @param Review $subject
     * @param $intercepter
     * @throws \Exception
     */
    public function afterSave(Review $review, $intercepter) {
        $entity = $review->getData('entity_id');
        $productId = $review->getData('entity_pk_value');
        $reviewId = $review->getData('review_id');

        if($entity == self::ENTITY_PRODUCT){
            $item = new ProductQueueItem(ProductQueueItem::EVENT_SAVE, $productId);
            $this->queueManager->addItem($item);
        }

        return;
    }
}

Можно было бы сразу слать запрос к Elastic Search API, Но... Было требование сделать это асинхронно. Ок. Можно было бь класть запросы в mysql, потом выбирать оттуда и отправлять.

Очередь

Но мы используем Redis для этих целей. В модуле реализованы классы для работы с очередью

/app/code/DIY/ElasticSuite/Queue/QueueManager.php
/app/code/DIY/ElasticSuite/Queue/ProductQueueItem.php
/app/code/DIY/ElasticSuite/Queue/CategoryQueueItem.php

А также классы обработчики

/app/code/DIY/ElasticSuite/Queue/Worker/CategoryDeleteWorker.php
/app/code/DIY/ElasticSuite/Queue/Worker/CategorySaveWorker.php
/app/code/DIY/ElasticSuite/Queue/Worker/ProductDeleteWorker.php
/app/code/DIY/ElasticSuite/Queue/Worker/ProductSaveWorker.php

Обработчики отвечают за отправку запроса к Elastic Search API.

Синонимы

Возможно, что для поиска нам захочется настроить синонимы. Например Отвертка - Шуруповерт, Стол - Верстак, Углошлифовальная машина - болгарка, и т.д.

Логичнее всего редактировать и хранить синонимы в базе данных magento. И периодически загружать обновления в Elastic Search. Для этого реализуем отдельный модуль и grid для управления синонимами.

В Elastic Service реализуем метод updateSynonyms, который будет забирать из БД синонимы, закрывать индекс Elastic Search, Загружать синонимы и снова открывать индекс.

/app/code/DIY/ElasticSuite/Integration/ElasticService.php

Рекомендуется запускать по cron не очень часто. Скажем, раз в сутки. Т.к. в результате индекс на некоторое время закрывается и, фактически, получаем простой сервиса. Пусть и ненадолго. Вероятно, это можно рещить с помощью кластера из нескольких нод.

Итоги

В результате получаем гибкий настраиваемый плагин, реализующий интеграцию с Elastic Search. Поддерживающий последние версии. Работающий асинхронно либо в фоновом режиме. Доступный для использования в Magento 2 CE.

Код (будет) доступен на github. На этом пока все. Спасибо за внимание.