...

вторник, 15 июля 2014 г.

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

Написать эту статью меня сподвигло отсутствие в интернете готового пошагового руководства «как реализовать фильтр товаров на ElasticSearch», а задача сделать это у меня стояла чётко и непоколебимо. Удавалось находить отрывочную справочную информацию, но никак не cookbook по решению самых тривиальных задач.

Акцентирую ваше внимание именно на symfony2, поскольку буду использовать FOSElasticaBundle, который позволяет описывать mapping индексов elasticsearch в удобных yaml конфигах и привязывать к ним сущности Doctrine ORM или документы Doctrine ODM. Промаппленные индексы заполняются из связанных доктриновских сущностей с помощью одной единственной консольной команды. Кроме того, он включает в себя вендорную библиотеку для конструирования поисковых и фильтрационных запросов. Результаты поиска возвращаются в виде массива объектов сущности или документа Doctrine ORM/ODM, привязанной к поисковому индексу. Подробнее о FOSElasticaBundle, традиционно, на гитхабе: http://ift.tt/1zEojp7


Использование бандла позволяет полностью абстрагироваться от манипуляций с чистым JSON, что-то кодировать и декодировать функциями json_encode и json_decode, лезть куда-то с помощью сurl. Здесь только ООП подход!


Немного о схеме данных в SQL



Поскольку мои товары хранятся в реляционной СУБД, мне понадобилось реализовать EAV модель для их свойств и значений (подробнее: http://ift.tt/PrWAla )

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

image


дамп базы: http://ift.tt/WeugKg

По ней создадим доктриновские сущности и их будем маппить в 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. В противном случае, характеристики будут храниться в виде массивов как описано здесь: http://ift.tt/Weuj93 и фильтр будет работать неправильно. Также следует обратить внимание, что фильтруемые поля не должны анализироваться за что отвечает строка index: not_analyzed. В противном случае проблемы возникнут при фильтрации строк, содержащих пробелы.


Теперь вы сможете посмотреть список товаров с вложенными в них характеристиками по адресу localhost:9200/test/product/_search?pretty В моём случае ответ сервера выглядит таким образом:

http://ift.tt/Weuj95


Рендерим форму фильтрации

Сама форма у меня выглядит следующим образом:


В контроллере выполним запросы на получение всех свойств и товаров, объявим пустой массив фильтра и передадим всё это в 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 %}
<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(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 будет фильтровать наш набор данных:

http://ift.tt/1mdhEI4


Этот JSON примерно соответствует следующему условию в реляционной БД:

WHERE ((option=resolution AND value=1980х1020) OR (option=resolution AND value=1600x900)) AND (option=weight AND value= 2,7 kg)


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



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


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


This entry passed through the Full-Text RSS service — if this is your content and you're reading it on someone else's site, please read the FAQ at http://ift.tt/jcXqJW.


Комментариев нет:

Отправить комментарий