Не раз я сталкивался с удивлением при оглашении оценки сложности проекта: «А почему так долго?», «Да тут же раз, два и готово!», «Можно же просто взять X и сунуть в Y!». Программисты привыкли оценивать сроки как время на написание и отладку кода, хотя в крупные задачи входит ещё много всего.
Знаете ли вы, что в реальности айсберги располагаются в воде горизонтально, а не вертикально, как на большинстве стоковых картинок?
Но даже если забыть про традиционный букет энтерпрайзовых примочек вроде аналитики, поддержки обратной совместимости и A/B-тестирования и сосредоточиться чисто на коде, напрямую связанном с реализуемой функциональностью, можно увидеть, что зачастую его сложность выходит из-под контроля.
В этой статье я расскажу про несколько фич, которые я и мои коллеги реализовали в Joom в разное время, от постановки задачи до деталей реализации, и покажу, как легко простые на первый взгляд вещи превращаются в клубок сложнейшей логики, требующий много итераций разработки.
Одним из крупных разделов приложения Joom является внутренняя социальная сеть, где покупатели могут писать обзоры на товары, лайкать и обсуждать их и подписываться друг на друга. А какая же соцсеть без поиска по пользователям!
Конечно, поиск нельзя назвать такой уж лёгкой на вид задачей (по крайней мере после моей предыдущей статьи). Но я уже обладал всеми нужными знаниями, а также у нас в компании был готовый компонент
Задача. Сделай бэкенд для поиска по пользователям соцсети. Фильтров не надо, сортировка по количеству подписчиков сойдёт для начала.
Окей, это правда звучит просто. Настраиваем переливку из коллекции
Первая проблема, с которой мы столкнулись — транслитерация. Имена пользователей брались из соцсетей, где пользователи из России (а их на тот момент было большинство) часто писали их латиницей. Пытаешься найти Мадса, а он в фейсбуке Mads, и всё — нет его в результатах. Аналогично по Ivan не получится найти Ивана, а очень хотелось бы.
Вот и первое усложнение — при индексации мы стали ходить в Microsoft Translator API за транслитерацией и сохранять две версии имени и фамилии, а общий индексирующий компонент стал зависеть от клиента транслитератора (и зависит до сих пор).
Ну и вторая проблема, которую легко предвидеть, если твой родной язык — русский, но существующая и в других европейских языках — уменьшительно-ласкательные формы и сокращения имён. Если Иван решил в фейсбуке назваться Ваня, по запросу Иван его уже не найти, сколько ни транслитерируй.
Так что следующее усложнение заключалось в том, что мы нашли на Грамоте.ру указатель уменьшительных имён (из единственного в своём роде словаря русских имён Никандра Александровича Петровского), добавили в кодовую базу в качестве захардкоженной таблички (какие-то две тысячи строк) и стали индексировать не только имя и его транслитерацию, но и все найденные уменьшительные формы (fun fact: в английском языке для них есть термин hypocorisms). Мы брали каждое слово в имени пользователя и делали лукап в нашей скромной таблице.
Нотариально заверенный скриншот кодовой базы Joom. Circa 2018.
Но потом, чтобы не обидеть вторую половину наших пользователей, распределённую неровным слоем по нерусскоговорящему миру, мы кинули клич кантри-менеджерам Joom и попросили их найти нам справочники сокращений национальных имён в их странах. Если не академические, то хоть какие-нибудь. И выяснилось, что в некоторых языках, помимо традиции иметь сложносоставное имя (Juan Carlos, Maria Aurora), также существуют сокращения двух, трёх или даже четырёх слов в одно (María de las Nieves → Marinieves).
Это новое обстоятельство лишило нас возможности делать лукап по одному слову. Теперь надо разбивать последовательность слов на фрагменты произвольной длины, и более того — разные разбиения могут приводить к разным сокращениям! Погружаться в глубины лингвистики и писать искусственный интеллект, сокращающий испанское имя так, как его сократил бы живой испанец, мы не хотели, поэтому набросали, прости Кнут, комбинаторный перебор.
И, как это всегда бывает с комбинаторными переборами, на одном из юзеров он лопнул и нам экстренно пришлось впилить в него ограничение на максимальное количество сгенерированных вариантов написания. Что дополнительно усложнило и так неожиданно сложный для этой задачи код.
Задача. Нужно сделать перевод названий и описаний товаров, предоставляемых продавцами на английском языке, на язык пользователя.
Все, наверное, видели мемы про кривой перевод названий китайских товаров. Мы тоже их видели, но желаемое time to market не позволяло придумывать что-то лучше, чем использование какого-то существующего API для перевода.
Написать HTTP-клиент, завести аккаунт и при выдаче товара пользователю переводить его на язык устройства легко. Но переводы стоят недёшево, и было бы расточительно переводить один и тот же популярный товар на русский язык при каждом из десятков тысяч просмотров. Поэтому мы прикрутили кэширование: для каждого товара мы сохраняли в базу переводы и при наличии переводов там в переводчик уже не ходили.
Но потенциал для экономии всё ещё был. Мы решили, что разумным компромиссом между качеством перевода и ценой будет бить описания на предложения и кэшировать их — всё-таки в товарах часто встречаются одни и те же шаблонные фразы, и переводить их каждый раз расточительно. Так в нашем переводчике появился ещё один слой абстракции — прослойка между HTTP-клиентом и кэшем, хранящим целые товары на разных языках, которая занимается разбиением текста на фрагменты.
После запуска качество переводов, конечно же, не давало нам покоя, и мы подумали: а что, если использовать более дорогой переводчик? Но будет ли он хорош на наших специфических текстах? На глаз их не сравнишь, нужно проводить A/B-тест. Так в нашем кэше переводов помимо ID товара появился ID переводчика, и мы стали запрашивать перевод с ID переводчика, зависящим от того, в какую группу A/B-теста попал пользователь.
Дорогой переводчик проявил себя хорошо, но запускать его на всех товарах всё ещё было слишком расточительно. Но мы вышли в страны, с национальными языками которых наш основной переводчик справлялся настолько плохо, что мы были готовы раскошелиться ради успешного запуска; так логика выбора переводчика усложнилась ещё раз.
Потом решили, что некоторые магазины на платформе настолько хороши и платформа так болеет за их успех, что готова переводить их товары более дорогим переводчиком всегда. Так логика выбора переводчика стала зависеть от пользователя, страны и ID магазина.
И наконец, мы решили, что за несколько лет существования Joom наш основной переводчик мог улучшиться, и, возможно, кэш переводов имеет смысл обновлять с какой-то периодичностью. Но как же без A/B-теста? Так в нашем кэше появилось поле freshness, и всё усложнилось ещё раз. В итоге наша компонента, занимающаяся переводом, невероятно сложна, и это при том, что мы даже ещё не прикрутили туда никакой самодельной вычислительной лингвистики. Пока что.
Пожалуй, одна из самых болезненных проблем при покупке одежды и обуви онлайн — правильный выбор размера. И если при доставке с локальных складов игроки вроде Lamoda могут просто привозить сразу несколько размеров и с той же лёгкостью увозить неподошедшее обратно, в кроссбордере так не получится. Посылки идут долго, стоимость каждого лишнего килограмма велика, а их отправители не ожидают большой поток входящей почты.
Дополнительно проблема усложняется тем, что продавцы из разных стран могут иметь совершенно разное представление о размерах. Китайский M легко может оказаться русским XS, а ужасающий 9XL — не так уж сильно отличаться от XXL. Прошаренным пользователям приходится ориентироваться на замеры, но и те не всегда верны: например, пользователь ожидает, что указан обхват груди человека, а продавец указывает измерения самой одежды — они отличаются процентов на пять-десять. Мы не хотим, чтобы пользователю нужно было так заморачиваться для шоппинга на Joom!
Задача. Вместо размеров, предоставляемых продавцами, покажи пользователям размеры, вычисленные нами по некоторой единой таблице, исходя из обхватов.
Окей. Берём таблицу размеров, которая у нас парсится из описания товара (этим занимается отдельный космолёт на 5к строк) и хранится отдельным полем, и подменяем в ней размеры на вычисленные. Хардкодим таблицу для конвертации обхвата в размер, найденную в интернете, и радуемся жизни.
Но если таблицы нет или в ней не хватает строк, это не работает. Фича отключается на товаре неявным образом номер раз.
Хм, в таблице обхваты тела человека, а большинство продавцов указывает их, померив на самих вещах. Вшиваем коэффициент разницы. Продакт-менеджер Родион, счастливый обладатель идеальной М-ки, идёт в торговый центр, меряет на себе кучу разных вещей и приходит с коэффициентами — они похожи, но существенно различаются для разных категорий товаров. Для обхватывающей водолазки разница практически 0%, а для свитера все 10%. Также верхняя одежда различается по посадке: slim fit, normal fit, loose fit, и это даёт размах ещё в ±5%. Теперь наш коэффициент (увековеченный мною в коде как коэффициент Родиона) состоит из двух множителей.
Для определения посадки делаем ещё один парсер, пытающийся извлечь её из названия или описания товара. Если товар не попал в одну из категорий, проверенных Родионом, фича выключается неявным образом номер два.
Финальный штрих: в большом количестве товаров обхват груди указан от подмышки до подмышки, то есть только половина обхвата, что приводит к смехотворно маленьким размерам. Добавляем логику, что если обхват меньше X, то ну не может такого быть, это явно половина обхвата, и умножаем его на два. Хорошо, что взрослые люди обычно не отличаются друг от друга обхватом груди в два раза.
Теперь всё так сложно, что при тестировании фичи по виду товара в админке невозможно понять, почему она не включилась или сработала так или иначе. Добавляем в код большой слой логики, детально логирующий причины выключения конвертации. Чтобы можно было полностью отследить причину выключения на конкретном товаре, приходится пробрасывать сообщения об ошибках наверх, обогащая деталями, несколько раз. Код становится ужасающим.
И всё это работает по-разному в зависимости от группы A/B-теста, конечно.
Бойтесьданайцев, дары приносящих разработчиков, оптимистично оценивающих сроки. Оценивать время на разработку очень сложно, как бы просто ни звучала задача, и сюрпризы ждут нас на каждом шагу!
Знаете ли вы, что в реальности айсберги располагаются в воде горизонтально, а не вертикально, как на большинстве стоковых картинок?
Но даже если забыть про традиционный букет энтерпрайзовых примочек вроде аналитики, поддержки обратной совместимости и A/B-тестирования и сосредоточиться чисто на коде, напрямую связанном с реализуемой функциональностью, можно увидеть, что зачастую его сложность выходит из-под контроля.
В этой статье я расскажу про несколько фич, которые я и мои коллеги реализовали в Joom в разное время, от постановки задачи до деталей реализации, и покажу, как легко простые на первый взгляд вещи превращаются в клубок сложнейшей логики, требующий много итераций разработки.
Поиск по пользователям
Одним из крупных разделов приложения Joom является внутренняя социальная сеть, где покупатели могут писать обзоры на товары, лайкать и обсуждать их и подписываться друг на друга. А какая же соцсеть без поиска по пользователям!
Конечно, поиск нельзя назвать такой уж лёгкой на вид задачей (по крайней мере после моей предыдущей статьи). Но я уже обладал всеми нужными знаниями, а также у нас в компании был готовый компонент
joom-mongo-connector
, который умел переливать данные из коллекции в MongoDB в индекс Elasticsearch, при необходимости приджойнивая дополнительные данные и делая какую-то ещё постобработку. Задача звучала довольно просто.Задача. Сделай бэкенд для поиска по пользователям соцсети. Фильтров не надо, сортировка по количеству подписчиков сойдёт для начала.
Окей, это правда звучит просто. Настраиваем переливку из коллекции
socialUsers
в Elasticsearch путём написания конфига на YAML. На бэкенде добавляем новый эндпоинт с API, аналогичным API поиска товаров, только пока что без поддержки фильтров и сортировок (остаются только текст запроса и пагинация, всего-то). В хендлере делаем простейший запрос в Elasticsearch-кластер (главное — не ошибиться кластером!), из результата достаём ID нашедшихся документов, — они же ID пользователей — по ним самих пользователей, потом конвертируем в клиентский JSON, пряча от посторонних глаз приватную информацию, и готово. Или нет?Первая проблема, с которой мы столкнулись — транслитерация. Имена пользователей брались из соцсетей, где пользователи из России (а их на тот момент было большинство) часто писали их латиницей. Пытаешься найти Мадса, а он в фейсбуке Mads, и всё — нет его в результатах. Аналогично по Ivan не получится найти Ивана, а очень хотелось бы.
Вот и первое усложнение — при индексации мы стали ходить в Microsoft Translator API за транслитерацией и сохранять две версии имени и фамилии, а общий индексирующий компонент стал зависеть от клиента транслитератора (и зависит до сих пор).
Ну и вторая проблема, которую легко предвидеть, если твой родной язык — русский, но существующая и в других европейских языках — уменьшительно-ласкательные формы и сокращения имён. Если Иван решил в фейсбуке назваться Ваня, по запросу Иван его уже не найти, сколько ни транслитерируй.
Так что следующее усложнение заключалось в том, что мы нашли на Грамоте.ру указатель уменьшительных имён (из единственного в своём роде словаря русских имён Никандра Александровича Петровского), добавили в кодовую базу в качестве захардкоженной таблички (какие-то две тысячи строк) и стали индексировать не только имя и его транслитерацию, но и все найденные уменьшительные формы (fun fact: в английском языке для них есть термин hypocorisms). Мы брали каждое слово в имени пользователя и делали лукап в нашей скромной таблице.
Нотариально заверенный скриншот кодовой базы Joom. Circa 2018.
Но потом, чтобы не обидеть вторую половину наших пользователей, распределённую неровным слоем по нерусскоговорящему миру, мы кинули клич кантри-менеджерам Joom и попросили их найти нам справочники сокращений национальных имён в их странах. Если не академические, то хоть какие-нибудь. И выяснилось, что в некоторых языках, помимо традиции иметь сложносоставное имя (Juan Carlos, Maria Aurora), также существуют сокращения двух, трёх или даже четырёх слов в одно (María de las Nieves → Marinieves).
Это новое обстоятельство лишило нас возможности делать лукап по одному слову. Теперь надо разбивать последовательность слов на фрагменты произвольной длины, и более того — разные разбиения могут приводить к разным сокращениям! Погружаться в глубины лингвистики и писать искусственный интеллект, сокращающий испанское имя так, как его сократил бы живой испанец, мы не хотели, поэтому набросали, прости Кнут, комбинаторный перебор.
И, как это всегда бывает с комбинаторными переборами, на одном из юзеров он лопнул и нам экстренно пришлось впилить в него ограничение на максимальное количество сгенерированных вариантов написания. Что дополнительно усложнило и так неожиданно сложный для этой задачи код.
Машинный перевод товаров
Задача. Нужно сделать перевод названий и описаний товаров, предоставляемых продавцами на английском языке, на язык пользователя.
Все, наверное, видели мемы про кривой перевод названий китайских товаров. Мы тоже их видели, но желаемое time to market не позволяло придумывать что-то лучше, чем использование какого-то существующего API для перевода.
Написать HTTP-клиент, завести аккаунт и при выдаче товара пользователю переводить его на язык устройства легко. Но переводы стоят недёшево, и было бы расточительно переводить один и тот же популярный товар на русский язык при каждом из десятков тысяч просмотров. Поэтому мы прикрутили кэширование: для каждого товара мы сохраняли в базу переводы и при наличии переводов там в переводчик уже не ходили.
Но потенциал для экономии всё ещё был. Мы решили, что разумным компромиссом между качеством перевода и ценой будет бить описания на предложения и кэшировать их — всё-таки в товарах часто встречаются одни и те же шаблонные фразы, и переводить их каждый раз расточительно. Так в нашем переводчике появился ещё один слой абстракции — прослойка между HTTP-клиентом и кэшем, хранящим целые товары на разных языках, которая занимается разбиением текста на фрагменты.
После запуска качество переводов, конечно же, не давало нам покоя, и мы подумали: а что, если использовать более дорогой переводчик? Но будет ли он хорош на наших специфических текстах? На глаз их не сравнишь, нужно проводить A/B-тест. Так в нашем кэше переводов помимо ID товара появился ID переводчика, и мы стали запрашивать перевод с ID переводчика, зависящим от того, в какую группу A/B-теста попал пользователь.
Дорогой переводчик проявил себя хорошо, но запускать его на всех товарах всё ещё было слишком расточительно. Но мы вышли в страны, с национальными языками которых наш основной переводчик справлялся настолько плохо, что мы были готовы раскошелиться ради успешного запуска; так логика выбора переводчика усложнилась ещё раз.
Потом решили, что некоторые магазины на платформе настолько хороши и платформа так болеет за их успех, что готова переводить их товары более дорогим переводчиком всегда. Так логика выбора переводчика стала зависеть от пользователя, страны и ID магазина.
И наконец, мы решили, что за несколько лет существования Joom наш основной переводчик мог улучшиться, и, возможно, кэш переводов имеет смысл обновлять с какой-то периодичностью. Но как же без A/B-теста? Так в нашем кэше появилось поле freshness, и всё усложнилось ещё раз. В итоге наша компонента, занимающаяся переводом, невероятно сложна, и это при том, что мы даже ещё не прикрутили туда никакой самодельной вычислительной лингвистики. Пока что.
Конвертация размеров одежды
Пожалуй, одна из самых болезненных проблем при покупке одежды и обуви онлайн — правильный выбор размера. И если при доставке с локальных складов игроки вроде Lamoda могут просто привозить сразу несколько размеров и с той же лёгкостью увозить неподошедшее обратно, в кроссбордере так не получится. Посылки идут долго, стоимость каждого лишнего килограмма велика, а их отправители не ожидают большой поток входящей почты.
Дополнительно проблема усложняется тем, что продавцы из разных стран могут иметь совершенно разное представление о размерах. Китайский M легко может оказаться русским XS, а ужасающий 9XL — не так уж сильно отличаться от XXL. Прошаренным пользователям приходится ориентироваться на замеры, но и те не всегда верны: например, пользователь ожидает, что указан обхват груди человека, а продавец указывает измерения самой одежды — они отличаются процентов на пять-десять. Мы не хотим, чтобы пользователю нужно было так заморачиваться для шоппинга на Joom!
Задача. Вместо размеров, предоставляемых продавцами, покажи пользователям размеры, вычисленные нами по некоторой единой таблице, исходя из обхватов.
Окей. Берём таблицу размеров, которая у нас парсится из описания товара (этим занимается отдельный космолёт на 5к строк) и хранится отдельным полем, и подменяем в ней размеры на вычисленные. Хардкодим таблицу для конвертации обхвата в размер, найденную в интернете, и радуемся жизни.
Но если таблицы нет или в ней не хватает строк, это не работает. Фича отключается на товаре неявным образом номер раз.
Хм, в таблице обхваты тела человека, а большинство продавцов указывает их, померив на самих вещах. Вшиваем коэффициент разницы. Продакт-менеджер Родион, счастливый обладатель идеальной М-ки, идёт в торговый центр, меряет на себе кучу разных вещей и приходит с коэффициентами — они похожи, но существенно различаются для разных категорий товаров. Для обхватывающей водолазки разница практически 0%, а для свитера все 10%. Также верхняя одежда различается по посадке: slim fit, normal fit, loose fit, и это даёт размах ещё в ±5%. Теперь наш коэффициент (увековеченный мною в коде как коэффициент Родиона) состоит из двух множителей.
Для определения посадки делаем ещё один парсер, пытающийся извлечь её из названия или описания товара. Если товар не попал в одну из категорий, проверенных Родионом, фича выключается неявным образом номер два.
Финальный штрих: в большом количестве товаров обхват груди указан от подмышки до подмышки, то есть только половина обхвата, что приводит к смехотворно маленьким размерам. Добавляем логику, что если обхват меньше X, то ну не может такого быть, это явно половина обхвата, и умножаем его на два. Хорошо, что взрослые люди обычно не отличаются друг от друга обхватом груди в два раза.
Теперь всё так сложно, что при тестировании фичи по виду товара в админке невозможно понять, почему она не включилась или сработала так или иначе. Добавляем в код большой слой логики, детально логирующий причины выключения конвертации. Чтобы можно было полностью отследить причину выключения на конкретном товаре, приходится пробрасывать сообщения об ошибках наверх, обогащая деталями, несколько раз. Код становится ужасающим.
И всё это работает по-разному в зависимости от группы A/B-теста, конечно.
Заключение
Бойтесь