Делаем простейший фильтр по свойствам товаров с помощью ElasticSearch на Symfony2

  • Tutorial
Написать эту статью меня сподвигло отсутствие в интернете готового пошагового руководства «как реализовать фильтр товаров на 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 )

В результате, у меня вышла вот такая схема данных:
image


дамп базы: 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();
$request = $this->get('request');
$filter = $request->query->get('filter');
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 %}
    <h1>Фильтр</h1>
    <form>
    <ul>
    {% for option in options %}

        <li> {{ option.name }}
            <ul>
        {% for value in option.productsOptionValues  %}
            <li>
                <input type="checkbox" value="{{ value.value }}" name="filter[{{ option.name }}][{{ value.id }}]" {% if filter[option.name][value.id] is defined %} checked="checked" {% endif %} />
                {{ value.value }}
            </li>
        {% endfor %}
            </ul>
        </li>
    {% endfor %}
    </ul>
        <input type="submit" />
    </form>

    <h1>Товары</h1>

    <table>
    {% for product in products %}
        <tr>
        <td>{{ product.name  }}</td>
        <td>{{ product.price  }}</td>
        <td>
            {% for option_value in product.productsOptionValues %}

                {{ option_value.productOption }} : {{ option_value.value }} <br />

            {% endfor %}

        </td>
        </tr>
    {% endfor %}
    </table>

{% endblock %}


Обрабатываем форму фильтрации

Приступим к самому интересному.
Теперь нам нужно будет сконструировать поисковый запрос (или, точнее — JSON-фильтр), который будет передан ElasticSearch'y для обработки. Делается это с помощью встроенной в FOSElasticaBundle библиотеки Elastica.io (подробнее: elastica.io )
Итак, в экшене вашего контроллера обрабатываем массив фильтрации, полученный от формы:

if(!empty($filter))
        {
            $finder = $this->container->get('fos_elastica.finder.parfums.product');

            $andOuter = new \Elastica\Filter\Bool();
            foreach($filter as $optionKey=>$arrValues)
            {

                $orOuter = new \Elastica\Filter\Bool();
                foreach($arrValues as $value)
                {

                    $andInner = new \Elastica\Filter\Bool();
                    $optionKeyTerm = new \Elastica\Filter\Term();
                    $optionKeyTerm->setTerm('productOptionValues.productOption', $optionKey);

                    $valueTerm = new \Elastica\Filter\Term();
                    $valueTerm->setTerm('productOptionValues.value', $value);
                    $andInner->addMust($optionKeyTerm);
                    $andInner->addMust($valueTerm);

                    $orOuter->addShould($andInner);
                }
                $andOuter->addMust($orOuter);
            }

            $filtered = new \Elastica\Query\Filtered();
            $filtered->setFilter($andOuter);           
            $products = $finder->find($filtered);
        }


Здесь я достаю массив, переданный через адресную строку и перебираю выбранные пользователем значения фильтра, чтобы создать древовидную структуру объектов классов по которым библиотека Elastica сгенерирует JSON строку, по которой ElasticSearch будет фильтровать наш набор данных:
gist.github.com/ArFeRR/97671e54515dfd7be012

Этот JSON примерно соответствует следующему условию в реляционной БД:
WHERE ((option=resolution AND value=1980х1020) OR (option=resolution AND value=1600x900)) AND (option=weight AND value= 2,7 kg)

В итоге, в результате мы должны получить товары, у которых обязательно должен совпадать вес и хотя бы одно разрешение экрана из двух, выбранных пользователем. В моём наборе данных — это только 1 товар.



Вроде-бы всё работает правильно.

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

