Привет! Меня зовут Павел и я занимаюсь бэкенд разработкой. Как уже писал AndreyHabr, многие из наших проектов основаны на стеке Adobe Magento 2 (для краткости далее я буду называть ее M2) в качестве бэкенда и Vue Storefront (VS) в качестве фронтенда.


Я не буду подробно останавливаться на архитектуре стека VS/M2 — мы уже писали об этом ранее. Предлагаю ознакомиться с данной статьей для более полного понимания изложенного ниже.


Сегодня я расскажу о взаимодействии с VS изнутри M2: посмотрим на реализацию индексеров, обсудим особенности их работы, после чего я кратко расскажу как создать свой индексер для кастомной сущности.


Погнали!


Модули интеграции


Официальные модули интеграции M2 и VS разрабатывает компания Divante. Модули представлены в их репозитории на Github и распространяются под лицензией MIT. Пакет интеграционных модулей включает в себя:


  • Divante_ReviewApi;
  • Divante_VsbridgeIndexerCore;
  • Divante_VsbridgeIndexerCatalog;
  • Divante_VsbridgeIndexerCms;
  • Divante_VsbridgeIndexerTax;
  • Divante_VsbridgeIndexerReview;
  • Divante_VsbridgeIndexerAgreement;
  • Divante_VsbridgeDownloadable.

Несложно догадаться, что все модули кроме первого занимаются индексацией различных сущностей с целью размещения их в Elasticsearch, используемый VS в качестве хранилища сущностей. Модуль Divante_ReviewApi реализует API для работы с отзывами (создание, удаление, получение коллекций).


Из коробки модули обеспечивают индексацию следующих сущностей:


  • Категории и продукты каталога;
  • CMS страницы и блоки;
  • Отзывы;
  • Налоги;
  • Соглашения;
  • Ссылки на скачиваемые продукты.

Из этого следует, что связка M2 и VS прямо после установки способна выполнять все базовые задачи интернет-магазина и требует минимальной настройки для начала применения.


Индексируем!


Посмотрим вблизи на реализацию индексеров. Для примера возьмем индексер CMS Page из стандартной поставки Divante. В целом, индексеры VSBridge работают на тех же принципах, что и другие индексеры в M2.


Объявляется новый индексер:


<indexer id="vsbridge_cms_page_indexer" view_id="vsbridge_cms_page_indexer" class="Divante\VsbridgeIndexerCms\Model\Indexer\CmsPage">
    <title translate="true">Vsbridge Cms Page Indexer</title>
    <description translate="true">Update Cms Pages in Elastic</description>
</indexer>

etc/indexer.xml


Где объявляется класс-индексер. Данный класс реализует общий для всех индексеров интерфейс \Magento\Framework\Indexer\ActionInterface, поэтому функционально схож с остальными индексерами M2. Его задача — запуск индексации в разных ситуациях. Здесь нас интересует свойство $cmsPageAction, содержащее Action класс, извлекающий сущности для индексации:


public function execute($ids)
{
    $stores = $this->storeManager->getStores();

    foreach ($stores as $store) {
        $this->indexHandler->saveIndex($this->cmsPageAction->rebuild($store->getId(), $ids), $store);
        $this->indexHandler->cleanUpByTransactionKey($store, $ids);
        $this->cacheProcessor->cleanCacheByTags($store->getId(), ['cmsPage']);
    }
}

Model/Indexer/CmsPage.php


Action класс содержит единственный метод rebuild, который получает коллекцию и готовит ее для индексации:


