Привет! Меня зовут Павел и я занимаюсь бэкенд разработкой. Как уже писал 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 ключами.
На этом все. Пишите код с удовольствием, используйте правильные решения и да пребудет с вами сила.