upd0:
По просьбам читателей переписан обработчик формы фильтра с использованием безопасного объекта Symfony\Component\HttpFoundation\Request. Его следует внедрить в экшн (передать в виде параметра) либо получить из сервиса посредством $request = $this->get('request') в экшне.
Share post
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 22

    +2
    Интересно посмотреть на реализацию сортировки, напишите, пожалуйста.
      0
      Благодарю за ваш интерес. Напишу, когда будет время.
      0
      А в php стало так принято один action контроллера раздувать на 50 строчек?
        0
        Я в курсе о принципе DRY для контроллеров, но старался показать решение задачи наиболее наглядно. Так то формирование запроса к ES следовало бы вынести в отдельный метод.
          –1
          А суперглобалы зачем в контроллер пихаете? у вас же есть няшный Request. Вообще «для наглядности» — простая отговорка. С классом-репозиторием было бы даже нагляднее.
            +1
            Там прямо под кодом автор указал:

            для наглядности использую $_GET, но вы используйте Request


            Если уж к _GET придираетесь, то почему не написали про отстуствтие проверки что filters — массив, что underscore в перемешку с camesCase это несоответствие стандартам и, наконец, про неправильные отступы?

            Статья годная, надо будет писать интернет магазин с таким функционалом — может пригодиться как один из вариантов решения.
              +1
              Неправильные отступы, камелькейс и нижнее подчеркивание и прочее фиксится при помощи csfixer, да и это мелочи. А вот использование суперглобальных переменных (а можете поверить, подавляющее большинство начинающих разработчиков, а эта статья годна только для них, будут бездумно копировать именно такой вариант) убивает на корню возможность красиво покрыть интеграционными тестами контроллер, да и с таким подходом можно было бы вообще выкинуть Symfony и писать на plain-php.

              ИМХО если уж писать туториал для новичков, стоит делать по best practice с разьяснениями что и зачем, тут же я вижу набор из примеров в сети слепленный вместе в кашу на скорую руку.
                0
                Благодарю вас за ваш отзыв. В момент написания статьи не посчитал важным эту дырку, однако мнения читателей показывают, что это важно, посему обработчик формы фильтра переписал на симфонивский Request. Переменные внутри него назвал единообразно по camelCase.

                И я по своей святой наивности надеюсь, что код отсюда не будет бездумно копироваться. Иначе, кому нужны best practice и объяснения?
        0
        Интересно, а есть ли возможность, имея 3 значения 2,68; 2,7; 2,71 аггрегировать их в 2,7 средствами движка?

        или

        Есть характеристика товаров
        Intel core i5-352
        Intel core i5-1234
        proЦЦessor интел core i5-4321

        аггрегировать в одну характеристику core i5?
          0
          думаю да, так как они поддерживают скрипты.
          А по поводу процессора — там есть анализаторы, которые могут помочь разбить текст.
            0
            побрел читать:) может быть заменит мой костыль с regexp :)
          0
          ArFeRR чуть чуть не потеме, в чем схему рисовали? приятная картинка.
            0
            Кнопка «дизайнер» в phpmyadmin на странице со списком таблиц вашей БД.
            0
            «Intel core i5-352» можно написать в разном регистре — «Intel core i5-352», «Intel Core i5-352», «INTEL CORE i5-352»
            Как с этим боретесь?
              0
              Подключайте анализатор. Или при записи в базу (следовательно индексации) делайте это по какому-то договору.
                0
                Обычные анализаторы разбивают текст на слова и индексируют все слова. Тут же надо чтобы строка анализировалась как одно слово, только предварительно приводилось к одному регистру. Но анализатор lowercase тут не подойдет т.к. все фасеты будут в нижнем регистре «intel core i5-352», это не красиво. Какой анализатор тут подойдет?
                  0
                  а что если для фасетов все записывать по одному алгоритму, а индексировать друго поле, куда будет заноситься значение по другому алгоритму?
                    0
                    Да, такая идея приходит в голову, но кажется что это довольно стандартная задача, возможно она как-то решается средствами ES. При реализации через два поля, дополнительно придется еще фасеты в разных регистрах склеивать на сервере.
              0
              Несмотря на примечание автора, тип должен быть nested. В примере ArFeRR, ему просто повезло, что у разных свойств были неповторимые значения. У меня были несколько свойств типа да/нет/пусто. Это была катастрофа. По одному «нет» выбирались все объекты, у которых «нет» было хоть в каком-то свойстве.
              подробное объяснение на примере.
                0
                Спасибо за статью. Для полноты понимания, помог бы итоговый вид класса продукта. Есть возможность прикрепить?
                  0
                  А лучше бы выложить весь рабочий код на GitHub. Чтобы можно было сразу склонировать и потестить.
                  0
                  Подскажите кто нибудь как на этой структуре для каждого значения вывести рядом количество товаров, которые относятся к этому значению, причем относительное (имеется ввиду с "+" к числу товаров по выбранным значениям), к примеру как на этом сайте: hotline.ua/computer/noutbuki-netbuki/26221-858-880?
                  Буду очень благодарен!

                  Only users with full accounts can post comments. Log in, please.