public function rebuild($storeId = 1, array $pageIds = [])
{
    $lastPageId = 0;

    do {
        $cmsPages = $this->resourceModel->loadPages($storeId, $pageIds, $lastPageId);

        foreach ($cmsPages as $pageData) {
            $lastPageId = (int)$pageData['page_id'];
            $pageData['id'] = $lastPageId;
            $pageData['content'] = $pageData['content'];
            $pageData['active'] = (bool)$pageData['is_active'];

            if (isset($pageData['sort_order'])) {
                $pageData['sort_order'] = (int)$pageData['sort_order'];
            }

            unset($pageData['creation_time'], $pageData['update_time'], $pageData['page_id']);
            unset($pageData['created_in']);
            unset($pageData['is_active'], $pageData['custom_theme'], $pageData['website_root']);

            yield $lastPageId => $pageData;
        }
    } while (!empty($cmsPages));
}

Model/Indexer/Action/CmsPage.php


Обратите внимание, что коллекция элементов извлекается методом loadPages ресурсной модели следующим образом:


public function loadPages($storeId = 1, array $pageIds = [], $fromId = 0, $limit = 1000)
{
    $metaData = $this->getCmsPageMetaData();
    $linkFieldId = $metaData->getLinkField();

    $select = $this->getConnection()->select()->from(['cms_page' => $metaData->getEntityTable()]);
    $select->join(
        ['store_table' => $this->resource->getTableName('cms_page_store')],
        "cms_page.$linkFieldId = store_table.$linkFieldId",
        []
    )->group("cms_page.$linkFieldId");

    $select->where(
        'store_table.store_id IN (?)',
        [
            Store::DEFAULT_STORE_ID,
            $storeId,
        ]
    );

    if (!empty($pageIds)) {
        $select->where('cms_page.page_id IN (?)', $pageIds);
    }

    $select->where('is_active = ?', 1);
    $select->where('cms_page.page_id > ?', $fromId)
        ->limit($limit)
        ->order('cms_page.page_id');

    return $this->getConnection()->fetchAll($select);
}

Model/ResourceModel/CmsPage.php


Данный метод может извлекать данные итеративно, коллекциями по 1000 элементов, либо только определенный набор элементов с IDs, указанными в переменной $pageIds.


Полученную коллекцию IndexHandler класса Indexer сохраняет в базу elasticsearch:


public function saveIndex(Traversable $documents, StoreInterface $store)
{
    try {
        $index = $this->getIndex($store);
        $storeId = (int)$store->getId();
        $batchSize = $this->indexOperations->getBatchIndexingSize();

        foreach ($this->batch->getItems($documents, $batchSize) as $docs) {
            foreach ($index->getDataProviders() as $dataProvider) {
                if (!empty($docs)) {
                    $docs = $dataProvider->addData($docs, $storeId);
                }
            }

            if (!empty($docs)) {
                $bulkRequest = $this->indexOperations->createBulk()->addDocuments(
                    $index->getName(),
                    $index->getType(),
                    $docs
                );

                $this->indexOperations->optimizeEsIndexing($storeId, $index->getName());
                $response = $this->indexOperations->executeBulk($storeId, $bulkRequest);
                $this->indexOperations->cleanAfterOptimizeEsIndexing($storeId, $index->getName());
                $this->bulkLogger->log($response);
            }

            $docs = null;
        }

        if ($index->isNew()) {
            $this->indexOperations->switchIndexer($store->getId(), $index->getName(), $index->getIdentifier());
        }

        $this->indexOperations->refreshIndex($store->getId(), $index);
    } catch (ConnectionDisabledException $exception) {
        // do nothing, ES indexer disabled in configuration
    } catch (ConnectionUnhealthyException $exception) {
        $this->indexerLogger->error($exception->getMessage());
        $this->indexOperations->cleanAfterOptimizeEsIndexing($storeId, $index->getName());
        throw $exception;
    }
}

Модуль Divante_VsbridgeIndexerCore: Indexer/GenericIndexerHandler.php


На этом этапе важно упомянуть класс Mapper, который задает типы полей объекта для elasticsearch.


