Русский
Русский
English
Статистика
Реклама

Search

Кому рецепты для электронной коммерции? Для SAP Commerce и не только

12.08.2020 08:15:24 | Автор: admin
Моё хобби автоматизация онлайн-ритейла. Уже много лет даже всвои выходные яневылажу изэтого болота. Да, наверное, это звучит дико идаже смешно. Как можно увлекаться таким скучным делом? скажут одни. Что там увлекаться, это просто какая-то частная тема для уважающего себя архитектора ПО! скажут другие.

Действительно, напервый взгляд, это, как говорится, недиссертабельная тема. Фактически, это сборная солянка изразных тем, тем или иным образом притащенных векоммерс. Ивитоге оказалась ровно тем, что ялюблю: интеграция технологий.

Ивот с2016я веду техноблог, hybrismart.com. Такая хабра вминиатюре, только наанглийском исфокусом наблизкую мне тему разработку наSAP Commerce. Унас тут сформировалась небольшая компания изнескольких десятков тысяч авторов, нонаблог пока что пишут только часть изних. Ну, хорошо, пишут пока немногие. Десяток. Номыстараемся. Наблоге уже накопилось под две сотни статей, преимущественно больших иочень больших насамые разные темы, тем или иным боком относящиеся кecom. Всущественной части это все-таки персональный блог, поэтому отдуваюсь тутя, аненаша пиар-служба. Ноэто отдуши, правда.

Как легко догадаться изназвания, hybrismart про хайбрис. Ипочти все, кто его находит, знают охайбрисе непонаслышке. Нуинаоборот: наверное, каждый разработчик наhybris хотябы раз наблог заходил (Конечно, неподоброй воли, нам гугл помогает!). Теперь вот ивызашли. Ичтобы вытам непотерялись, хочу провести небольшую экскурсию. Задавайте, пожалуйста, вопросы всамом конце.



ЖАЖДА ПОИСКА



Кто-то скажет, что где екоммерс, там шоппинг карт, агде шоппинг карт, там екоммерс. Ноэту шоппинг-карт еще нужно найти. Как итовары. Итут возникает тема, вкоторой число самодельных велосипедов зашкаливает: поиск потоварам.

Пожалуй, это самый жирный топик намоем блоге. Вхайбрисе запоиск отвечает Apache Solr, один издвух крупных иповсеместно известных опенсорсных движков (вместе сElasticSearch). Нокак выпонимаете, специфики хайбриса встатьях про поиск минимум.

Rauf Aliev, Timofey Klyubin
The Challenges OfChinese And Japanese Searching
https://hybrismart.com/2019/08/18/the-challenges-of-chinese-and-japanese-searching/

Вместе сТимофеем Клюбиным мысделали гигантский обзор текстового поиска наиероглифических языках, описали типичные сложности укомпьютеров сэтими значками испособы ихрешения вSolr. Также выузнаете про различные культурные иязыковые особенности испецифику оформления фасетного поиска вЯпонии иКитае.

Тимофей кроме хайбриса ивсяких айтишних штук давно изучает японский. Мне хотелосьбы написать тут ая китайский, ноувы. Уменя труд родился впроцессе глубокого изучения темы, вызванного нуждой поработе ижеланием раз инавсегда закрыть вопросы, которые меня мучали, аТимофей просто занимался любимым делом.

Rauf Aliev
Facet Search: The Most Comprehensive Guide. Best Practices, Design Patterns, Hidden Caveats, And Workarounds.
https://hybrismart.com/2019/02/13/facet-search-the-most-comprehensible-guide-best-practices-design-patterns/

Аэта работа относится кфасетному поиску. Очень много букв, ноесть содержание сссылками. Былобы концептуально сделать фасетный поиск постатье пофасетному поиску, ноясебя остановил. Встатье предпринята попытка систематизировать знания иопыт вэтой области иорганизовать эти знания ввиде одной большой простыни сфактами, ссылками, иbest practices. Наверное, эта статья должна быть полезна тем, кто породу работы связан спользовательскими интерфейсами.

Несмотря нато, что это самая часто используемая концепция, вней большой соблазн начать изобретать колеса заново, иочень многие этим пользуются, получая врезультате много несостыковок ипротиворечий.



Rauf Aliev
Autocomplete, Live Search Suggestions, and Autocorrection: Best Practice Design Patterns
https://hybrismart.com/2019/01/08/autocomplete-live-search-suggestions-autocorrection-best-practice-design-patterns/

Поскольку поиски сейчас пошли умные, ичасто лучше пользователя знают, что онхотел найти, аустройства маленькие инеудобные, большое внимание уделяется Search Suggestions способу сформулировать желаемый поисковый запрос заменьшее время, заминимальное число нажатий клавиш, кликов мыши или тапов поэкрану.

Встатье яделаю обзор темы, лучших практик ичастых ошибок. Статья родилась, когда япроектировал систему умного автокомплита для одной крупной biotech-компании, делающего удобнее поиск поантителам иреагентам. Умный автокомплит предлагал завершение текущего слова водно нажатие, опираясь науже введенные слова, определенные правила сочетаемости истатистику запросов. Ближайший аналог излингвистики после ввода глагола сбольше вероятностью идет существительное, чем другой глагол.



Rauf Aliev
Search Analytics
https://hybrismart.com/2017/10/06/part2-sap-hybris-thinking-outside-the-box-part-2-of-4-video-russian-english-search-analytics/

Некоторые материалы представлены наблоге неввиде статей, аввиде видеозаписей. Ксожалению, такой формат пока еще неприжился. Здесь ярассказываю про Search Analytics механизму сбора иобработки статистики, имеющей отношение кдействиям покупателей свовлечением поиска потоварам. Япридумал этот механизм для большого продуктового магазина вЕвропе, иперепроверил его еще раз для той самой байотек-компании изпредыдущего примера. Вкратце, идея сводится ктому, что действия покупателей могут много рассказать прото, как работает поиск, игде унего слабые места. Например, статистика показывает, что некоторые товары ищут часто, нокладут вкорзину редко (высокая цена? Устаревшие модели?), адругие кладут часто, нодовольно плохо ищут (подсказки?), азатретьими готовы прокликивать несколько страниц результатов поиска (какие-то нерелевантные товары вылезают вперед?). Вобщем, это такой Google Analytics, нодля поиска.

Rauf Aliev
Multi-line Search
https://hybrismart.com/2017/04/07/multi-line-product-search-for-bulk-orders/

Иметь свой блог удобно тем, что туда можно выгружать идеи ивысвобождать мозги под новые. Вэтой статье яописал концепцию многострочного поиска для B2B-сайтов. Идея втом, что часто удобно искать скопипастив целую группу артикулов или названий товаров вполе для поиска, чем делать это одной строчкой зараз.



Rauf Aliev
Product Image Visual Search
https://hybrismart.com/2018/08/26/product-image-visual-search-in-sap-commerce-cloud-hybris-commerce/

