DoctrineSolrBundle
Добрый день, хочу представить свой symfony 2 бандл для автоматической синхронизации Doctrine entity в Solr и последующим поиском. Бандл предназначен для работы с Solr на уровне Doctrine entity и позволяет избежать написания низкоуровневых запросов в solr. Процесс установки и подробную документацию можно посмотреть на github.
Возможности
Реализованы основные (не все) возможности поиска стандартного парсера запросов Solr:
Также реализована поддержка SuggestComponent
Пример конфигурации
После установки для начала работы требуется настроить бандл в config.yml. Пример минимальной конфигурации бандла:
mdiyakov_doctrine_solr:
indexed_entities:
page:
class: AppBundle\Entity\Page
schema: page
config:
- { name: type, value: page }
schemes:
page:
document_unique_field: { name: 'uid' }
config_entity_fields:
- { config_field_name: 'type', document_field_name: 'type', discriminator: true }
fields:
- { entity_field_name: 'id', document_field_name: 'entity_id', field_type: int, entity_primary_key: true }
- { entity_field_name: 'textField', document_field_name: 'text', priority: 100, suggester: true }
Как результат после каждого создания, обновления «AppBundle\Entity\Page» сущности, будут прондексированы поля «id» и «textField», а также конфигурационное поле «type». В случае удаления экземпляра сущности соответствущий solr документ будет удален.
В «schemes» секции описываютс схемы индексации. В схему индексации входит описание полей сущности (fields), конфигурационных полей (config_entity_fields) которые должны быть проиндексированы в solr. А также «document_unique_field» указывающее уникальное поле для schemes.xml в solr. Помимо этого есть необязательное поле client в случае если надо использовать несколько solr core для разных схем индексации (подробнее об этом тут). По сути каждая схема в schemes отражает конкретное solr core.
Конфигурационное поле это поле которое задается в секции «indexed_entities» в рамках конфига сущности. Его можно использовать для индексации параметров заданных в parameters.yml, например:
....
class: AppBundle\Entity\Page
schema: page
config:
- { name: app_version, value: %app_version% }
- { name: host, value: %host% }
...
....
Для каждой индексируемой сущности должно быть задано как минимум одно конфигурационное поле с уникальным значением относительно всех индексируемых сущностей обозначенное как discriminator: true в schemes. Например:
mdiyakov_doctrine_solr:
indexed_entities:
page:
class: AppBundle\Entity\Article
schema: page
config:
- { name: type, value: article }
news:
class: AppBundle\Entity\News
schema: page
config:
- { name: type, value: news }
schemes:
page:
...
config_entity_fields:
- { config_field_name: 'type', document_field_name: 'discriminator', discriminator: true }
Т.к. и AppBundle\Entity\Article и AppBundle\Entity\News использует одну и ту же схему «page» то соотв. уникальность их primary key теряется т.к. могут существовать News и Article с одинаковым id. Чтобы избежать неопределенности задается конфигурационное поле используемое как дискриминатор значение которого добавляется к primary key сущности и результат записывается в уникальное поле документа.
Также есть возможность задать фильтры для индексируемых сущностей применяемых перед тем как сущность будет проиндексирована в solr. В зависимости от результата фильтра сущность может быть проиндексирована, удалена или пропущена во время индексации. Пример:
indexed_entities:
page:
class: AppBundle\Entity\Page
...
filters: [ big_id, published, ... ]
news:
class: AppBundle\Entity\News
...
filters: [ published, ... ]
schemes:
....
filters:
fields:
big_id: { entity_field_name: "id", entity_field_value: 3, operator: ">=" }
published: { entity_field_name: "published", entity_field_value: true, operator: "=" }
Соотв. если после того как Page или News были созданы и например поле «published» = false то индексация будет пропущена и в solr ничего не будет записано.
Если же Page или News были обновлены и например поле «published» = false то solr документ соответствующий этому экземпляру сущности будет удален в solr
То же самое и для фильтра big_id в случае если значение поле id < 3. «big_id» и «published» это произвольные названия для фильтров, могут быть какими угодно. Также есть возможность задачть symfony service как фильтр который применяется к экземпляру сущности а не к отдельному полю, подробности тут
Для успешной индексации должны быть выполнены условия для всех фильтров.
Индексация
Индексация сущности запускается каждый раз когда выполняется $em->flush и реализуется через symfony.com/doc/current/bundles/DoctrineBundle/entity-listeners.html
В случае когда надо выполнить первичную индексацию для всех сущностей в базе, реализована консольная команда:
app/console doctrine-solr:index
Подробности ее работы и аргументы можно найти на github.
Пример поиска:
После того как конфигурация задана и индексация выполнена можно искать как в рамках отдельной сущности так и в рамках схемы. Пример:
// MyController
//...
// @var \Mdiyakov\DoctrineSolrBundle\Finder\ClassFinder $finder
$finder = $this->get('ds.finder')->getClassFinder(Article::class);
/** @var Article[] $searchResults */
$searchResults = $finder->findSearchTermByFields($searchTerm, ['title']);
Результатом будет массив состоящий только из Article::class.
Если схема используется несколькими сущностями, например:
indexed_entities:
page:
class: AppBundle\Entity\Article
schema: page
...
news:
class: AppBundle\Entity\News
schema: page
...
...
то можно искать по всем сущностям использующим эту схему:
$schemaFinder = $this->get('ds.finder')->getSchemaFinder('page');
$schemaFinder->addSelectClass(Article::class);
$schemaFinder->addSelectClass(News::class);
/** @var object[] **/
$result = $schemaFinder->findSearchTermByFields($searchTerm, ['title', 'category']);
Результатом будет массив состоящий из Article и News экземпляров отсортированных в соотв. с релевантностью.
Подробнее про методы поиска тут
Заключение
Это вводная статься не описывающая полностью все возможности. Если вас заинтересовал бандл привожу пару ссылок для быстрого перехода по остальным возможностям бандла:
- использование SuggestComponent
- построение своих запросов Query Building
- реализация своего ClassFinder
Комментарии (10)
Fid
02.04.2018 20:08Что за лохи ещё пользуются Symfony 2?
KoloBango Автор
02.04.2018 20:10«Лохи» у которых есть приоритетный скоуп из бизнес задач. Имхо сифони2 (2.8) еще актуальна на многих проектах и с большим легаси гнаться за последней версией симофни не целесообразно с точки зрения бизнеса. SensionLab даже выпустила критические патчи в конце ноября прошлого года для 2ой версии — habrahabr.ru/post/342782
А вообще быковать и «лошить» это вам в другое место идти надо, а не на хабр.
Anton_Zh
А как обеспечивается согласованность между Реляционной БД и индексом? Допустим, если транзакция в БД закоммитилась, а Solr оказался недоступен по каким-то причинам?
KoloBango Автор
Это обеспечивается доктриной, тем как вызываются EntityListener. Если взглянуть на \Doctrine\ORM\UnitOfWork::commit:
в рамках вызова executeInserts и executeUpdates происходит вызов EntityListener который запускает индексацию. В случае если solr недоступен будет выброшен эксепшн "\Solarium\Exception\HttpException" и выполнен код в секции catch и изменения в базе откатятся через rollback
Anton_Zh
Хорошо, а если в Solr все запишется удачно, а транзакция откатится, например будет выброшено исключение в commit()?
KoloBango Автор
Да это может случиться, предлагаю заглянуть в \Doctrine\DBAL\Connection::commit (у меня версия doctrine/dbal = v2.5.13):
Соотв. эксепшн может быть в ситуации когда «this->_transactionNestingLevel == 0» это возможно если не был вызван $connection->beginTransaction() перед $connection->commit(). Но т.к. индексация происходит в рамках $em->flush() то за вызов "$connection->beginTransaction()" отвечает UnitOfWork, соответственно тут мы в безопасности.
Второй случай когда может возникнуть эксепшн это "$this->_isRollbackOnly == true". Выполнение этого условия возможно в след. ситуациях (не тестировал, исхожу из чтения кода):
1. Когда был вызван явно \Doctrine\DBAL\Connection::setRollbackOnly для текущей транзакции до \Doctrine\DBAL\Connection::commit
2. Либо был вызван \Doctrine\DBAL\Connection::rollBack для вложенной транзакции до \Doctrine\DBAL\Connection::commit
В обоих случаях если работа идет стандартым способом через $em->flush() оба вышеописанных случая исключены. Имхо эти эксепшены могут возникнуть если разработчик явно использует beginTranscation и endTransaction и допускает ошибку например, забыв где то вызвать beginTranscation что в принципе отслеживается практически сразу либо использует вложенные транзакции, но тут уж он должен понимать тогда что он делает и к чему это приведет.
Более опасный источник десинхронизации на самом деле не в \Doctrine\DBAL\Connection::commit а в других EntityListener которые могут вызываться для индексируемой сущности. Если после успешной индексации в каком-либо другом EntityListener будет неотлавливаемый эксепшн то получим кейс что индексация в солр прошла но изменения в базу не записались. Тут могу только порекомендовать разработчику иметь это ввиду и не злоупотреблять EntityListener либо корректно обрабатывать ошибки в других EntityListener.
В итоге рекомендую на каждую измененную сущность делать свой $em->flush($entity) чтобы избежать десинхронизации и не злоупотреблять EntityListener. Как и любой фреймворк/библиотека DoctrineSolrBundle может использоваться неправильно или неоптимально что может привести к ошибке. Если у вас реализуется сложная логика в EntityListener или вы используется вложенные, «ручные» или еще как то усложненные транзакции то возможно DoctrineSolrBundle не будет вам удобен. Возможно в будущей версии я сделаю чтобы можно было отключить автоиндексацию при каждом flush и возможность вызвать индексацию вручную.
Anton_Zh
Еще рассинхронизация может произойти при останове/перезапуске веб-сервера, отключении питания, сбоях в работе сети (плдключения к БД, не удалось запрос COMMIT отравить). Во всеъ этих случаях подход, используемый в бандле может привести к рассинхронизации. И дело даже не в том, что что-то не так запрограммировано — дело в принципе, положенном в основу бандла. Для отказоустойчивой синхронизации между БД и индексом нужен двухфаный коммит.
KoloBango Автор
Как вы себе представляете двухфазный коммит между postgresql и solr? Т.е. вы можете сделать например двухфазный коммит между двумя postgresql базами данных. Солр тут надо рассматривать как third-party api. Между такими разными продуктами это имхо надо городить такой свой огород что неизвестно что дешевле и лучше, пофиксить неконсистентность раз в полгода (что по сути просто пересохранить сущность) или поддерживать свой двухфазный велосипед для солр и рсубд. К тому же это разные продукты с разными целями и внутренним устройством.
Помимо того коммит в солр это не коммит в смысле реляционной субд, коммит в солр пушит набор изменений в индексе с последнего коммита на диск и эти изменения могут включать в себя запросы от разных пользователей по разным документам. Т.е. rollback в solr откатит все изменения сделанные с последнего коммита, а т.к. это процедура выполняемая (в зависимости от настроек) раз в какой то период (в случае если это не hard commit), то мы можем откатить и измения сделанные другими пользователями, но еще не закомиченные в индекс (поправьте меня если я неправ). Соотв. вместо роллбэка нам остается только переиндексировать сущность в исходном состоянии до того как она была изменена.
Это можно сделать след образом (протестировано):
При таком подходе даже если в солр были сделаны изменения они откатятся к прежнему состянию.
И соотв. если сущность создается:
Так что подход в бандле абсолютно нормальный. Имхо возможно имеет смысл сделать враппер EntityManager для перехвата эксепшна и его обработки как показано выше. Но двухфазный коммит тут не при чем. Вот еще ссылку в пример что не я один так думаю.
Как вариант вынести индексацию в очередь, но опять же это не гарант того что изменения будут приняты в солр например и не придется руками обрабатывать ошибку. В DoctrineSolrBundle предусмотрена валидация конфига индексируемых сущностей во время инициализации основных сервисов это поможет снизить возможные ошибки во время индексации.
Anton_Zh
Прекрасно представляю. Вместо того, чтобы сразу писать в Solr пишем необходимые для индексирования данные в отдельную таблицу, назовем ее log в той же РСУБД в той же транзакции. Получится что-то вроде журнала событий. Можно в json или xml положить, будет гибко. В другом процессе (cron, например) читаем необработанные события из log индексируем в solr. Если при обработке события произойдет сбой, событие не отметится как обработанное и процедура повторится. Изменения в индексах типа Solr как правило идемпотентны и могут быть повторены (если например фейл произошел при пометке события обработанным). Вот вам и 2PC.
А с подходом в бандле сколько не смотри на исключения, не читай код доктрины, не пиши тестов — все равно может произойти рассинхронизация. Его нельзя назвать отказоустойчивым. Аварийный останов веб-сервера тут никак не обработать, а его нужно учитывать.
То что вы с SO привели — я с этим не совсем согласен. Eventual Consistency можно сделать. В MySQL можно вообще бинлог читать и на его основе выполнять индексирование. В Postgres тоже вроде что-то добавили в последних версиях.