public function getMappingProperties()
{
    $properties = [
        'id' => ['type' => FieldInterface::TYPE_LONG],
        'active' => ['type' => FieldInterface::TYPE_BOOLEAN],
        'sort_order' => ['type' => FieldInterface::TYPE_LONG],
        //compatible with product/category attribute mapping
        'page_layout' => ['type' => FieldInterface::TYPE_KEYWORD],
        'identifier' => ['type' => FieldInterface::TYPE_KEYWORD],
    ];

    foreach ($this->textFields as $field) {
        $properties[$field] = ['type' => FieldInterface::TYPE_TEXT];
    }

    $mappingObject = new \Magento\Framework\DataObject();
    $mappingObject->setData('properties', $properties);

    $this->eventManager->dispatch(
        'elasticsearch_cms_page_mapping_properties',
        ['mapping' => $mappingObject]
    );

    return $mappingObject->getData();
}

Index/Mapping/CmsPage.php


Это может быть необходимо, в том числе, если поле должно участвовать в полнотекстовом поиске — в этом случае полю надо задать тип keyword.


Данный класс используется в файле vsbridge_indices.xml, который задает идентификатор индекса и класс Mapper для полей объектов в индексе:


<index identifier="cms_page" mapping="Divante\VsbridgeIndexerCms\Index\Mapping\CmsPage">
    <data_providers>
        <data_provider name="content">Divante\VsbridgeIndexerCms\Model\Indexer\DataProvider\Page\ContentData</data_provider>
    </data_providers>
</index>

etc/vsbridge_indices.xml


Полная индексация элементов производится путем выполнения команды bin/magento indexer:reindex, которая последовательно запускает все индексеры М2. Также можно запустить конкретный индексер, указав его идентификатор в параметрах команды: bin/magento indexer:reindex <indexer_identifier>.


Из описанной процедуры индексации следуют несколько важных выводов:


  • Объект в elasticsearch не обязан полностью отражать объект в БД M2. Его можно сократить, убрав некоторые поля, или наоборот дополнить — к примеру, на этапе выборки приджоинить таблицу с дополнительными данными. Также данные можно модифицировать после выборки. Это дает свободу и удобство при размещении объектов в elasticsearch, например если нужно разрешать поля с foreign ключами в их значения;
  • Объект для индексации не обязательно доставать из базы — он может быть взят откуда угодно, к примеру, получен через запрос к внешнему источнику;
  • Выборку можно производить посредством стандартных инструментов М2, например через репозитории или коллекции, а не сырым запросом в БД, как в примере выше.

Актуализация данных в elasticsearch


Конечно же, запускать полную реиндексацию каждый раз, когда какой либо объект меняется в базе, было бы расточительно. Для отслеживания изменения объектов в базе и их актуализацией в elasticsearch следит механизм M2, который называется mview. Данный механизм состоит из триггеров в таблице, за которой производится наблюдение, индексной таблицы в которой фиксируются ID измененных сущностей, а также cron-задачи, которая считывает ID измененных сущностей из индексной таблицы и запускает индексер с указанием этих ID (вспомним, что метод loadPages ресурсной модели может принимать в качестве аргумента массив ID). Рассмотрим каждый из элементов механизма подробнее.


Создание триггеров инициируется созданием файла mview.xml:


<view id="vsbridge_cms_page_indexer" class="Divante\VsbridgeIndexerCms\Model\Indexer\CmsPage" group="indexer">
    <subscriptions>
        <table name="cms_page" entity_column="page_id"/>
    </subscriptions>
</view>

etc/mview.xml


После присвоения индексеру статуса Scheduled указанная в файле таблица приобретает триггеры на добавление, изменение и удаление объектов в таблице. ID модифицированного объекта добавляется индексную таблицу с указанием версии изменений:



Крон-задача один раз в минуту (по умолчанию) делает выборку из этих таблиц, составляет массив ID измененных объектов, после чего запускает соответствующий индексер. Таким образом, данные в elasticsearch постоянно сохраняют актуальность максимально экономичным способом.


Добавляем индексер для своей сущности


Главным достоинством этого механизма является способность к расширению. Представим, что у нас есть сущность, которую необходимо разместить в эластике. Назовем ее whiteRabbit. Создадим для ее индексации стандартный M2 модуль, после чего создадим свой индексер:


<?xml version="1.0" encoding="UTF-8"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Indexer/etc/indexer.xsd">
    <indexer id="vsbridge_white_rabbit_indexer" view_id="vsbridge_white_rabbit_indexer"
             class="RSHB\WhiteRabbit\Model\Indexer\WhiteRabbit">
        <title translate="true">White Rabbits Indexer</title>
        <description translate="true">Update White Rabbits in Elastic</description>
    </indexer>
</config>

etc/indexer.xml


А также сразу зададим таблицу за которой будем следить и идентификатор индекса, в котором будут храниться наши объекты:


<?xml version="1.0" encoding="UTF-8"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Mview/etc/mview.xsd">
    <view id="vsbridge_white_rabbit_indexer" class="RSHB\WhiteRabbit\Model\Indexer\WhiteRabbit" group="indexer">
        <subscriptions>
            <table name="rshb_white_rabbit” entity_column="id" />
        </subscriptions>
    </view>
</config>

etc/mview.xml


<?xml version="1.0" encoding="UTF-8"?>
<indices xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Divante_VsbridgeIndexerCore:etc/vsbridge_indices.xsd">
    <index identifier="white_rabbit" mapping="RSHB\WhiteRabbit\Index\Mapping\WhiteRabbit" />
</indices>

etc/vsbridge_indices.xml


Создадим индексер, маппер и экшен:


<?php
namespace RSHB\WhiteRabbit\Model\Indexer;

use Divante\VsbridgeIndexerCore\Indexer\StoreManager;
use Exception;
use Magento\Framework\Indexer\ActionInterface as IndexerActionInterface;
use Magento\Framework\Mview\ActionInterface as MviewActionInterface;
use Divante\VsbridgeIndexerCore\Indexer\GenericIndexerHandler as IndexerHandler;
use RSHB\WhiteRabbit\Model\Indexer\Action\WhiteRabbit as WhiteRabbitAction;

/**
 * Class WhiteRabbit
 * @package RSHB\WhiteRabbit\Model\Indexer
 */
class WhiteRabbit implements IndexerActionInterface, MviewActionInterface
{
    /**
     * @var IndexerHandler
     */
    private $indexHandler;

    /**
     * @var WhiteRabbitAction
     */
    private $WhiteRabbitAction;

    /**
     * @var StoreManager
     */
    private $storeManager;

    /**
     * WhiteRabbit constructor.
     * @param IndexerHandler $indexerHandler
     * @param WhiteRabbitAction $action
     * @param StoreManager $storeManager
     */
    public function __construct(
        IndexerHandler $indexerHandler,
        WhiteRabbitAction $action,
        StoreManager $storeManager
    ) {
        $this->indexHandler = $indexerHandler;
        $this->whiteRabbitAction = $action;
        $this->storeManager = $storeManager;
    }

    /**
     * @inheritdoc
     * @throws Exception
     */
    public function execute($ids)
    {
        $stores = $this->storeManager->getStores();

        foreach ($stores as $store) {
            $this->indexHandler->saveIndex($this->whiteRabbitAction->rebuild($ids), $store);
            $this->indexHandler->cleanUpByTransactionKey($store, $ids);
        }
    }

    /**
     * @inheritdoc
     * @throws Exception
     */
    public function executeFull()
    {
        $stores = $this->storeManager->getStores();
        foreach ($stores as $store) {
            $this->indexHandler->saveIndex($this->whiteRabbitAction->rebuild(), $store);
            $this->indexHandler->cleanUpByTransactionKey($store);
        }
    }

    /**
     * @inheritdoc
     * @throws Exception
     */
    public function executeList(array $ids)
    {
        $this->execute($ids);
    }