Вэтой статье яописываю поиск похожих товаров поцвету или форме. Это довольно классическая тема, нонапрактике, понепонятной мне причине, редко реализуемая. Ясделал прототип, иописал матчасть. Практически все статьи подобного характера сопровождаются видео, как работает прототип сSAP Commerce, иэта неисключение. Для интеграции сApache Solr яиспользовал Lire (http://personeltest.ru/aways/github.com/dermotte/lire).



Rauf Aliev
More Like This InSOLR
https://hybrismart.com/2017/02/05/more-like-this-in-hybris-solr-search/

Если впрошлой статье мыискали похожие товары поцвету иразмеру, тотут показываются похожие почерт знает чему. Система рассчитывает иупорядочивает товары попринципу похожести индексируемого контента описаний товаров, названий, характеристик. Тем больше сходства, тем ближе товары будут втаких кластерах друг кдругу. Для пользователяже мыможем вывести товары, находящиеся поблизости втаком пространстве похожестей, которые скорее всего окажутся товарами-заменителями.



Rauf Aliev
Concept Aware Search: Automatic Facet Discovery
hybrismart.com/2017/06/25/concept-aware-search-automatic-facet-discovery-in-hybris

Здесь ятоже описываю интересный эксперимент ипрототип: система выставляет фасеты самостоятельно, основываясь навведенном поисковом запросе. Например, если выищите что-то запросом красное платьишко 39размера, товам надо показывать нетовары, укоторых все эти слова есть вописании или названии, атовары, отфильтрованные потегу красный, платье иразмер 39. Для русского языка понадобятся еще танцы сбубнами, асанглийским все работает уже сейчас. Внутри есть демка, показывающая разницу между тем, как работает дефолтный поиск ионже, носмоей логикой поверх. Называется, почувствуйте разницу. Однако, нужно отметить, что такой подход все еще блещет сайд-эффектами, инужно очень тщательно настраивать систему, чтобы она удовлетворяла всех или почти всех.

Rauf Aliev
Enhanced Multi-Word Synonyms and Phrase Search
https://hybrismart.com/2017/08/09/enhanced-multi-word-synonyms-and-phrase-search/

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

Наблоге есть еще пара десятков статей натему поиска. Анаэтом прекрасном месте тема поиска уступает теме расчета акций искидок ипрочей лояльности.

АКЦИИ ПОПРАВИЛАМ



Купи два пуховика поцене трех иполучи один вподарок!. Что только маркетологи непридумают, чтобы программисты нескучали. Делаешь полгода совершенный движок акций, который умеет вообще всё иеще немножко, итут приходит менеджер сочередной идеей, из-за которой нужно переписывать половину! ВХайбрисе тоже было два поколения таких движков. Разработчики решили неизобретать велосипед ииспользовали JBoss Drools, довольно мощную систему управления бизнес-правилами, которая интегрирована вхайбрис для темы акционных механик, темы узкой, норазнообразной всвоей узости.



Если вдвух словах, тоDrools это среда выполнения бизнес-правил. Механизм обрабатывает так называемые факты входные данные, ивыдает результат врезультате обработки правил ифактов. ВХайбрисе для Drools сделали интерактивный редактор правил втерминах e-commerce, атакже представили API для расширения.

Rauf Aliev
Could Have Fired
https://hybrismart.com/2016/06/04/hybris-6-could-have-fired-messages-poc/

Если какое-то правило срабатывает, тонакладывается скидка. Правила применяются ккорзине. Мой эксперимент, вописанной статье, показывает, что правила могут применяться неккорзине, ноккомбинации корзины итекущего товара. Тоесть, тыеще накнопку купить ненажал, ауже видишь, какие райские сады ивеликолепные дворцы сейчас будут добавлены вкорзину как подарок. Предполагается, что это должно сделать пользователя счастливее иувеличить продажи.



Rauf Aliev
Distributed promotion calculation inthe cluster. Promo asaservice
https://hybrismart.com/2016/07/05/distributed-promotion-calculation-cluster-promo-as-a-service/

Так вот, этот самый Drools интегрирован вплатформу. Аона монолит. Монолит это когда весь код растет изодного места. Ивот когда пользователь тыкает иконочку нашоппинг карт, насервере миллионы маленьких гномиков начинают создавать контекст для Drools, потом заполнять его фактами, куда входят товары, категории, свойства пользователя ивсякое еще разное, отчего может зависеть акция. Ипроисходит это натой ноде вкластере, куда принесло пользователя лоад-балансером. Иесли там вдруг вэто время перебои спроцессорными ресурсами или памятью, топользователь будет страдать. Затем, пользователю вручают скидку или подарок, асервер все это хозяйство подчищает. Доследующего раза, когда оно опять начнет создаваться. Встатье яописываю свой эксперимент ввынесении Drools вотдельный кластер ивынос этапа этого конфигурирования Drools иззапроса. Кроме того, что это повышает производительность, это еще позволяет делать довольно сложные акции, где участвует, например, миллионы фактов.



Rauf Aliev
Using hybris rule engine for product recommendations
https://hybrismart.com/2016/08/09/using-hybris-rule-engine-for-product-recommendations/

Вэтом примере япоказываю, как можно устроить рекомендательную систему наоснове правил, используя уже готовый механизм наоснове Drools. Вмоем прототипе рекомендательной системы рекомендации можно создавать интерактивно, конструируя логику связей аксессуаров стоварами или похожих товаров между собой. Например, анчоусы кпиву, ментос кколе, березовый сок кбуратино, мыло кверевке, розетку ифай-фай роутер кчаю икофе. Рекомендации это всегда хорошо, когда они сосмыслом.



Rauf Aliev
Complex Realtime Event Processing with Drools Fusion
https://hybrismart.com/2016/10/17/complex-realtime-event-processing-with-drools-fusion-integrating-with-hybris/

Нураз яуже построил этот кластер, янемог его недомучить ипостроить наего основе штуку, которая обрабатывалабы события налету, накладывая наних натомже лету правила. Мне удалось разобраться иподключить Drools Fusion + Drools Server последней версии кhybris. Эта штука правильно называется Complex Event Processing. Смысл втом, что если увас есть поток каких-либо данных для обработки вреальном времени, Drools Fusion позволяет делать это быстро игибко. Например, вслучае екоммерса таких данных много. Самые простые это клики ипереходы

Язаписал ипубликнул демку, изкоторой понятно, как это работает. Логи выгружаются куда-то вхранилище, аоттуда попадают вdrools fusion для обработки. Наязыке drools пишутся правила, которые вытягивают излогов какие-то новые знания. Вмоей демке это просто идентификация фотограф/не фотограф похарактеру посещенных страниц икликов. Например, пользователь уже просмотрел тучу моделей имыделаем вывод, что онлюбит моделей. Или долго водит мышью пофотографии любимого штатива, изчего мыделаем что онлюбит нетолько модели, ноиштативы. Результат правил возвращается обратно вхайбрис икак-нибудь там может использоваться. Баннер показать или цены чуть-чуть понизить нафототехнику.



Основная особенность всего этого, что обрабатывается поток событий вреальном времени. Вмоем примере, это нахождение как минимум пяти страниц одной тематической группы запоследние 30секунд для одного пользователя.



Второй важный пункт втом, что такая система очень масштабируема, ведь каждый сервер работает независимо. Втовремя еще была жива встроенной вхайбрис персонализация. Еепотом заменили наплатный сервис. Она была жутко тормозная ипоэтому еемало кто использовал. Здесьже нагружаются серверы, софт которых нестоит ничего: онбесплатен. Авхайбрис потом пропихиваются уже готовые решения, которые нужно там тупо визуализировать.

Rauf Aliev
Reactive Rule-based Dynamic Forms
https://hybrismart.com/2018/01/04/reactive-rule-based-dynamic-forms-in-hybris-using-drools-7/

Drools также можно использовать для автоматизации сложных форм, ивсвоем эксперименте япоказываю, как это может быть достигнуто. Вэтом эксперименте ядемонстрирую как можно реализовать многостраничную, многоэтапную форму, укоторой состав иконфигурация полей ишагов меняется взависимости отвведенной информации вдругие поля. Такая логика довольно сложно реализуется встандартных подходах кформам, иеепрограммирование значительно облегчается, когда для описания правил используется Drools.



Чтобы плавно закончить тему сDrools иначать тему про всякое разное векоммерсе ихайбрисе, япредставлю еще подробный обзор акционных механик

Rauf Aliev
Promotion Mechanics and Their Implementation inHybris
https://hybrismart.com/2017/04/30/promotion-mechanics-and-their-implementation-in-hybris-6-x/

Замечаете, почти все темы несовсем ипро Хайбрис. Там везде онкаким-то боком есть, новцелом екоммерс это невещь всебе. Все связано совсем.

Конечно, насайте есть еще десятки материалов, которые довольно сложно понять тем, кто вообще неразбирался сХайбрисом.

Rauf Aliev
Merging Carts When ACustomer Logs In: Problems, Solutions, and Recommendations
https://hybrismart.com/2019/02/24/merging-carts-when-a-customer-logs-in-problems-solutions-and-recommendations/

Например, вэтой статье яописываю проблему объединения корзин после аутентификации. Это когда выположили пятьдесят разных уточек вкорзину, апотом авторизовались, амагазин туда подмешал выбранных спрошлого раза 50зайчиков. Есть разные стратегии потому, как разделять уточек изайчиков вэтом примере, ияихразбираю. Стратегии разбираю, незайчиков.



Rauf Aliev
Hybris Impex Preprocessor
https://hybrismart.com/2018/05/27/hybris-impex-preprocessor-impex/

Эта вот тема точно для разбирающихся вхайбрисе. Привожу еетут как пример.

Вхайбрисе есть специальный формат для импорта иэкспорта данных. Называется Impex исостороны очень похож наобычный CSV. Там есть очень простой язык разметки, показывающий, что вот этот блок ниже товары, авот тот блок еще категории кним. Вцелом, довольно удобно, нонетогда, когда утебя двадцать почти одинаковых сайтов наразных языках, икаждый раз, когда добавляешь какой-нибудь интерфейсный компонент навсе двадцать, нужно без ошибок скопипастить одно итоже двадцать раз, ипотом это поддерживать. Уменя был такой проект, ияпредложил решение смакросами наJSON, которые помогали создать импекс изимпекса-с-макросами. Там необычные макросы, асциклами ипараметрами.

Если выничего непоняли, тоэто нормально. Унас еще ишутки есть, которые никто вне тусовки непонимает. Хотя они все грустные, небудем про это. Унасже серьезная статья.

Rauf Aliev
Payments: Alook Inside the Black Box
https://hybrismart.com/2019/09/08/payments-a-look-inside-the-black-box/

Якогда-то работал руководителем разработки вChronopay, истех пор тема электронных платежей висела надо мной тёмной тяжёлой тучей, пока яеёнеприземлил вот вэту статью иосободил мозги под новые челенджи. Там собрано самое необходимое для понимания вопросов интеграции сплатежными шлюзами исервисами, бест практисес итипичные недосмотры, которых нужно избегать (или использовать, если вызлой покупатель).



Rauf Aliev
Server-side PDF document generation
https://hybrismart.com/2017/06/15/pdf-and-sap-hybris/

Аещё раньше, вовремена зачеток ипейджеров, яработал дизайнером иверстальщиком (впрочем, вколоменском педуниверситете ипейджинговой компании Мобилтелеком ятоже работал. Да, яуже старый). Нетем верстальщиком, который HTML, атем, который про книжки ижурналы, аиногда даже православные газеты, телепрограммы иноты. И, конечно, янемог обойти стороной тему Postscript иPDF, которые пугают очень многих из-за туманных иплохо документированных внутренностей. Встатье япоказываю, что нетак страшен черт, иделаю обзор инструментов под генерацию PDF.



Rauf Aliev
Authentication with Hardware Security Keys via Webauthn inSAP Commerce Cloud
https://hybrismart.com/2019/05/23/authentication-with-hardware-security-keys-via-webauthn-in-sap-commerce-cloud/

Вэтой статье яописываю прототип авторизации поUSB-ключикам, ипоследние (намомент статьи) продвиги вэтом направлении нарынке, типа беспарольной авторизации, поддерживаемой браузерами. Удалось интегрировать схайбрисом Yubikey, описываю как оно получалось (иполучилось).



Rauf Aliev
Geofencing: Custom Shipping Zones
https://hybrismart.com/2016/10/19/geofencing-in-hybris-custom-shipping-zones/

Очередной эксперимент: использование размеченных областей накарте Google для различных целей вe-commerce: поиска оптимального склада, поиска доступных магазинов для самовывоза или лучшего доставщика, аможет исамого факта возможности продать товар или услугу покупателю изэтой зоны.

Работает это так: покупатель вводит адрес, асистема его определяет водну или несколько крупных зон. Различные компоненты системы зависят уже отэтих крупных зон, анеотмелких компонентов адреса, таких как почтовый индекс.

Заодно разобрался сразработкой наGoogle AppEngine. Дело втом, что определение многоугольника (зоны), вкоторый входит точка накарте (где покупатель), для ситуации много зон сложной формы потенциально может быть довольно тяжелой вычислительной задачей. Иесли есть возможность, еелучше сразу делать накластере, который может легко масштабироваться, алучше еще исам. Ивот этот кейс отличный для Google AppEngine, где задействован Google DataStore для хранения параметров многоугольников, иGoogle Memcache для хранения кэша.

Rauf Aliev
Page Fragment Caching: Custom, with Varnish, Nginx, Memcached
https://hybrismart.com/2016/07/24/page-fragment-caching-for-hybris/
https://hybrismart.com/2016/07/27/varnish/
https://hybrismart.com/2016/07/30/hybris-page-fragment-caching-with-nginx-and-memcached/

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

Механизм особо эффективен, если тяжелый функционал (всмысле использования памяти ипроцессора) вынесен изконтроллеров страниц вкомпоненты, т.к. для кэширования контроллеров страниц описанная методика подходит неидеально.

Чтобы лучше понять идею, проще всего посмотреть наскриншоты шаблонов всередине статьи.

Rauf Aliev
Best Practices: Migrating Content ToHybris
https://hybrismart.com/2017/01/10/best-practices-migrating-content-to-hybris/

Migrating Data with Pentaho ETL (Kettle)
https://hybrismart.com/2017/01/15/migrating-data-with-pentaho-etl-kettle/

Опубликовал статью про миграцию данных: best practices, инструменты, архитектура моей самописной тулзы. Хоть тут иесть вназвании слово Hybris, нокак ивпрочих, эта статья нена100% про хайбрис, неочень гиковая, так что, надеюсь, будет понятна иинтересна всем, кто знает, что такое миграция данных ввеб-проекте.

Также наблоге есть довольно подробно разобранные темы счат-ботами (Facebook, Skype, кастом), вынесение хранения сессий запределы хайбриса вотдельный сервис, разбор всего, что касается аутентификации илогин-форм, разбор особенностей реализации тревел-сервисов (заказ билетов, отели) часть 1ичасть2, атакже собранные best practices поинтеграции поproduct availability свнешними системами, икакие сложности этот процесс имеет.

Какие еще темы выбы хотели видеть разобранными подобным образом? Поконцепции блога они должны иметь отношение кecommerce. Буду рад любым отзывам ипредложениям.
Подробнее..

Делаем поиск в веб-приложении с нуля

05.11.2020 18:21:17 | Автор: admin
В статье Делаем современное веб-приложение с нуля я рассказал в общих чертах, как выглядит архитектура современных высоконагруженных веб-приложений, и собрал для демонстрации простейшую реализацию такой архитектуры на стеке из нескольких предельно популярных и простых технологий и фреймворков. Мы построили single page application с server side rendering, поддерживающее просмотр неких карточек, набранных в Markdown, и навигацию между ними.

В этой статье я затрону чуть более сложную и интересную (как минимум мне, разработчику команды поиска) тему: полнотекстовый поиск. Мы добавим в наш контейнерный рай ноду Elasticsearch, научимся строить индекс и делать поиск по контенту, взяв в качестве тестовых данных описания пяти тысяч фильмов из TMDB 5000 Movie Dataset. Также мы научимся делать поисковые фильтры и копнём совсем немножко в сторону ранжирования.




Инфраструктура: Elasticsearch


Elasticsearch популярное хранилище документов, умеющее строить полнотекстовые индексы и, как правило, используемое именно как поисковый движок. Elasticsearch добавляет к движку Apache Lucene, на котором он основан, шардирование, репликацию, удобный JSON API и ещё миллион мелочей, которые сделали его одним из самых популярных решений для полнотекстового поиска.

Давайте добавим одну ноду Elasticsearch в наш docker-compose.yml:

services:  ...  elasticsearch:    image: "elasticsearch:7.5.1"    environment:      - discovery.type=single-node    ports:      - "9200:9200"  ...


Переменная окружения discovery.type=single-node подсказывает Elasticsearch, что надо готовиться к работе в одиночку, а не искать другие ноды и объединяться с ними в кластер (таково поведение по умолчанию).

Обратите внимание, что мы публикуем 9200 порт наружу, хотя наше приложение ходит в него внутри сети, создаваемой docker-compose. Это исключительно для отладки: так мы сможем обращаться в Elasticsearch напрямую из терминала (до тех пор, пока не придумаем более умный способ об этом ниже).

Добавить клиент Elasticsearch в наш вайринг не составит труда благо, Elastic предоставляет минималистичный Python-клиент.

Индексация


В прошлой статье мы положили наши основные сущности карточки в коллекцию MongoDB. Из коллекции мы умеем быстро извлекать их содержимое по идентификатору, потому что MongoDB построила для нас прямой индекс в ней для этого используются B-деревья.

Теперь же перед нами стоит обратная задача по содержимому (или его фрагментам) получить идентификаторы карточек. Стало быть, нам нужен обратный индекс. Для него-то нам и пригодится Elasticsearch!

Общая схема построения индекса обычно выглядит как-то так.
  1. Создаём новый пустой индекс с уникальным именем, конфигурируем его как нам нужно.
  2. Обходим все наши сущности в базе и кладём их в новый индекс.
  3. Переключаем продакшн, чтобы все запросы начали ходить в новый индекс.
  4. Удаляем старый индекс. Тут по желанию вы вполне можете захотеть хранить несколько последних индексов, чтобы, например, удобнее было отлаживать какие-то проблемы.


Давайте создадим скелет индексатора и потом разберёмся подробнее с каждым шагом.

import datetimefrom elasticsearch import Elasticsearch, NotFoundErrorfrom backend.storage.card import Card, CardDAOclass Indexer(object):    def __init__(self, elasticsearch_client: Elasticsearch, card_dao: CardDAO, cards_index_alias: str):        self.elasticsearch_client = elasticsearch_client        self.card_dao = card_dao        self.cards_index_alias = cards_index_alias    def build_new_cards_index(self) -> str:        # Построение нового индекса.        # Сначала придумываем для индекса оригинальное название.        index_name = "cards-" + datetime.datetime.now().strftime("%Y-%m-%d-%H-%M-%S")        # Создаём пустой индекс.         # Здесь мы укажем настройки и опишем схему данных.        self.create_empty_cards_index(index_name)        # Кладём в индекс все наши карточки одну за другой.        # В настоящем проекте вы очень скоро захотите         # переписать это на работу в пакетном режиме.        for card in self.card_dao.get_all():            self.put_card_into_index(card, index_name)        return index_name    def create_empty_cards_index(self, index_name):        ...     def put_card_into_index(self, card: Card, index_name: str):        ...    def switch_current_cards_index(self, new_index_name: str):        ... 


Индексация: создаём индекс


Индекс в Elasticsearch создаётся простым PUT-запросом в /имя-индекса или, в случае использования Python-клиента (нашем случае), вызовом

elasticsearch_client.indices.create(index_name, {    ...})


Тело запроса может содержать три поля.

  • Описание алиасов ("aliases": ...). Система алиасов позволяет держать знание о том, какой индекс сейчас актуальный, на стороне Elasticsearch; мы поговорим про неё ниже.
  • Настройки ("settings": ...). Когда мы будем большими дядями с настоящим продакшном, мы сможем сконфигурировать здесь репликацию, шардирование и другие радости SRE.
  • Схема данных ("mappings": ...). Здесь мы можем указать, какого типа какие поля в документах, которые мы будем индексировать, для каких из этих полей нужны обратные индексы, по каким должны быть поддержаны агрегации и так далее.


Сейчас нас интересует только схема, и у нас она очень простая:

{    "mappings": {        "properties": {            "name": {                "type": "text",                "analyzer": "english"            },            "text": {                "type": "text",                "analyzer": "english"            },            "tags": {                "type": "keyword",                "fields": {                    "text": {                        "type": "text",                        "analyzer": "english"                    }                }            }        }    }}


Мы пометили поля name и text как текстовые на английском языке. Анализатор это сущность в Elasticsearch, которая обрабатывает текст перед сохранением в индекс. В случае english анализатора текст будет разбит на токены по границам слов (подробности), после чего отдельные токены будут лемматизированы по правилам английского языка (например, слово trees упростится до tree), слишком общие леммы (вроде the) будут удалены и оставшиеся леммы будут положены в обратный индекс.

С полем tags чуть-чуть сложнее. Тип keyword предполагает, что значения этого поля некие строковые константы, которые не надо обрабатывать анализатором; обратный индекс будет построен по их сырым значениям без токенизации и лемматизации. Зато Elasticsearch создаст специальные структуры данных, чтобы по значениям этого поля можно было считать агрегации (например, чтобы одновременно с поиском можно было узнать, какие теги встречались в документах, удовлетворяющих поисковому запросу, и в каком количестве). Это очень удобно для полей, которые по сути enum; мы воспользуемся этой фичей, чтобы сделать клёвые поисковые фильтры.

Но чтобы по тексту тегов можно было искать и текстовым поиском тоже, мы добавляем к нему подполе "text", настроенное по аналогии с name и text выше по существу это означает, что Elasticsearch во всех приходящих ему документах будет создавать ещё одно виртуальное поле под названием tags.text, в которое будет копировать содержимое tags, но индексировать его по другим правилам.

Индексация: наполняем индекс


Для индексации документа достаточно сделать PUT-запрос в /имя-индекса/_create/id-документа или, при использовании Python-клиента, просто вызвать нужный метод. Наша реализация будет выглядеть так:

    def put_card_into_index(self, card: Card, index_name: str):        self.elasticsearch_client.create(index_name, card.id, {            "name": card.name,            "text": card.markdown,            "tags": card.tags,        })


Обратите внимание на поле tags. Хотя мы описали его как содержащее keyword, мы отправляем не одну строку, а список строк. Elasticsearch поддерживает такое; наш документ будет находиться по любому из значений.

Индексация: переключаем индекс


Чтобы реализовать поиск, нам надо знать имя самого свежего полностью достроенного индекса. Механизм алиасов позволяет нам держать эту информацию на стороне Elasticsearch.

Алиас это указатель на ноль или более индексов. API Elasticsearch позволяет использовать имя алиаса вместо имени индекса при поиске (POST /имя-алиаса/_search вместо POST /имя-индекса/_search); в таком случае Elasticsearch будет искать по всем индексам, на которые указывает алиас.

Мы заведём алиас под названием cards, который всегда будет указывать на актуальный индекс. Соответственно, переключение на актуальный индекс после завершения построения будет выглядеть так:

    def switch_current_cards_index(self, new_index_name: str):        try:            # Нужно удалить ссылку на старый индекс, если она есть.            remove_actions = [                {                    "remove": {                        "index": index_name,                         "alias": self.cards_index_alias,                    }                }                for index_name in self.elasticsearch_client.indices.get_alias(name=self.cards_index_alias)            ]        except NotFoundError:            # Ого, старого индекса-то и не существует вовсе.            # Наверное, мы впервые запустили индексацию.            remove_actions = []        # Одним махом удаляем ссылку на старый индекс         # и добавляем ссылку на новый.        self.elasticsearch_client.indices.update_aliases({            "actions": remove_actions + [{                "add": {                    "index": new_index_name,                     "alias": self.cards_index_alias,                }            }]        })


Я не стану подробнее останавливаться на alias API; все подробности можно посмотреть в документации.

Здесь надо сделать ремарку, что в реальном высоконагруженном сервисе такое переключение может быть довольно болезненным и может иметь смысл сделать предварительный прогрев нагрузить новый индекс каким-то пулом сохранённых пользовательских запросов.

Весь код, реализующий индексацию, можно посмотреть в этом коммите.

Индексация: добавляем контент


Для демонстрации в этой статье я использую данные из TMDB 5000 Movie Dataset. Чтобы избежать проблем с авторскими правами, я лишь привожу код утилиты, импортирующей их из CSV-файла, который предлагаю вам скачать самостоятельно с сайта Kaggle. После загрузки достаточно выполнить команду

docker-compose exec -T backend python -m tools.add_movies < ~/Downloads/tmdb-movie-metadata/tmdb_5000_movies.csv


, чтобы создать пять тысяч карточек, посвящённых кино, и команду

docker-compose exec backend python -m tools.build_index


, чтобы построить индекс. Обратите внимание, что последняя команда на самом деле не строит индекс, а только ставит задачу в очередь задач, после чего она выполнится на воркере подробнее об этом подходе я рассказывал в прошлой статье. docker-compose logs worker покажут вам, как воркер старался!

Прежде, чем мы приступим к, собственно, поиску, нам хочется своими глазами увидеть, записалось ли что-нибудь в Elasticsearch, и если да, то как оно выглядит!

Наиболее прямой и быстрый способ это сделать воспользоваться HTTP API Elasticsearch. Сперва проверим, куда указывает алиас:

$ curl -s localhost:9200/_cat/aliasescards                cards-2020-09-20-16-14-18 - - - -


Отлично, индекс существует! Посмотрим на него пристально:

$ curl -s localhost:9200/cards-2020-09-20-16-14-18 | jq{  "cards-2020-09-20-16-14-18": {    "aliases": {      "cards": {}    },    "mappings": {      ...    },    "settings": {      "index": {        "creation_date": "1600618458522",        "number_of_shards": "1",        "number_of_replicas": "1",        "uuid": "iLX7A8WZQuCkRSOd7mjgMg",        "version": {          "created": "7050199"        },        "provided_name": "cards-2020-09-20-16-14-18"      }    }  }}


Ну и, наконец, посмотрим на его содержимое:

$ curl -s localhost:9200/cards-2020-09-20-16-14-18/_search | jq{  "took": 2,  "timed_out": false,  "_shards": {    "total": 1,    "successful": 1,    "skipped": 0,    "failed": 0  },  "hits": {    "total": {      "value": 4704,      "relation": "eq"    },    "max_score": 1,    "hits": [      ...    ]  }}


Итого в нашем индексе 4704 документа, а в поле hits (которое я пропустил, потому что оно слишком большое) можно даже увидеть содержимое некоторых из них. Успех!

Более удобным способом просмотра содержимого индекса и вообще всевозможного баловства с Elasticsearch будет воспользоваться Kibana. Добавим контейнер в docker-compose.yml:

services:  ...  kibana:    image: "kibana:7.5.1"    ports:      - "5601:5601"    depends_on:      - elasticsearch  ...


После повторного docker-compose up мы сможем зайти в Kibana по адресу localhost:5601 (внимание, сервер может стартовать небыстро) и, после короткой настройки, просмотреть содержимое наших индексов в симпатичном веб-интерфейсе.



Очень советую вкладку Dev Tools при разработке вам часто нужно будет делать те или иные запросы в Elasticsearch, и в интерактивном режиме с автодополнением и автоформатированием это гораздо удобнее.

Поиск


После всех невероятно скучных приготовлений пора нам уже добавить функциональность поиска в наше веб-приложение!

Разделим эту нетривиальную задачу на три этапа и обсудим каждый в отдельности.

  1. Добавляем в бэкенд компонент Searcher, отвечающий за логику поиска. Он будет формировать запрос к Elasticsearch и конвертировать результаты в более удобоваримые для нашего бэкенда.
  2. Добавляем в API эндпоинт (ручку/роут/как у вас в компании это называют?) /cards/search, осуществляющий поиск. Он будет вызывать метод компонента Searcher, обрабатывать полученные результаты и возвращать клиенту.
  3. Реализуем интерфейс поиска на фронтенде. Он будет обращаться в /cards/search, когда пользователь определился, что он хочет искать, и отображать результаты (и, возможно, какие-то дополнительные контролы).


Поиск: реализуем


Не так сложно написать менеджер, осуществляющий поиск, как его задизайнить. Давайте опишем результат поиска и интерфейс менеджера и обсудим, почему он такой, а не иной.

# backend/backend/search/searcher.pyimport abcfrom dataclasses import dataclassfrom typing import Iterable, Optional@dataclassclass CardSearchResult:    total_count: int    card_ids: Iterable[str]    next_card_offset: Optional[int]class Searcher(metaclass=abc.ABCMeta):    @abc.abstractmethod    def search_cards(self, query: str = "",                      count: int = 20, offset: int = 0) -> CardSearchResult:        pass


Какие-то вещи очевидны. Например, пагинация. Мы амбициозный молодой убийца IMDB стартап, и результаты поиска никогда не будут вмещаться на одну страницу!

Какие-то менее очевидны. Например, список ID, а не карточек в качестве результата. Elasticsearch по умолчанию хранит наши документы целиком и возвращает их в результатах поиска. Это поведение можно отключить, чтобы сэкономить на размере поискового индекса, но для нас это явно преждевременная оптимизация. Так почему бы не возвращать сразу карточки? Ответ: это нарушит single-responsibility principle. Возможно, когда-нибудь мы накрутим в менеджере карточек сложную логику, переводящую карточки на другие языки в зависимости от настроек пользователя. Ровно в этот момент данные на странице карточки и данные в результатах поиска разъедутся, потому что добавить ту же самую логику в поисковый менеджер мы забудем. И так далее и тому подобное.

Реализация этого интерфейса настолько проста, что мне было лень писать этот раздел :-(

# backend/backend/search/searcher_impl.pyfrom typing import Anyfrom elasticsearch import Elasticsearchfrom backend.search.searcher import CardSearchResult, SearcherElasticsearchQuery = Any  # для аннотаций типовclass ElasticsearchSearcher(Searcher):    def __init__(self, elasticsearch_client: Elasticsearch, cards_index_name: str):        self.elasticsearch_client = elasticsearch_client        self.cards_index_name = cards_index_name    def search_cards(self, query: str = "", count: int = 20, offset: int = 0) -> CardSearchResult:        result = self.elasticsearch_client.search(index=self.cards_index_name, body={            "size": count,            "from": offset,            "query": self._make_text_query(query) if query else self._match_all_query        })        total_count = result["hits"]["total"]["value"]        return CardSearchResult(            total_count=total_count,            card_ids=[hit["_id"] for hit in result["hits"]["hits"]],            next_card_offset=offset + count if offset + count < total_count else None,        )    def _make_text_query(self, query: str) -> ElasticsearchQuery:        return {            # Multi-match query делает текстовый поиск по             # совокупности полей документов (в отличие от match            # query, которая ищет по одному полю).            "multi_match": {                "query": query,                # Число после ^  приоритет. Найти фрагмент текста                # в названии карточки лучше, чем в описании и тегах.                "fields": ["name^3", "tags.text", "text"],            }        }    _match_all_query: ElasticsearchQuery = {"match_all": {}}


По сути мы просто ходим в API Elasticsearch и аккуратно достаём ID найденных карточек из результата.

Реализация эндпоинта тоже довольно тривиальна:

# backend/backend/server.py...    def search_cards(self):        request = flask.request.json        search_result = self.wiring.searcher.search_cards(**request)        cards = self.wiring.card_dao.get_by_ids(search_result.card_ids)        return flask.jsonify({            "totalCount": search_result.total_count,            "cards": [                {                    "id": card.id,                    "slug": card.slug,                    "name": card.name,                    # Здесь не нужны все поля, иначе данных на одной                    # странице поиска будет слишком много, и она будет                    # долго грузиться.                } for card in cards            ],            "nextCardOffset": search_result.next_card_offset,        })...


Реализация фронтенда, пользующегося этим эндпоинтом, хоть и объёмна, но в целом довольно прямолинейна и в этой статье я не хочу заострять на ней внимание. На весь код можно посмотреть в этом коммите.



So far so good, идём дальше.

Поиск: добавляем фильтры


Поиск по тексту это клёво, но если вы когда-нибудь пользовались поиском на серьёзных ресурсах, вы наверняка видели всякие плюшки вроде фильтров.

У наших описаний фильмов из базы TMDB 5000 помимо названий и описаний есть теги, так что давайте для тренировки реализуем фильтры по тегам. Наша цель на скриншоте: при клике на тег в выдаче должны остаться только фильмы с этим тегом (их число указано в скобках рядом с ним).



Чтобы реализовать фильтры, нам нужно решить две проблемы.
  • Научиться по запросу понимать, какой набор фильтров доступен. Мы не хотим показывать все возможные значения фильтра на каждом экране, потому что их очень много и при этом большинство будет приводить к пустому результату; нужно понять, какие теги есть у документов, найденных по запросу, и в идеале оставить N самых популярных.
  • Научиться, собственно, применять фильтр оставить в выдаче только документы с тегами, фильтр по которым выбрал пользователь.


Второе в Elasticsearch элементарно реализуется через API запросов (см. terms query), первое через чуть менее тривиальный механизм агрегаций.

Итак, нам надо знать, какие теги встречаются у найденных карточек, и уметь отфильтровывать карточки с нужными тегами. Сперва обновим дизайн поискового менеджера:

# backend/backend/search/searcher.pyimport abcfrom dataclasses import dataclassfrom typing import Iterable, Optional@dataclassclass TagStats:    tag: str    cards_count: int@dataclassclass CardSearchResult:    total_count: int    card_ids: Iterable[str]    next_card_offset: Optional[int]    tag_stats: Iterable[TagStats]class Searcher(metaclass=abc.ABCMeta):    @abc.abstractmethod    def search_cards(self, query: str = "",                      count: int = 20, offset: int = 0,                     tags: Optional[Iterable[str]] = None) -> CardSearchResult:        pass


Теперь перейдём к реализации. Первое, что нам нужно сделать завести агрегацию по полю tags:

--- a/backend/backend/search/searcher_impl.py+++ b/backend/backend/search/searcher_impl.py@@ -10,6 +10,8 @@ ElasticsearchQuery = Any  class ElasticsearchSearcher(Searcher): +    TAGS_AGGREGATION_NAME = "tags_aggregation"+     def __init__(self, elasticsearch_client: Elasticsearch, cards_index_name: str):         self.elasticsearch_client = elasticsearch_client         self.cards_index_name = cards_index_name@@ -18,7 +20,12 @@ class ElasticsearchSearcher(Searcher):         result = self.elasticsearch_client.search(index=self.cards_index_name, body={             "size": count,             "from": offset,             "query": self._make_text_query(query) if query else self._match_all_query,+            "aggregations": {+                self.TAGS_AGGREGATION_NAME: {+                    "terms": {"field": "tags"}+                }+            }         })


Теперь в поисковом результате от Elasticsearch будет приходить поле aggregations, из которого по ключу TAGS_AGGREGATION_NAME мы сможем достать бакеты, содержащие информацию о том, какие значения лежат в поле tags у найденных документов и как часто они встречаются. Давайте извлечём эти данные и вернём в удобоваримом виде (as designed above):

--- a/backend/backend/search/searcher_impl.py+++ b/backend/backend/search/searcher_impl.py@@ -28,10 +28,15 @@ class ElasticsearchSearcher(Searcher):         total_count = result["hits"]["total"]["value"]+        tag_stats = [+            TagStats(tag=bucket["key"], cards_count=bucket["doc_count"])+            for bucket in result["aggregations"][self.TAGS_AGGREGATION_NAME]["buckets"]+        ]         return CardSearchResult(             total_count=total_count,             card_ids=[hit["_id"] for hit in result["hits"]["hits"]],             next_card_offset=offset + count if offset + count < total_count else None,+            tag_stats=tag_stats,         )


Добавить применение фильтра самая лёгкая часть:

--- a/backend/backend/search/searcher_impl.py+++ b/backend/backend/search/searcher_impl.py@@ -16,11 +16,17 @@ class ElasticsearchSearcher(Searcher):         self.elasticsearch_client = elasticsearch_client         self.cards_index_name = cards_index_name -    def search_cards(self, query: str = "", count: int = 20, offset: int = 0) -> CardSearchResult:+    def search_cards(self, query: str = "", count: int = 20, offset: int = 0,+                     tags: Optional[Iterable[str]] = None) -> CardSearchResult:         result = self.elasticsearch_client.search(index=self.cards_index_name, body={             "size": count,             "from": offset,-            "query": self._make_text_query(query) if query else self._match_all_query,+            "query": {+                "bool": {+                    "must": self._make_text_queries(query),+                    "filter": self._make_filter_queries(tags),+                }+            },             "aggregations": {


Подзапросы, включённые в must-клаузу, обязательны к выполнению, но также будут учитываться при расчёте скоров документов и, соответственно, ранжировании; если мы когда-нибудь будем добавлять ещё какие-то условия на тексты, их лучше добавить сюда. Подзапросы в filter-клаузе только фильтруют, не влияя на скоры и ранжирование.

Осталось реализовать _make_filter_queries():

    def _make_filter_queries(self, tags: Optional[Iterable[str]] = None) -> List[ElasticsearchQuery]:        return [] if tags is None else [{            "term": {                "tags": {                    "value": tag                }            }        } for tag in tags]


На фронтенд-части опять-таки не стану останавливаться; весь код в этом коммите.

Ранжирование


Итак, наш поиск ищет карточки, фильтрует их по заданному списку тегов и выводит в каком-то порядке. Но в каком? Порядок очень важен для практичного поиска, но всё, что мы сделали за время наших разбирательств в плане порядка это намекнули Elasticsearch, что находить слова в заголовке карточки выгоднее, чем в описании или тегах, указав приоритет ^3 в multi-match query.

Несмотря на то, что по умолчанию Elasticsearch ранжирует документы довольно хитрой формулой на основе TF-IDF, для нашего воображаемого амбициозного стартапа этого вряд ли хватит. Если наши документы это товары, нам надо уметь учитывать их продажи; если это user-generated контент уметь учитывать его свежесть, и так далее. Но и просто отсортировать по числу продаж/дате добавления мы не можем, потому что тогда мы никак не учтём релевантность поисковому запросу.

Ранжирование это большое и запутанное царство технологий, которое никак не покрыть за один раздел в конце статьи. Поэтому здесь я перехожу в режим крупных мазков; я попробую рассказать в самых общих словах, как может быть устроено industrial grade ранжирование в поиске, и раскрою немного технических деталей того, как его можно реализовать с Elasticsearch.

Задача ранжирования очень сложна, так что неудивительно, что один из основных современных методов её решения машинное обучение. Приложение технологий машинного обучения к ранжированию собирательно называется learning to rank.

Типичный процесс выглядит так.

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

Определяемся, как мы хотим ранжировать. Мы решаем, по какой характеристике надо отранжировать нашу выдачу, в соответствии с бизнес-целями нашего сервиса. Например, если наши сущности это товары, которые мы продаём, мы можем хотеть отсортировать их по убыванию вероятности покупки; если мемы по вероятности лайка или шера и так далее. Эти вероятности мы, конечно, не умеем считать в лучшем случае прикидывать, да и то только для старых сущностей, для которых у нас набрано достаточно статистики, но мы попытаемся научить модель предсказывать их, исходя из косвенных признаков.

Извлекаем признаки. Мы придумываем для наших сущностей какое-то множество признаков, которые могли бы помочь нам оценить релевантность сущностей поисковым запросам. Помимо того же TF-IDF, который уже умеет для нас вычислять Elasticsearch, типичный пример CTR (click-through rate): мы берём логи нашего сервиса за всё время, для каждой пары сущность+поисковый запрос считаем, сколько раз сущность появлялась в выдаче по этому запросу и сколько раз её кликали, делим одно на другое, et voil простейшая оценка условной вероятности клика готова. Мы также можем придумать признаки для пользователя и парные признаки пользователь-сущность, чтобы сделать ранжирование персонализированным. Придумав признаки, мы пишем код, который их вычисляет, кладёт в какое-то хранилище и умеет отдавать в real time для заданного поискового запроса, пользователя и набора сущностей.

Собираем обучающий датасет. Тут много вариантов, но все они, как правило, формируются из логов хороших (например, клик и потом покупка) и плохих (например, клик и возврат на выдачу) событий в нашем сервисе. Когда мы собрали датасет, будь то список утверждений оценка релевантности товара X запросу Q примерно равна P, список пар товар X релевантнее товара Y запросу Q или набор списков для запроса Q товары P1, P2, правильно отранжировать так-то, мы ко всем фигурирующим в нём строкам подтягиваем соответствующие признаки.

Обучаем модель. Тут вся классика ML: train/test, гиперпараметры, переобучение, перфовидеокарты и так далее. Моделей, подходящих (и повсеместно использующихся) для ранжирования, много; упомяну как минимум XGBoost и CatBoost.

Встраиваем модель. Нам остаётся так или иначе прикрутить вычисление модели на лету для всего топа, чтобы до пользователя долетали уже отранжированные результаты. Тут много вариантов; в иллюстративных целях я (опять-таки) остановлюсь на простом Elasticsearch-плагине Learning to Rank.

Ранжирование: плагин Elasticsearch Learning to Rank


Elasticsearch Learning to Rank это плагин, добавляющий в Elasticsearch возможность вычислить ML-модель на выдаче и тут же отранжировать результаты согласно посчитанным ею скорам. Он также поможет нам получить признаки, идентичные используемым в real time, переиспользовав при этом способности Elasticsearch (TF-IDF и тому подобное).

Для начала нам нужно подключить плагин в нашем контейнере с Elasticsearch. Нам потребуется простенький Dockerfile

# elasticsearch/DockerfileFROM elasticsearch:7.5.1RUN ./bin/elasticsearch-plugin install --batch http://es-learn-to-rank.labs.o19s.com/ltr-1.1.2-es7.5.1.zip


и сопутствующие изменения в docker-compose.yml:

--- a/docker-compose.yml+++ b/docker-compose.yml@@ -5,7 +5,8 @@ services:   elasticsearch:-    image: "elasticsearch:7.5.1"+    build:+      context: elasticsearch     environment:       - discovery.type=single-node


Также нам потребуется поддержка плагина в Python-клиенте. С изумлением я обнаружил, что поддержка для Python не идёт в комплекте с плагином, так что специально для этой статьи я её запилил. Добавим elasticsearch_ltr в requirements.txt и проапгрейдим клиент в вайринге:

--- a/backend/backend/wiring.py+++ b/backend/backend/wiring.py@@ -1,5 +1,6 @@ import os +from elasticsearch_ltr import LTRClient from celery import Celery from elasticsearch import Elasticsearch from pymongo import MongoClient@@ -39,5 +40,6 @@ class Wiring(object):         self.task_manager = TaskManager(self.celery_app)          self.elasticsearch_client = Elasticsearch(hosts=self.settings.ELASTICSEARCH_HOSTS)+        LTRClient.infect_client(self.elasticsearch_client)         self.indexer = Indexer(self.elasticsearch_client, self.card_dao, self.settings.CARDS_INDEX_ALIAS)         self.searcher: Searcher = ElasticsearchSearcher(self.elasticsearch_client, self.settings.CARDS_INDEX_ALIAS)


Ранжирование: пилим признаки


Каждый запрос в Elasticsearch возвращает не только список ID документов, которые нашлись, но и некоторые их скоры (как вы бы перевели на русский язык слово score?). Так, если это match или multi-match query, которую мы используем, то скор это результат вычисления той самой хитрой формулы с участием TF-IDF; если bool query комбинация скоров вложенных запросов; если function score query результат вычисления заданной функции (например, значение какого-то числового поля в документе) и так далее. Плагин ELTR предоставляет нам возможность использовать скор любого запроса как признак, позволяя легко скомбинировать данные о том, насколько хорошо документ соответствует запросу (через multi-match query) и какие-то предрассчитанные статистики, которые мы заранее кладём в документ (через function score query).

Поскольку на руках у нас база TMDB 5000, в которой лежат описания фильмов и, помимо прочего, их рейтинги, давайте возьмём рейтинг в качестве образцово-показательного предрассчитанного признака.

В этом коммите я добавил в бэкенд нашего веб-приложения некую базовую инфраструктуру для хранения признаков и поддержал подгрузку рейтинга из файла с фильмами. Дабы не заставлять вас читать очередную кучу кода, опишу самое основное.

  • Признаки мы будем хранить в отдельной коллекции и доставать отдельным менеджером. Сваливать все данные в одну сущность порочная практика.
  • В этот менеджер мы будем обращаться на этапе индексации и класть все имеющиеся признаки в индексируемые документы.
  • Чтобы знать схему индекса, нам надо перед началом построения индекса знать список всех существующих признаков. Этот список мы пока что захардкодим.
  • Поскольку мы не собираемся фильтровать документы по значениям признаков, а собираемся только извлекать их из уже найденных документов для обсчёта модели, мы выключим построение по новым полям обратных индексов опцией index: false в схеме и сэкономим за счёт этого немного места.


Ранжирование: собираем датасет


Поскольку, во-первых, у нас нет продакшна, а во-вторых, поля этой статьи слишком малы для рассказа про телеметрию, Kafka, NiFi, Hadoop, Spark и построение ETL-процессов, я просто сгенерирую случайные просмотры и клики для наших карточек и каких-то поисковых запросов. После этого нужно будет рассчитать признаки для получившихся пар карточка-запрос.

Пришла пора закопаться поглубже в API плагина ELTR. Чтобы рассчитать признаки, нам нужно будет создать сущность feature store (насколько я понимаю, фактически это просто индекс в Elasticsearch, в котором плагин хранит все свои данные), потом создать feature set список признаков с описанием, как вычислять каждый из них. После этого нам достаточно будет сходить в Elasticsearch с запросом специального вида, чтобы получить вектор значений признаков для каждой найденной сущности в результате.

Начнём с создания feature set:

# backend/backend/search/ranking.pyfrom typing import Iterable, List, Mappingfrom elasticsearch import Elasticsearchfrom elasticsearch_ltr import LTRClientfrom backend.search.features import CardFeaturesManagerclass SearchRankingManager:    DEFAULT_FEATURE_SET_NAME = "card_features"    def __init__(self, elasticsearch_client: Elasticsearch,                  card_features_manager: CardFeaturesManager,                 cards_index_name: str):        self.elasticsearch_client = elasticsearch_client        self.card_features_manager = card_features_manager        self.cards_index_name = cards_index_name    def initialize_ranking(self, feature_set_name=DEFAULT_FEATURE_SET_NAME):        ltr: LTRClient = self.elasticsearch_client.ltr        try:            # Создать feature store обязательно для работы,            # но при этом его нельзя создавать дважды \_()_/            ltr.create_feature_store()        except Exception as exc:            if "resource_already_exists_exception" not in str(exc):                raise        # Создаём feature set с невероятными ТРЕМЯ признаками!        ltr.create_feature_set(feature_set_name, {            "featureset": {                "features": [                    # Совпадение поискового запроса с названием                    # карточки может быть более сильным признаком,                     # чем совпадение со всем содержимым, поэтому                     # сделаем отдельный признак про это.                    self._make_feature("name_tf_idf", ["query"], {                        "match": {                            # ELTR позволяет параметризовать                            # запросы, вычисляющие признаки. В данном                            # случае нам, очевидно, нужен текст                             # запроса, чтобы правильно посчитать                             # скор match query.                            "name": "{{query}}"                        }                    }),                    # Скор запроса, которым мы ищем сейчас.                    self._make_feature("combined_tf_idf", ["query"], {                        "multi_match": {                            "query": "{{query}}",                            "fields": ["name^3", "tags.text", "text"]                        }                    }),                    *(                        # Добавляем все имеющиеся предрассчитанные                        # признаки через механизм function score.                        # Если по какой-то причине в документе                         # отсутствует искомое поле, берём 0.                        # (В настоящем проекте вам стоит                        # предусмотреть умолчания получше!)                        self._make_feature(feature_name, [], {                            "function_score": {                                "field_value_factor": {                                    "field": feature_name,                                    "missing": 0                                }                            }                        })                        for feature_name in sorted(self.card_features_manager.get_all_feature_names_set())                    )                ]            }        })    @staticmethod    def _make_feature(name, params, query):        return {            "name": name,            "params": params,            "template_language": "mustache",            "template": query,        }


Теперь функция, вычисляющая признаки для заданного запроса и карточек:

    def compute_cards_features(self, query: str, card_ids: Iterable[str],                                feature_set_name=DEFAULT_FEATURE_SET_NAME) -> Mapping[str, List[float]]:        card_ids = list(card_ids)        result = self.elasticsearch_client.search({            "query": {                "bool": {                    # Нам не нужно проверять, находятся ли карточки                    # на самом деле по такому запросу  если нет,                     # соответствующие признаки просто будут нулевыми.                    # Поэтому оставляем только фильтр по ID.                    "filter": [                        {                            "terms": {                                "_id": card_ids                            }                        },                        # Это  специальный новый тип запроса,                        # вводимый плагином SLTR. Он заставит                        # плагин посчитать все факторы из указанного                        # feature set.                        # (Несмотря на то, что мы всё ещё в разделе                        # filter, этот запрос ничего не фильтрует.)                        {                            "sltr": {                                "_name": "logged_featureset",                                "featureset": feature_set_name,                                "params": {                                    # Та самая параметризация.                                     # Строка, переданная сюда,                                    # подставится в запросах                                    # вместо {{query}}.                                    "query": query                                }                            }                        }                    ]                }            },            # Следующая конструкция заставит плагин запомнить все            # рассчитанные признаки и добавить их в результат поиска.            "ext": {                "ltr_log": {                    "log_specs": {                        "name": "log_entry1",                        "named_query": "logged_featureset"                    }                }            },            "size": len(card_ids),        })        # Осталось достать значения признаков из (несколько        # замысловатого) результата поиска.        # (Чтобы понять, где в недрах результатов нужные мне         # значения, я просто делаю пробные запросы в Kibana.)        return {            hit["_id"]: [feature.get("value", float("nan")) for feature in hit["fields"]["_ltrlog"][0]["log_entry1"]]            for hit in result["hits"]["hits"]        }


Простенький скрипт, принимающий на вход CSV с запросами и ID карточек и выдающий CSV с признаками:

# backend/tools/compute_movie_features.pyimport csvimport itertoolsimport sysimport tqdmfrom backend.wiring import Wiringif __name__ == "__main__":    wiring = Wiring()    reader = iter(csv.reader(sys.stdin))    header = next(reader)    feature_names = wiring.search_ranking_manager.get_feature_names()    writer = csv.writer(sys.stdout)    writer.writerow(["query", "card_id"] + feature_names)    query_index = header.index("query")    card_id_index = header.index("card_id")    chunks = itertools.groupby(reader, lambda row: row[query_index])    for query, rows in tqdm.tqdm(chunks):        card_ids = [row[card_id_index] for row in rows]        features = wiring.search_ranking_manager.compute_cards_features(query, card_ids)        for card_id in card_ids:            writer.writerow((query, card_id, *features[card_id]))


Наконец можно это всё запустить!

# Создаём feature setdocker-compose exec backend python -m tools.initialize_search_ranking# Генерируем событияdocker-compose exec -T backend \    python -m tools.generate_movie_events \    < ~/Downloads/tmdb-movie-metadata/tmdb_5000_movies.csv \    > ~/Downloads/habr-app-demo-dataset-events.csv# Считаем признакиdocker-compose exec -T backend \    python -m tools.compute_features \    < ~/Downloads/habr-app-demo-dataset-events.csv \    > ~/Downloads/habr-app-demo-dataset-features.csv


Теперь у нас есть два файла с событиями и признаками и мы можем приступить к обучению.

Ранжирование: обучаем и внедряем модель


Опустим подробности загрузки датасетов (скрипт полностью можно посмотреть в этом коммите) и перейдём сразу к делу.

# backend/tools/train_model.py... if __name__ == "__main__":    args = parser.parse_args()    feature_names, features = read_features(args.features)    events = read_events(args.events)    # Разделим запросы на train и test в соотношении 4 к 1.    all_queries = set(events.keys())    train_queries = random.sample(all_queries, int(0.8 * len(all_queries)))    test_queries = all_queries - set(train_queries)    # DMatrix  это тип данных, используемый xgboost.    # Фактически это массив значений признаков с названиями     # и лейблами. В качестве лейбла мы берём 1, если был клик,     # и 0, если не было (детали см. в коммите).    train_dmatrix = make_dmatrix(train_queries, events, feature_names, features)    test_dmatrix = make_dmatrix(test_queries, events, feature_names, features)    # Учим модель!    # Поля этой статьи всё ещё крайне малы для долгого разговора     # про ML, так что я возьму минимально модифицированный пример     # из официального туториала к XGBoost.    param = {        "max_depth": 2,        "eta": 0.3,        "objective": "binary:logistic",        "eval_metric": "auc",    }    num_round = 10    booster = xgboost.train(param, train_dmatrix, num_round, evals=((train_dmatrix, "train"), (test_dmatrix, "test")))    # Сохраняем обученную модель в файл.     booster.dump_model(args.output, dump_format="json")     # Санитарный минимум проверки того, как прошло обучение: давайте    # посмотрим на топ признаков по значимости и на ROC-кривую.    xgboost.plot_importance(booster)    plt.figure()    build_roc(test_dmatrix.get_label(), booster.predict(test_dmatrix))    plt.show()


Запускаем

python backend/tools/train_search_ranking_model.py \    --events ~/Downloads/habr-app-demo-dataset-events.csv \    --features ~/Downloads/habr-app-demo-dataset-features.csv \     -o ~/Downloads/habr-app-demo-model.xgb


Обратите внимание, что поскольку мы экспортировали все нужные данные предыдущими скриптами, этот скрипт уже не надо запускать внутри докера его нужно запускать на вашей машине, предварительно установив xgboost и sklearn. Аналогично в настоящем продакшне предыдущие скрипты нужно было бы запускать где-то, где есть доступ в продакшн-окружение, а этот нет.

Если всё сделано правильно, модель успешно обучится, и мы увидим две красивых картинки. Первая график значимости признаков:



Хотя события генерировались случайно, combined_tf_idf оказался сильно значимее других потому что я сделал фокус и искусственно понизил вероятность клика для карточек, которые ниже в выдаче, отранжированной нашим старым способом. То, что модель это заметила добрый знак и признак того, что мы не совершили в процессе обучения каких-то совсем глупых ошибок.

Второй график ROC-кривая:



Синяя линия выше красной, а значит, наша модель предсказывает лейблы чуть-чуть лучше, чем бросок монетки. (Кривая ML-инженера маминой подруги должна почти касаться верхнего левого угла).

Дело совсем за малым добавляем скрипт для заливки модели, заливаем и добавляем маленький новый пункт в поисковый запрос рескоринг:

--- a/backend/backend/search/searcher_impl.py+++ b/backend/backend/search/searcher_impl.py@@ -27,6 +30,19 @@ class ElasticsearchSearcher(Searcher):                     "filter": list(self._make_filter_queries(tags, ids)),                 }             },+            "rescore": {+                "window_size": 1000,+                "query": {+                    "rescore_query": {+                        "sltr": {+                            "params": {+                                "query": query+                            },+                            "model": self.ranking_manager.get_current_model_name()+                        }+                    }+                }+            },             "aggregations": {                 self.TAGS_AGGREGATION_NAME: {                     "terms": {"field": "tags"}


Теперь после того, как Elasticsearch произведёт нужный нам поиск и отранжирует результаты своим (довольно быстрым) алгоритмом, мы возьмём топ-1000 результатов и переранжируем, применив нашу (относительно медленную) машинно-обученную формулу. Успех!

Заключение


Мы взяли наше минималистичное веб-приложение и прошли путь от отсутствия фичи поиска как таковой до масштабируемого решения со множеством продвинутых возможностей. Сделать это было не так уж просто. Но и не так уж сложно! Итоговое приложение лежит в репозитории на Github в ветке со скромным названием feature/search и требует для запуска Docker и Python 3 с библиотеками для машинного обучения.

Чтобы показать, как это в целом работает, какие проблемы встречаются и как их можно решить, я использовал Elasticsearch, но это, конечно, не единственный инструмент, который можно выбрать. Solr, полнотекстовые индексы PostgreSQL и другие движки точно так же заслуживают вашего внимания при выборе, на чём построить свою многомиллиардную корпорацию поисковую систему.

И, конечно, это решение не претендует на законченность и готовность к продакшну, а является исключительно иллюстрацией того, как всё может быть сделано. Улучшать его можно практически бесконечно!
  • Инкрементальная индексация. При модификации наших карточек через CardManager хорошо бы сразу обновлять их в индексе. Чтобы CardManager не знал, что у нас в сервисе есть ещё и поиск, и обошлось без циклических зависимостей, придётся прикрутить dependency inversion в том или ином виде.
  • Для индексации в конкретно нашем случае связки MongoDB с Elasticsearch можно использовать готовые решения вроде mongo-connector.
  • Пока пользователь вводит запрос, мы можем предлагать ему подсказки для этого в Elasticsearch есть специальная функциональность.
  • Когда запрос введён, стоит попытаться исправить в нём опечатки, и это тоже целое дело.
  • Для улучшения ранжирования нужно организовать логирование всех пользовательских событий, связанных с поиском, их агрегацию и расчёт признаков на основе счётчиков. Признаки сущность-запрос, сущность-пользователь, сущность-положение Меркурия тысячи их!
  • Особенно весело пилить агрегации событий не офлайновые (раз в день, раз в неделю), а реалтаймовые (задержка от события до учёта в признаках в пределах пяти минут). Вдвойне весело, когда событий сотни миллионов.
  • Предстоит разобраться с прогревом, нагрузочным тестированием, мониторингами.
  • Оркестрировать кластер нод с шардированием и репликацией это целое отдельное наслаждение.

Но чтобы статья осталась читабельного размера, я остановлюсь на этом и оставлю вас наедине с этими челленджами. Спасибо за внимание!
Подробнее..

Полнотекстовый поиск в Couchbase Server

27.11.2020 10:16:55 | Автор: admin
Дмитрий Калугин-Балашов большую часть своей жизни писал поиск: с 2011 года в компании Mail.ru был поиск по почте, затем был небольшой перерыв из-за работы в США, а сейчас это работа над поиском в Couchbase. Одна из первых вещей, которую Дмитрий понял, работая в США не всегда покупают самое эффективное решение. Иногда покупают то, где клиент будет иметь меньше проблем.

Поэтому ещё в 2013 году Дмитрий написал движок поиска для почтовых ящиков Mail.ru и рассказал об этом в том же году на конференции HighLoad и в статье на Хабре. А на HighLoad 2019 показал, как устроен полнотекстовый поиск в Couchbase Server, и сегодня мы предлагаем расшифровку его доклада.



На самом деле в Couchbase используется внешний опенсорсный движок Bleve:

Этот движок изначально был создан внутри Couchbase как проект для реализации полнотекстового поиска в виде библиотеки, написанной на Go. Сначала он был простым монолитным индексом если есть хранилище ключ-значение, то слева берём слова в виде ключей, а в виде значений ставим документы и всё, у нас есть полнотекстовый поиск! Но в работе он становился очень большим, а обычное хранилище k-value под поиск не оптимизировано. Нагрузка под поиск очень специфичная, а если ещё и апдейты есть, то движок неизбежно начинает тупить.

Поэтому Couchbase решили создать сегментированный индекс специальное хранилище под поисковые данные, в котором используются Immutable-сегменты, оптимизированные под поиск.

Что такое обратный индекс? Если есть книга, в которой я хочу что-то найти по слову как это сделать? На последней странице обычно есть указатель на слово и страницу, где этот термин встречается. Обратный индекс сопоставляет слова со списком. В книге это страницы, у нас документы в базе (у нас документно-ориентированная БД, в которой хранятся джейсонки).

ОК, а как хранить список слов?


Map[string]uint. Самое простое. Кто владеет Гошкой, знает, что это хэш-таблица. Но это не очень хорошо, потому что у нас используются слова, и иногда надо по ним итерироваться. Если у нас неточный поиск, мы начинаем считать расстояние Левенштейна, нужно итерироваться местами, а это по hashmap нельзя. Но можно сделать дерево.

Tree. Но дерево оно здоровое, поэтому тоже не годится, потому что мы делаем хранилище под поиск, а это одновременно и общее хранилище для БД.

Trie. Префиксное дерево это уже лучше, оно довольно компактное, его можно через два массива построить. Но можно ли сделать лучше?

Finite State Transducer. Это автомат под названием Vellum, который Couchbase для себя же и написали. Есть реализация FST на Go и других языках. В нем есть всего лишь три процедуры:
  • Построение FST (один раз);
  • Поиск;
  • Итерирование.

Чтобы построить, нужен список слов в алфавитном порядке (например, возьмём are, ate, see). Так как у нас индексы сегментированные (сегменты неизменяемые), то нам это отлично подходит мы его построили один раз и забыли.

Давайте теперь построим. Мы должны хранить пары: слово число (потом это будет указатель). Состояния автомата просто нумеруются числами, и на каждом ребре есть буква и число. По сумме этих чисел мы считаем конечное число:



Таким образом слово are дает 4. Добавляем второе слово:



Добавляем третье слово:



Теперь мы делаем еще одну операцию и компактим:



Получается автомат, в котором всего два объекта:
  1. Builder мы строим, операцию делаем;
  2. Reader мы читаем и делаем либо поиск, либо итерацию.

Открыть его мы можем либо из файла через mmap (если он поддерживается в системе):
fst, err := vellum.Open("/tmp/vellum.fst")

либо из массива Bytes (это нам будет очень нужно дальше), и можем загрузить прямо из буфера:
fst, err := vellum.Load(buf.Bytes())

Дальше запускаем поиск в FST:
val, exists, err = fst.Get([]byte("dog"))


Как хранить PostingsList?


К списку слов есть список страниц в книге, а в БД PostingsList список документов. Как его можно хранить?
  • Просто список чисел это массив (кстати, можно использовать дельта-кодирование);
  • Битовый массив. Это удобно, если, что список чисел, как страницы в книге, начинается просто от 0 и числа идут подряд. Но только если в нем не будет повторяемых элементов.
  • RLE-сжатие. Битовый массив можно еще иногда сжать.

Как сделать лучше? Есть RoaringBitmap, с библиотеками почти для всех языков. Работает он так: берем последовательность наших чисел и разбиваем на куски (чанки). Каждый чанк кодируем одним из трех способов:
  1. Просто список чисел
  2. Битовый массив
  3. RLE-сжатие

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

Поиск Memory-Only?


Теперь попробуем сделать полнотекстовый поиск Memory-Only. У нас есть Snapshot это наш индекс на какую-то единицу времени. В нём есть несколько сегментов с данными. Внутри сегментов ищем обратные индексы, которые все read-only. Также есть какое-то приложение, которое работает с нашим поиском:



Давайте пытаемся что-нибудь туда добавить. Понятно, что если мы что-то добавляем, возможно, будет удаление какие-то данные в предыдущих сегментах инвалидируются:



Но сами сегменты нам не нужны мы копируем их под read mutex и добавляем новый сегмент. Маленькие фрагменты это просто битовые массивы. Если что-то удаляется, мы просто выставляем там биты:



То есть сам сегмент read only (immutable), но удалить что-то можно, выставив эти биты, если в новом сегменте пришла эта информация. Это называется Segment Introduction: есть новый сегмент (битовый массив) и горутина (Introducer). Ее задача добавить сегмент в наш Snapshot, который мы присылаем по каналу:



Горутина еще раз, уже под эксклюзивной блокировкой, выставляет эти биты, потому что за время, пока мы передавали, что-то могло измениться:



И все мы переходим к четвертому Snapshot и получаем Memory-Only решение:



Для того, чтобы сохранить на диск, есть еще одна горутина Persister. У нас есть номера эпох, также пронумеруем сегменты (a, b, c, d):



В Persister есть цикл FOR, время от времени он пробуждается и смотрит О! Появился новый сегмент, давайте его сохраним:



Для этого сегмента у нас две базы данных: маленькая БД, куда он сохраняется, и корневая БД, которая просто описывает всю эту конструкцию. Таким образом мы сохраняем на диск и меняем номер эпохи после записи:



Сегменты будут расти (они все неизменяемые), когда-то мы захотим их склеить и для этого у нас есть merger. Создаем Merge plane третьей горутиной:



Также есть номер эпохи, когда мы мерджим. Далее мы делаем какие-то операции. Merge Introduction, как и обычный сегмент Introduction, попадает обратно в него и создает новый сегмент, а два склеивает, удаляя оттуда данные:



Таким образом мы удаляем часть сегментов.

Важное примечание. После того, как мы записали zap-файл на диск, мы сегмент закрываем и тут же обратно открываем, но не как in-memory сегмент, а как сегмент на диске. Хотя у них одинаковый интерфейс, устроены они по-разному: сегмент на диске проецируется в память через mmap.

Формат ZAP


Изначально это был BoltDB. Потом создатель сказал: Ну вас всех на фиг, я устал, я ухожу, и BoltDB закрылся. Хотя позже его форкнули в BBoltDB, работал он всё равно плохо, потому что вернулись к той же проблеме: база данных в общем не очень подходит для хранения поисковых индексов. И мы тогда заменили BBoltDB на самописный формат .zap. Корневая база осталась BBoltDB, но там ничего особо критичного хранить не надо:



Формат ZAP это просто сегмент на диске. Мы его не читаем READами, он читается mmapом, при этом данные внутри максимально удобны для in-memory работы. Сам формат ZAP с индексами выглядит так:



В футере описано, где что искать, и мы по указателю находим индекс полей. Что у нас есть? Есть сколько-то (от 0 до F#) проиндексированных полей в документах, они пронумерованы и проиндексированы. Стрелками показаны указатели, сколько полей, и мы знаем заранее, сколько их. Мы находим в Fields Index указатель, а в Fields место, и понимаем по названию поля, что это такое:



Есть еще один указатель:



Он указывает место, где будут храниться наши словари (dictionaries), при этом, под каждое поле будет свой словарь:



Этот указатель показывает VELLUM данные:



А мы помним, что VELLUM может читать данные прямо из памяти. Мы спроецировали память, нашли нужный указатель, взяли кусок данных и начали с помощью VELLUM в нём искать. Нам даже не нужны READ, мы просто к списку слов делаем список OFFSET, который ведёт к RoaringBitmap. Таким образом мы получаем номера документов, которые найдутся по этому слову в этом поле:



Также есть позиции вхождения, если мы хотим сделать подсветку. Если у нас длинная строка, то мы узнаем, в каком месте находится нужное нам слово. Еще мы можем получить определенную статистику частоты встречаемости и другую инфу, нужную для ранжирования:



DocValues это вспомогательный и необязательный список полей, в нём хранятся значения полей:



Значения полей нужны для ранжирования, мы дублируем по номеру поля значения для каждого документа:



Для каждого документа мы знаем список полей и их значение. И, что самое важное, тут есть искусственное поле ID. Дело в том, что внутри ZAP-файла все документы нумеруются с 0 и до бесконечности, а в самой базе данных текстовые имена. Так что в ID происходит сопоставление между номером внутри ZAP-файла и внешним номером (внешним именем):



Остаются мелочи. Это Chunk Factor(на какие кусочки бьем, когда делаем чанки данных), версия, контрольная сумма и число документов (D#):



Это весь ZAP-файл. Как искать, думаю, уже понятно: мы отражаем в память и находим, по какому полю ищем. После этого находим слово, идем по длинной цепочке и находим результаты.

Rollback


Есть интересная операция Rollback. Базе данных Rollback нужен всегда мы можем нагородить огород, и нам нужна возможность откатиться. В нашем случае откат делается очень просто. У нас есть эпохи. Мы храним в корневой базе текущую конфигурацию и предыдущие (1-2), куда хотим откатиться. Если мы хотим откатиться на предыдущую эпоху, мы достаем конфигурацию из индекса, и получаем откат на предыдущую эпоху. В сегмент мы добавляем, когда происходит какая-то операция или группа операций (мы обычно их вставляем большой группой):



Если же за это время у нас ничего такого не добавится, мы сделаем Rollback под эксклюзивом это нормально.

Так работает само ядро. Как работает поиск уже очевидно. Если поиск идет по точному совпадению, то прочитали, прошли по цепочке и нашли. Если поиск неточный, то добавляется автомат Левенштейна и начинаются итерации по vellum, но процесс проходит примерно так же.

Пока пандемия идет к финишу, мы продолжаем проводить встречи онлайн. В понедельник 30 ноября будем обсуждать банковскую архитектуру на встрече Эволюция через боль. Как устроен СберБанк Онлайн изнутри?. Рассмотрим с нуля, как создается и развивается архитектура в банке и узнаем, что под капотом у крупнейшего банка страны. Узнаем, почему банку недостаточно простого приложения из одного сервиса и одной базы данных и увидим на примерах монолит и микросервис. Начало в 18 часов.

А 3 декабря будет митап Безопасность и надёжность в финтехе. Спикеров будет несколько, и они поднимут несколько тем. Сначала будет разговор о закулисье финтеха, затем как и какими инструментами сделать финтех надежным сервисом, и на закуску как снизить риски ИБ в разработке финансовых систем. Начнем в 17.

Следите за новостями Telegram, Twitter, VK и FB и присоединяйтесь к обсуждениям.
Подробнее..

Как мы нарисовали на карте несколько тысяч интерактивных объектов без вреда для перформанса

29.07.2020 10:12:50 | Автор: admin

Привет, меня зовут Дарья, и я Frontend-разработчик юнита Гео вАвито. Хочу поделиться опытом того, как мы сделали навебе новый поиск покарте, заменив кластеры более удобным решением и сняв ограничение наколичество отображаемых объектов.


В статье я расскажу, какая перед нами стояла задача и как мы справлялись спроблемами впроцессе реализации.



Чем нас не устраивал старый поиск покарте


Раньше впоиске накарте объявления отображались кластерами, а при приближении или на небольшой выборке пинами. Кластеры это когда близкие объекты накарте собраны водну большую группу, обозначенную кружком сцифрой. Пин это объект собъявлениями вдоме.


Решение скластерами было неудобным понескольким причинам. Во-первых, пользователям нужно было сделать оттрёх досеми кликов поним, прежде чем появлялась возможность кликнуть наконкретный пин и посмотреть объявления изнего всписке слева. Во-вторых, кластеры плохо считывались нужно было сравнивать цифры внутри них или сопоставлять визуально размеры кластеров. Витоге пользователю приходилось прилагать много усилий длятого, чтобы найти интересующие его объявления.


Выглядело это так:



Что мы решили изменить


Мы исследовали описанные выше проблемы, обращая внимание нато, как их решают конкуренты, и увидели, что многие ушли откластеров впользу отдельных точек. Точки хорошо подсвечивают, вкаком месте карты объявлений больше там выше плотность точек.


Мы пришли квыводу, что нам подойдёт решение сточками вместо кластеров. Пины же останутся наближних уровнях зума и наборе фильтров, которые дают небольшую выдачу, когда реально нарисовать все объявления вобласти и не закрыть ими всю карту.


Поскольку мы решили рисовать пины или точки взависимости отплотности объектов наэкране и полноты выборки, нам понадобилась формула, которая бы говорила, когда рисовать какой тип объектов. Переменные этой формулы размер карты и размер пина впикселях. Константы вней подбирали наоснове наших ощущений того, какая плотность пинов кажется нам уместной, и здесь всё индивидуально. Мы выбрали вариант, прикотором каждый девятый элемент сетки состоронами, равными диаметру пина, может быть занят. Получилось так:


$limit = viewPort.width viewPort.height / (pinDiameter^2 9)$


Если количество найденных объявлений меньше этого лимита, то показываем пины, а иначе точки.


Мы хотели сделать консистентное поведение длявсех объектов накарте, чтобы пользователю было удобно пользоваться поиском. Поэтому нужно было применить дляточек те же требования, которыми обладают пины. Витоге получился такой список:


  • точки должны быть кликабельными навсех уровнях зума (сокращаем трудозатраты пользователя);
  • точки должны реагировать нанаведение (подсказываем пользователю, что они кликабельны);
  • точки должны становиться просмотренными, если поним кликали, и сохранять просмотренность надлительное время (чтобы пользователю было легче ориентироваться накарте).

Приведу примеры целевого состояния поиска покарте дляразных случаев:



Много объявлений безфильтров показываем точки. Голубой цвет точек обозначает просмотренность, красный ховер



Выбраны фильтры, которые дают небольшую выдачу показываем пины



Приблизились на уровень, когда виды дома, и можем отобразить все объявления показываем пины


Далее я перечислю вопросы, скоторыми мы столкнулись впроцессе реализации, и расскажу, как их решали.


Как нарисовали на карте несколько тысяч точек


Когда унас были кластеры и пины, мы рисовали накарте меньше 1000объектов. Вслучае сточками нужно уметь рисовать много объектов, чтобы подсвечивать пользователям, где объявлений больше. Например, так:



Поиск двухкомнатных квартир повсей России


Мы используем Яндекс-карты, API которых предоставляет разные способы отрисовки объектов. Например, кластеры мы рисовали черезинструмент ObjectManager, и он отлично подходит дляслучаев, когда количество объектов накарте не превышает 1000. Если попробовать нарисовать сего помощью, например, 3000объектов, карта начинает подтормаживать привзаимодействии сней.


Мы понимали, что может появиться необходимость отрисовать несколько тысяч объектов безвреда дляпроизводительности. Поэтому посмотрели всторону ещё одного API Яндекс-карт картиночного слоя, длякоторого используется класс Layer.


Основной принцип этого API заключается втом, что вся карта делится натайлы (изображения вpng или svg формате) фиксированного размера, которые маркируются через номера X, Y и зум Z. Эти тайлы накладываются поверх самой карты, и витоге каждая область представляется каким-то количеством изображений взависимости отразмера области и разрешения экрана. Собственно, API берёт насебя всю фронтовую часть, запрашивая нужные тайлы привзаимодействии скартой (изменении уровня зума и сдвиге), а бэкенд-часть нужно писать самостоятельно.



Разбиение натайлы. Длянаглядности их границы выделены черными линиями. Каждый квадрат отдельное изображение


Этот механизм подходит длярешения нашей задачи, ведь мы можем отрисовать набэкенде любое количество точек и отдать нафронт готовые изображения. Приэтом количество изображений не будет зависеть отданных.


Чтобы реализовать бэкенд, мы взяли формулы дляприведения координат точек ксистеме тайлов впроекции Меркатора:


func (*Tile) Deg2num(t *Tile) (x int, y int) {    x = int(math.Floor((t.Long + 180.0) / 360.0 * (math.Exp2(float64(t.Z)))))    y = int(math.Floor((1.0 - math.Log(math.Tan(t.Lat*math.Pi/180.0)+1.0/math.Cos(t.Lat*math.Pi/180.0))/math.Pi) / 2.0 * (math.Exp2(float64(t.Z)))))    return}func (*Tile) Num2deg(t *Tile) (lat float64, long float64) {    n := math.Pi - 2.0*math.Pi*float64(t.Y)/math.Exp2(float64(t.Z))    lat = 180.0 / math.Pi * math.Atan(0.5*(math.Exp(n)-math.Exp(-n)))    long = float64(t.X)/math.Exp2(float64(t.Z))*360.0 - 180.0    return lat, long}

Приведённый код написан наGo, формулы длядругих языков впроекции Меркатора можно найти поссылке.


Нарисовав svg поданным, мы получили подобные изображения тайлов:



Поскольку нам необходимо учитывать разные фильтры впоиске покарте, мы добавили значения выбранных фильтров вGET-запросы затайлами. Реализация нафронте свелась кнескольким строкам кода:


const createTilesUrl = (tileNumber, tileZoom) => {// params - выбранные фильтры в формате строки параметров, которые можно передать в GET-запросreturn `/web/1/map/tiles?${params}&z=${tileZoom}&x=${tileNumber[0]}&y=${tileNumber[1]}`;};const tilesLayer = new window.ymaps.Layer(createTilesUrl, { tileTransparent: true });ymap.layers.add(tilesLayer);

В итоге карта стайлами, подготовленными набэкенде и отрисованными черезLayer, выглядит так:



Как оптимизировали бэкенд


Используя механизм тайлов, мы будем присылать накаждую область примерно 15-30запросов отодного пользователя, и примаксимальном трафике накарте нагрузка насервис будет достигать 5000rps. Приэтом наш сервис только формирует изображения длякарты наосновании запросов сфронта, а данные дляобъектов собирает сервис поиска. Очевидно, всервис поиска не нужно ходить накаждый запрос.


Решая проблему сетевых походов вдругой сервис, мы придумали запрашивать данные дляобласти внесколько раз больше, чем реально отображаемая область. Будем называть её большой областью. Приэтом дляразных уровней зума мы установили разный коэффициент. Например, дляуровня, когда отображается вся Россия, это 4, а максимальное значение равно 25.


На изображении ниже разными цветами выделены разные большие области. Призапросе определённой области накарте, данные длянеё могут формироваться изнескольких больших областей.



Ответ побольшой области отсервиса поиска мы складываем вRedis схэш-ключом, содержащим всебе фильтры, диапазон тайлов и зум. Длякаждого запроса бэкенд проверяет, есть ли вкэше данные дляэтих фильтров и тайлов, и если есть, они отдаются изкэша. Если соответствий не нашлось, то дляэтих тайлов строится новая большая область и выполняется запрос впоиск поней.


Большая область геостационарна, то есть она строится всегда одинаково, независимо оттого, накакой тайл пришёлся первый запрос. Это избавляет отнакладок больших областей, чем обеспечивает консистентность данных и уменьшает размер кэша.


Время жизни разных запросов вRedis отличается. Дляшироких и популярных запросов, например, повсей России безфильтров, оно больше, чем длязапросов сузкими фильтрами. Кроме долгого кэша вRedis есть быстрый кэш непопулярных запросов вin-memory. Он позволяет уменьшить нагрузку насеть и освободить место вRedis.


Поскольку наш сервис горизонтально масштабирован и поднят нанескольких десятках подов, важно, чтобы параллельные запросы отодного пользователя попали наодин под. Тогда мы можем взять эти данные изin-memory cache пода, и не хранить одну и ту же большую область наразных подах. Этот механизм называется стики-сессиями. Насхеме его можно представить так:



Сейчас среднее время ответа запроса тайла на99перцентиле составляет ~140ms. То есть 99% измеряемых запросов выполняются за это или меньшее время. Длясравнения: вреализации черезкластеры запрос выполнялся ~230ms натом же перцентиле.


Работу пода упрощённо можно представить следующим образом: навход поступает запрос затайлом, строится большая область, проверяется кэш. Если вкэше есть данные, поним рисуется svg. Если данных дляэтой большой области нет, происходит запрос всервис поиска.



Итак, мы решили проблему сотрисовкой большого числа объектов накарте. Осталось разобраться синтерактивностью.


Кликабельность, ховер и просмотренность точек


Кликабельность. Самой критичной длянас была кликабельность, поэтому мы начали снеё. Врамках ресёрча мы сделали простое решение отправку запроса скоординатами набэк, бэк проверял, есть ли вкэше объявления поэтим координатам срадиусом 50метров. Если объявление находилось, рисовался пин. Если вкэше не было данных по области, вкоторой находились координаты, то есть истекло время хранения кэша, бэк запрашивал данные изсервиса поиска. Это решение оказалось нестабильным иногда пин рисовался втом месте, где не было точки как объекта накарте. Так происходило потому, что кэш набэке протухал, и появлялись новые объявления данные расходились стем, что есть накарте.


Мы поняли, что стабильнее будет реализовать кликабельность нафронте. Помимо запросов затайлами, унас всегда отправлялся один запрос запинами. Пины мы рисуем нафронте, и фронт ничего не знает прото, рисовать вданный момент времени пины или точки. Запросы запинами и тайлами уходят всегда накаждый сдвиг или изменение уровня зума. Чтобы не усложнять и рисовать всё быстрее, тайлы длянепустых выдач всегда возвращаются сточками. Если нужно рисовать пины, они рисуются поверх точек, перекрывая их. Поэтому всё, что нам оставалось длякликабельности точек, добавить вответ запроса запинами объект точек, который будет содержать координаты, id и количество объявлений вэтой точке. Этих данных достаточно для того, чтобы нарисовать поклику наточку пин.



Ховер. Идея присылать данные нафронт отлично решала также проблему ховера мы могли подписаться насобытие движения мыши покарте и проверять, попадают ли координаты курсора врадиус какой-то източек, что нам прислал бэкенд. Если совпадения найдены, рисуется красная точка накарте. Понашему опыту даже для10 тысяччерез forEach точек это выглядит нормально.



Просмотренность. Просмотренность пинов накарте уже была реализована наклиенте. Мы хранили вlocalStorage стек из1000id объявлений. Ids вытеснялись более свежими, которые были просмотрены позже других. Бэкенд ничего не знал пропросмотренные объявления, отдавал одинаковые данные всем пользователям, а клиент делал пин просмотренным наосновании данных изlocalStorage.


Для простоты мы поддержали это решение, распространив его наточки. Разница между точками и пинами втом, что пины это физические объекты карты, а точки нет. Поэтому наэтапе обогащения данных нам приходится рисовать просмотренные точки как объекты карты.


Поскольку запросы затайлами и запрос запинами и точками независимые, иногда мы можем видеть небольшой лаг вокрашивании точек вбледно-голубой. Тайлы обычно приходят раньше ответа запроса поточкам/пинам, и соответветственно, сначала мы увидим все синие точки, а потом нарисуем просмотренные точки как объекты. Сейчас длянас это не выглядит критичным моментом.


В целевом состоянии мы планируем перевести хранение просмотренных объявлений набэкенд и обогащать данные наэтапе рисования svg и формирования ответа запроса запинами. Тогда тайлы будут рисоваться сразу сбледно-голубыми точками.



Просмотренный пин выделен бледно-голубым



Просмотренная точка выделена бледно-голубым


Заключение


Качественные и количественные исследования показали, что решение сточками вместо кластеров облегчило поиск покарте дляпользователей выросло использование карты и сократились трудозатраты досовершения контакта.

Подробнее..

Продвинутые поиск на NuGet.org

11.08.2020 10:19:42 | Автор: admin
Мы рады сообщить, что NuGet.org теперь поддерживает один из главных запросов со стороны пользователей расширенный поиск! Теперь вы можете использовать множество критериев сортировки и фильтрации, чтобы найти лучшие пакеты NuGet для ваших нужд!



Что нового?




Добро пожаловать в новый опыт поиска NuGet! Теперь у вас есть доступ к множеству новых опций для настройки ваших поисковых запросов, включая фильтрацию типов пакетов и дополнительные опции сортировки.

Панель расширенного поиска позволит вам отсортировать по Загрузкам, чтобы увидеть результаты в порядке убывания общего количества загрузок, и по Недавно обновленным, чтобы найти самые актуальные соответствующие пакеты.

Кроме того, будет по-прежнему доступен переключатель предварительной версии, который поможет вам найти самые последние и лучшие версии пакетов.

Для получения подробного объяснения всех настроек расширенного фильтра поиска, а также того, как их использовать для поиска нужного пакета, обратитесь к нашей документации по поиску пакетов.



Что дальше?


Хотя эта функция добавляет несколько новых параметров для настройки ваших поисковых запросов, мы знаем, что фильтрация пакетов для совместимости с фреймворком (т. е. фильтрация по совместимости с .NET Core или .NET Framework) значительно улучшила бы поиск пакетов. Если вам важна фильтрация совместимости или вы хотите внести свой вклад в обсуждение, ознакомьтесь с проблемой на GitHub и проголосуйте за нее!

В ближайшем будущем мы также планируем добавить такой же уровень возможностей поиска в Visual Studio, чтобы помочь вам воспользоваться преимуществами расширенного поиска, не выходя из контекста разработки. Если вам нравится расширенный поиск пакетов в Visual Studio и вы хотите быть в курсе событий, проголосуйте за и следите за проблемой на GitHub.

Делитесь мнениями


Мы хотим, чтобы NuGet отвечал растущим потребностям нашего сообщества. Оставьте комментарий или используйте GitHub, чтобы оставить отзыв или задавать нужные вопросы.

Для более широких отзывов и предложений по NuGet:

  • Ознакомьтесь с нашей документацией по отправке сообщений об ошибках и предложениях.
  • Назначьте время для Talk to NuGet.
  • Свяжитесь с нами в твиттере упомяните @nuget в своих твитах.
Подробнее..

Превращаем EditText в SearchEditText

12.09.2020 22:20:08 | Автор: admin
image

Пробовали ли Вы когда-нибудь настроить внешний вид или поведение стандартного компонента SearchView? Полагаю, да. В этом случае, я думаю что вы согласитесь, что далеко не все его настройки являются достаточно гибкими, для того, чтобы удовлетворить всем бизнес-требованиям отдельно взятой задачи. Одним из способов решения этой проблемы является написание собственного кастомного SearchView, чем мы сегодня и займемся. Поехали!

Примечание: создаваемое view (далее SearchEditText), не будет обладать всеми свойствами стандартного SearchView. В случае необходимости, вы можете без труда добавить дополнительные опции под конкретные нужды.

План действий


Есть несколько вещей, которые нам нужно сделать, для превращения EditText в SearchEditText. Если кратко, то нам нужно:

  • Унаследовать SearchEditText от AppCompatEditText
  • Добавить иконку Поиск в левом (или правом) углу SearchEditText, при нажатии на которую введённый поисковый запрос будет передаваться зарегистрированному слушателю
  • Добавить иконку Очистка в правом (или левом) углу SearchEditText, при нажатии на которую введённый текст в поисковой строке будет очищаться
  • Установить в параметре imeOptions SearchEditText-а значение IME_ACTION_SEARCH, для того, чтобы при появлении клавиатуры кнопка ввода текста выполняла роль кнопки Поиск

SearchEditText во всей красе!


import android.content.Contextimport android.util.AttributeSetimport android.view.MotionEventimport android.view.View.OnTouchListenerimport android.view.inputmethod.EditorInfoimport androidx.appcompat.widget.AppCompatEditTextimport androidx.core.widget.doAfterTextChangedclass SearchEditText@JvmOverloads constructor(    context: Context,    attributeSet: AttributeSet? = null,    defStyle: Int = androidx.appcompat.R.attr.editTextStyle) : AppCompatEditText(context, attributeSet, defStyle) {    init {        setLeftDrawable(android.R.drawable.ic_menu_search)        setTextChangeListener()        setOnEditorActionListener()        setDrawablesListener()        imeOptions = EditorInfo.IME_ACTION_SEARCH    }    companion object {        private const val DRAWABLE_LEFT_INDEX = 0        private const val DRAWABLE_RIGHT_INDEX = 2    }    private var queryTextListener: QueryTextListener? = null    private fun setTextChangeListener() {        doAfterTextChanged {            if (it.isNullOrBlank()) {                setRightDrawable(0)            } else {                setRightDrawable(android.R.drawable.ic_menu_close_clear_cancel)            }            queryTextListener?.onQueryTextChange(it.toString())        }    }        private fun setOnEditorActionListener() {        setOnEditorActionListener { _, actionId, _ ->            if (actionId == EditorInfo.IME_ACTION_SEARCH) {                queryTextListener?.onQueryTextSubmit(text.toString())                true            } else {                false            }        }    }        private fun setDrawablesListener() {        setOnTouchListener(OnTouchListener { view, event ->            view.performClick()            if (event.action == MotionEvent.ACTION_UP) {                when {                    rightDrawableClicked(event) -> {                        setText("")                        return@OnTouchListener true                    }                    leftDrawableClicked(event) -> {                        queryTextListener?.onQueryTextSubmit(text.toString())                        return@OnTouchListener true                    }                    else -> {                        return@OnTouchListener false                    }                }            }            false        })    }    private fun rightDrawableClicked(event: MotionEvent): Boolean {        val rightDrawable = compoundDrawables[DRAWABLE_RIGHT_INDEX]        return if (rightDrawable == null) {            false        } else {            val startOfDrawable = width - rightDrawable.bounds.width() - paddingRight            val endOfDrawable = startOfDrawable + rightDrawable.bounds.width()            startOfDrawable <= event.x && event.x <= endOfDrawable        }    }    private fun leftDrawableClicked(event: MotionEvent): Boolean {        val leftDrawable = compoundDrawables[DRAWABLE_LEFT_INDEX]        return if (leftDrawable == null) {            false        } else {            val startOfDrawable = paddingLeft            val endOfDrawable = startOfDrawable + leftDrawable.bounds.width()            startOfDrawable <= event.x && event.x <= endOfDrawable        }    }    fun setQueryTextChangeListener(queryTextListener: QueryTextListener) {        this.queryTextListener = queryTextListener    }    interface QueryTextListener {        fun onQueryTextSubmit(query: String?)        fun onQueryTextChange(newText: String?)    }}

В приведенном выше коде были использованы две extension-функции для установки правого и левого изображения EditText-а. Эти две функции выглядят следующим образом:

import android.widget.TextViewimport androidx.annotation.DrawableResimport androidx.core.content.ContextCompatprivate const val DRAWABLE_LEFT_INDEX = 0private const val DRAWABLE_TOP_INDEX = 1private const val DRAWABLE_RIGHT_INDEX = 2private const val DRAWABLE_BOTTOM_INDEX = 3fun TextView.setLeftDrawable(@DrawableRes drawableResId: Int) {    val leftDrawable = if (drawableResId != 0) {        ContextCompat.getDrawable(context, drawableResId)    } else {        null    }    val topDrawable = compoundDrawables[DRAWABLE_TOP_INDEX]    val rightDrawable = compoundDrawables[DRAWABLE_RIGHT_INDEX]    val bottomDrawable = compoundDrawables[DRAWABLE_BOTTOM_INDEX]    setCompoundDrawablesWithIntrinsicBounds(        leftDrawable,        topDrawable,        rightDrawable,        bottomDrawable    )}fun TextView.setRightDrawable(@DrawableRes drawableResId: Int) {    val leftDrawable = compoundDrawables[DRAWABLE_LEFT_INDEX]    val topDrawable = compoundDrawables[DRAWABLE_TOP_INDEX]    val rightDrawable = if (drawableResId != 0) {        ContextCompat.getDrawable(context, drawableResId)    } else {        null    }    val bottomDrawable = compoundDrawables[DRAWABLE_BOTTOM_INDEX]    setCompoundDrawablesWithIntrinsicBounds(        leftDrawable,        topDrawable,        rightDrawable,        bottomDrawable    )}

Наследование от AppCompatEditText


class SearchEditText@JvmOverloads constructor(    context: Context,    attributeSet: AttributeSet? = null,    defStyle: Int = androidx.appcompat.R.attr.editTextStyle) : AppCompatEditText(context, attributeSet, defStyle)

Как видите, из написанного конструктора мы передаём все необходимые параметры в конструктор AppCompatEditText. Важным моментом тут является то, что значением defStyle по-умолчанию является android.appcompat.R.attr.editTextStyle. Наследуясь от LinearLayout, FrameLayout и некоторых других view, мы, как правило, используем 0 в качестве значения по-умолчанию для defStyle. Однако в нашем случае это не подходит, иначе наш SearchEditText будет вести себя как TextView, а не как EditText.

Обработка изменения текста


Следующее, что нам нужно сделать, это научиться реагировать на события изменения текста нашего SearchEditText. Нам это необходимо по двум причинам:

  • отображение или скрытие иконки очистки в зависимости от того, введён ли текст
  • оповещение слушателя об изменении текста в SearchEditText

Посмотрим на код слушателя:

private fun setTextChangeListener() {    doAfterTextChanged {        if (it.isNullOrBlank()) {            setRightDrawable(0)        } else {            setRightDrawable(android.R.drawable.ic_menu_close_clear_cancel)        }        queryTextListener?.onQueryTextChange(it.toString())    }}

Для обработки событий изменения текста использовалась extension-функция doAfterTextChanged из androidx.core:core-ktx.

Обработка нажатия кнопки ввода на клавиатуре


Когда пользователь нажимает клавишу ввода на клавиатуре, происходит проверка на то, является ли это действие IME_ACTION_SEARCH. Если это так, то мы сообщаем слушателю об этом действии и передаем ему текст из SearchEditText. Посмотрим как это происходит.

private fun setOnEditorActionListener() {    setOnEditorActionListener { _, actionId, _ ->        if (actionId == EditorInfo.IME_ACTION_SEARCH) {            queryTextListener?.onQueryTextSubmit(text.toString())            true        } else {            false        }    }}

Обработка нажатий на иконки


Ну и наконец, последний по счету, но не по значению вопрос обработка нажатия на иконки поиска и очистки текста. Здесь загвоздка заключается в том, что по-умолчанию drawables стандартного EditText-а не реагируют на события нажатия, а значит и официального слушателя, который мог бы их обрабатывать, нет.

Для решения этой проблемы был зарегистрирован OnTouchListener в SearchEditText. При касании, с помощью функций leftDrawableClicked и rightDrawableClicked мы теперь можем обрабатывать клик по иконкам. Взглянем на код:

private fun setDrawablesListener() {    setOnTouchListener(OnTouchListener { view, event ->        view.performClick()        if (event.action == MotionEvent.ACTION_UP) {            when {                rightDrawableClicked(event) -> {                    setText("")                    return@OnTouchListener true                }                leftDrawableClicked(event) -> {                    queryTextListener?.onQueryTextSubmit(text.toString())                    return@OnTouchListener true                }                else -> {                    return@OnTouchListener false                }            }        }        false    })}private fun rightDrawableClicked(event: MotionEvent): Boolean {    val rightDrawable = compoundDrawables[DRAWABLE_RIGHT_INDEX]    return if (rightDrawable == null) {        false    } else {        val startOfDrawable = width - rightDrawable.bounds.width() - paddingRight        val endOfDrawable = startOfDrawable + rightDrawable.bounds.width()        startOfDrawable <= event.x && event.x <= endOfDrawable    }}private fun leftDrawableClicked(event: MotionEvent): Boolean {    val leftDrawable = compoundDrawables[DRAWABLE_LEFT_INDEX]    return if (leftDrawable == null) {        false    } else {        val startOfDrawable = paddingLeft        val endOfDrawable = startOfDrawable + leftDrawable.bounds.width()        startOfDrawable <= event.x && event.x <= endOfDrawable    }}

В функциях leftDrawableClicked и RightDrawableClicked нет ничего сложного. Возьмём, к примеру, первую из них. Для левой иконки мы сначала рассчитываем startOfDrawable и endOfDrawable, а затем проверяем, находится ли x-координата точки касания в диапазоне [startofDrawable, endOfDrawable]. Если да, то это означает, что левая иконка была нажата. Функция rightDrawableClicked работает аналогичным образом.

В зависимости от того, нажата ли левая или правая иконка, мы осуществляем те или иные действия. При нажатии на левую иконку (значок поиска) мы сообщаем об этом слушателю, вызывая его функцию onQueryTextSubmit. При нажатии на правую очищаем текст SearchEditText.

Вывод


В этой статье мы рассмотрели вариант превращения стандартного EditText в более продвинутый SearchEditText. Как уже упоминалось ранее, готовое решение не поддерживает все параметры, предоставляемые SearchView, однако вы в любой момент можете его усовершенствовать, добавив дополнительные опции на свое усмотрение. Дерзайте!

P.S:

Доступ к исходному коду SearchEditText вы можете получить из этого репозитория GitHub.
Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru