Делаем простейший фильтр по свойствам товаров с помощью ElasticSearch на Symfony2
Написать эту статью меня сподвигло отсутствие в интернете готового пошагового руководства «как реализовать фильтр товаров на ElasticSearch», а задача сделать это у меня стояла чётко и непоколебимо. Удавалось находить отрывочную справочную информацию, но никак не cookbook по решению самых тривиальных задач.Акцентирую ваше внимание именно на symfony2, поскольку буду использовать FOSElasticaBundle, который позволяет описывать mapping индексов elasticsearch в удобных yaml конфигах и привязывать к ним сущности Doctrine ORM или документы Doctrine ODM. Промаппленные индексы заполняются из связанных доктриновских сущностей с помощью одной единственной консольной команды. Кроме того, он включает в себя вендорную библиотеку для конструирования поисковых и фильтрационных запросов. Результаты поиска возвращаются в виде массива объектов сущности или документа Doctrine ORM/ODM, привязанной к поисковому индексу. Подробнее о FOSElasticaBundle, традиционно, на гитхабе: github.com/FriendsOfSymfony/FOSElasticaBundle
Использование бандла позволяет полностью абстрагироваться от манипуляций с чистым JSON, что-то кодировать и декодировать функциями json_encode и json_decode, лезть куда-то с помощью сurl. Здесь только ООП подход!
Немного о схеме данных в SQLПоскольку мои товары хранятся в реляционной СУБД, мне понадобилось реализовать EAV модель для их свойств и значений (подробнее: en.wikipedia.org/wiki/Entity%E2%80%93attribute%E2%80%93value_model)В результате, у меня вышла вот такая схема данных: 
дамп базы: drive.google.com/file/d/0B30Ybwiexqx6S1hCanpISHVvcjQ/edit? usp=sharingПо ней создадим доктриновские сущности и их будем маппить в ElasticSearch.
Маппим EAV модель в ElasticSearchИтак, сначала установим FOSElasticaBundle. В composer.json нужно указать:
«friendsofsymfony/elastica-bundle»: «dev-master» Обновляем зависимости и прописываем установившийся бандл в AppKernel.php:
new FOS\ElasticaBundle\FOSElasticaBundle () Теперь прописываем в config.yml cледующие настройки:
fos_elastica: clients: default: { host: localhost, port: 9200 } indexes: test: types: product: mappings: name: ~ price: ~ category: ~ productsOptionValues: type: «object» properties: productOption: index: not_analyzed value: type: string index: not_analyzed persistence: driver: orm model: Vendor\TestBundle\Entity\Product provider: ~ listener: immediate: ~ finder: ~ Чтобы заполнить созданный выше индекс данными следует выполнить консольную команду php app/console fos: elastica: populate. В результате чего FOSElasticaBundle заполнит индекс данными из БД.
Примечание: Внутрь товара в виде вложенного объекта мы вкладываем характеристики и их значения. Чтобы всё работало как нужно, следует указать именно type: «object» вместо type: «nested» для коллекции характеристик productsOptionValues. В противном случае, характеристики будут храниться в виде массивов как описано здесь: www.elasticsearch.org/guide/en/elasticsearch/guide/current/complex-core-fields.html#_arrays_of_inner_objects и фильтр будет работать неправильно. Также следует обратить внимание, что фильтруемые поля не должны анализироваться за что отвечает строка index: not_analyzed. В противном случае проблемы возникнут при фильтрации строк, содержащих пробелы.
Теперь вы сможете посмотреть список товаров с вложенными в них характеристиками по адресу localhost:9200/test/product/_search? pretty В моём случае ответ сервера выглядит таким образом: gist.github.com/ArFeRR/3976778079d64d5a72cd
Рендерим форму фильтрации
Сама форма у меня выглядит следующим образом: 
В контроллере выполним запросы на получение всех свойств и товаров, объявим пустой массив фильтра и передадим всё это в TWIG шаблон:
$options = $entityManager→getRepository («ParfumsTestBundle: ProductOption»)→findAll (); $products = $entityManager→getRepository («ParfumsTestBundle: Product»)→findAll (); $filter = array (); return $this→render ('ParfumsTestBundle: Default: filter.html.twig', array ('options'=>$options, 'products' => $products, 'filter' => $filter)); Здесь следует выполнить группировку по именам свойств, чтобы избежать их дублирования на форме, но для экономии места я этого не делаю. Напишите запрос на DQL в ваш репозиторий сущности/документа самостоятельно. FindAll запрос по товарам нужен, чтобы вывести весь список товаров, если на фильтре ничего не выбрано.
А вот и сам twig:
{% extends «TwigBundle: layout.html.twig» %} {% block body %}
Фильтр
Товары
| {{ product.name }} | {{ product.price }} |
{% for option_value in product.productsOptionValues %}
{{ option_value.productOption }} : {{ option_value.value }} {% endfor %}
|
{% endblock %} Обрабатываем форму фильтрации Приступим к самому интересному.Теперь нам нужно будет сконструировать поисковый запрос (или, точнее — JSON-фильтр), который будет передан ElasticSearch’y для обработки. Делается это с помощью встроенной в FOSElasticaBundle библиотеки Elastica.io (подробнее: elastica.io/)Итак, в экшене вашего контроллера обрабатываем массив фильтрации, полученный от формы: if (isset ($_GET['filter'])) { $finder = $this→container→get ('fos_elastica.finder.test.product'); $andOuter = new \Elastica\Filter\Bool (); foreach ($_GET['filter'] as $option_key=>$arr_values) {
$orOuter = new \Elastica\Filter\Bool (); foreach ($arr_values as $value) {
$andInner = new \Elastica\Filter\Bool (); $option_key_term = new \Elastica\Filter\Term (); $option_key_term→setTerm ('productsOptionValues.productOption', $option_key);
$value_term = new \Elastica\Filter\Term (); $value_term→setTerm ('productsOptionValues.value', $value); $andInner→addMust ($option_key_term); $andInner→addMust ($value_term);
$orOuter→addShould ($andInner); } $andOuter→addMust ($orOuter); }
$filtered = new \Elastica\Query\Filtered (); $filtered→setFilter ($andOuter); $products = $finder→find ($filtered); $filter = $_GET['filter']; } Здесь я достаю массив, переданный через адресную строку (для наглядности использую $_GET, но вы используйте симфонивский объект Request — он безопасный) и перебираю выбранные пользователем значения фильтра, чтобы создать древовидную структуру объектов классов по которым библиотека Elastica сгенерирует JSON строку, по которой ElasticSearch будет фильтровать наш набор данных: gist.github.com/ArFeRR/97671e54515dfd7be012
Этот JSON примерно соответствует следующему условию в реляционной БД: WHERE ((option=resolution AND value=1980×1020) OR (option=resolution AND value=1600×900)) AND (option=weight AND value= 2,7 kg)
В итоге, в результате мы должны получить товары, у которых обязательно должен совпадать вес и хотя бы одно разрешение экрана из двух, выбранных пользователем. В моём наборе данных — это только 1 товар.

Вроде-бы всё работает правильно.
Приведённый пример фильтрации может быть доработан. Следующим этапом должна стать реализация сортировки результатов по релевантности их постраничный вывод и настройка агрегаций (частной реализации фасетов в ES). Об этом напишу позже, если это будет интересно хабр-сообществу.