    /**
     * @inheritdoc
     * @throws Exception
     */
    public function executeRow($id)
    {
        $this->execute([$id]);
    }
}

Model/Indexer/WhiteRabbit.php


<?php
namespace RSHB\WhiteRabbit\Index\Mapping;

use Divante\VsbridgeIndexerCore\Api\Mapping\FieldInterface;
use Divante\VsbridgeIndexerCore\Api\MappingInterface;
use Magento\Framework\DataObject;
use Magento\Framework\Event\ManagerInterface as EventManager;

/**
 * Class WhiteRabbit
 * @package RSHB\WhiteRabbit\Index\Mapping
 */
class WhiteRabbit implements MappingInterface
{
    /**
     * @var EventManager
     */
    private $eventManager;

    /**
     * WhiteRabbit constructor.
     * @param EventManager $eventManager
     */
    public function __construct(
        EventManager $eventManager
    ) {
        $this->eventManager = $eventManager;
    }

    /**
     * @inheritdoc
     */
    public function getMappingProperties()
    {
        $properties = [
            'id' => ['type' => FieldInterface::TYPE_LONG]
        ];

        $mappingObject = new DataObject();
        $mappingObject->setData('properties', $properties);

        $this->eventManager->dispatch(
            'elasticsearch_white_rabbit_mapping_properties',
            ['mapping' => $mappingObject]
        );

        return $mappingObject->getData();
    }
}

Index/Mapping/WhiteRabbit.php


<?php
namespace RSHB\WhiteRabbit\Model\Indexer\Action;

use RSHB\WhiteRabbit\Model\ResourceModel\WhiteRabbit as WhiteRabbitResource;
use Traversable;

/**
 * Class WhiteRabbit
 * @package RSHB\WhiteRabbit\Model\Indexer\Action
 */
class WhiteRabbit
{
    /**
     * @var WhiteRabbitResource
     */
    private $resourceModel;

    public function __construct(
        WhiteRabbitResource $resourceModel
    ) {
        $this->resourceModel = $resourceModel;
    }

    /**
     * Rebuild
     *
     * @param array $ids
     * @return Traversable
     */
    public function rebuild(array $ids = [])
    {
        $lastId = 0;

        do {
            $rabbits = $this->resourceModel->load($ids, $lastId);

            foreach ($rabbits as $rabbit) {
                $lastId = (int)$rabbit['id'];
                $rabbitData['id'] = $lastId;
                $rabbitData = $rabbit;

                yield $lastId => $rabbitData;
            }
        } while (!empty($rabbits));
    }
}

Model/Indexer/Action/WhiteRabbit.php


А также ресурсную модель:


<?php
namespace RSHB\WhiteRabbit\Model\ResourceModel;

use Magento\Framework\App\ResourceConnection; 
use Magento\Framework\DB\Adapter\AdapterInterface;

/**
 * Class WhiteRabbit
 * @package RSHB\WhiteRabbit\Model\ResourceModel
 */
class WhiteRabbit
{
    /**
     * @var ResourceConnection
     */
    private $resource;

    /**
     * Organization constructor.
     * @param ResourceConnection $resourceConnection
     */
    public function __construct(
        ResourceConnection $resourceConnection
    ) {
        $this->resource = $resourceConnection;
    }

    /**
     * @param array $ids
     * @param int $fromId
     * @param int $limit
     * @return array
     */    
    public function load($ids, $fromId, $limit = 1000)
    {
        $select = $this->getConnection()->select()->from('rshb_white_rabbit');

        if (!empty($ids)) {
            $select->where('id IN (?)', $ids);
        }

        $select->where('id > ?', $fromId)
            ->order('id')
            ->limit($limit);

        return $this->getConnection()->fetchAll($select);
    }

    /**
     * @return AdapterInterface
     */
    private function getConnection()
    {
        return $this->resource->getConnection();
    }
}

Model/ResourceModel/WhiteRabbit.php


Не забудем указать идентификатор индекса для класса IndexerHandler, для чего воспользуемся виртуальным типом:


...
<virtualType name="RSHB\WhiteRabbit\Indexer\WhiteRabbitIndexOperationsVirtual"
             type="Divante\VsbridgeIndexerCore\Indexer\GenericIndexerHandler">
    <arguments>
        <argument name="indexIdentifier" xsi:type="string">white_rabbit</argument>
        <argument name="typeName" xsi:type="string">white_rabbit</argument>
        <argument name="alias" xsi:type="string">vue_storefront_white_rabbit</argument>
    </arguments>
</virtualType>

<type name="RSHB\WhiteRabbit\Model\Indexer\WhiteRabbit">
    <arguments>
        <argument name="indexerHandler" xsi:type="object">
            RSHB\WhiteRabbit\Indexer\WhiteRabbitIndexOperationsVirtual
        </argument>
    </arguments>
</type>
...

etc/di.xml


И, в качестве последнего штриха, создадим дата патч, который включит для нашего вновь созданного индексера режим индексации по крону:


<?php
namespace RSHB\WhiteRabbit\Setup\Patch\Data;

use Exception;
use Magento\Framework\Indexer\IndexerRegistry;
use Magento\Framework\Setup\Patch\DataPatchInterface;
use Psr\Log\LoggerInterface;

/**
 * Class SetWhiteRabbitIndexerScheduleMode
 * @package RSHB\WhiteRabbit\Setup\Patch\Data
 */
class SetWhiteRabbitIndexerScheduleMode implements DataPatchInterface
{
    /**
     * @var LoggerInterface
     */
    private $logger;

    /**
     * @var IndexerRegistry
     */
    private $indexerRegistry;

    /**
     * SetWhiteRabbitIndexerScheduleMode constructor.
     * @param LoggerInterface $logger
     * @param IndexerRegistry $indexerRegistry
     */
    public function __construct(
        LoggerInterface $logger,
        IndexerRegistry $indexerRegistry
    ) {
        $this->logger = $logger;
        $this->indexerRegistry = $indexerRegistry;
    }

    /**
     * @return DataPatchInterface|void
     */
    public function apply()
    {
        try {
            $indexer = $this->indexerRegistry->get('vsbridge_white_rabbit_indexer');
            $indexer->setScheduled(true);
        } catch (Exception $e) {
            $this->logger->critical($e);
        }
    }

    /**
     * @inheritDoc
     */
    public static function getDependencies()
    {
        return [];
    }

    /**
     * @inheritDoc
     */
    public function getAliases()
    {
        return [];
    }
}

Setup/Patch/Data/SetWhiteRabbitIndexerScheduleMode.php


На этом наш индексер готов.


Подведение итогов


Механизм индексации данных позволяет действовать M2 и VS автономно, обеспечивая слабую связанность и, как следствие, повышение надежности и быстродействия. Гибкий механизм масштабирования позволяет использовать elasticsearch для хранения разнообразной информации, а скорость выборки и поиска позволяет фонтенду работать быстро.


Напоследок хотелось бы заострить внимание на некоторых нюансах работы индексеров, с которыми мы сталкиваись в процессе работы:


  • Если у объекта меняется набор полей или их типы, необходимо удалить индекс из ealasticsearch и запустить полную индексацию. Это происходит по причине несоответствия типов полей новых объектов с теми, что уже есть в индексе;
  • Индексация не запустится, если данные в таблицу попадают при помощи дампа. В этом случае также нужно запустить индексацию вручную;
  • Если в процессе индексации в объект elasticsearch включаются данные из другого объекта (к примеру, разрешается foreign ключ в его значение при помощи join`а таблицы) то необходимо отслеживать изменения этой таблицы и обеспечивать внесение в индексную таблицу ID элементов, для которых изменились данные, связанные foreign ключами.

На этом все. Пишите код с удовольствием, используйте правильные решения и да пребудет с вами сила.