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

Joom

Как построить надежное приложение на базе Event sourcing?

15.09.2020 14:04:30 | Автор: admin

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



The Project


Проект JoomAds предлагает продавцам инструменты продвижения товаров в Joom. Для продавца процесс продвижения начинается с создания рекламной кампании, которая состоит из:


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

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



Рис. 1


JoomAds API использует это состояние при обработке запросов в поиске, каталоге или в разделе Лучшее для показа оптимального товара каждому пользователю с учетом региональной доступности и персонализации.


JoomAds API может изменять часть состояния при регистрации покупок успешно прорекламированных товаров, корректируя остаток бюджета рекламных кампаний (Рис. 1). Настройками кампаний управляет сервис кампаний JoomAds Campaign, метаданными продукта сервис Inventory, данные ранжирования расположены в хранилище аналитики (Рис. 2).


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



Рис. 2
JoomAds API выступает в роли медиатора данной микросервисной системы.


Pure Microservices equals Problems


Вы можете задать справедливый вопрос: Есть ли проблема в таком количестве внешних коммуникаций? Всего три вызова....


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


Быстродействие


Любые внешние коммуникации (например, поход за метаданными товара в Inventory) это дополнительные накладные расходы, увеличивающие время ответа медиатора. Такие расходы не проблема на ранних этапах развития проекта: последовательные походы в JoomAds Campaign, Inventory и хранилище аналитики вносили небольшой вклад во время ответа JoomAds API, т.к. количество рекламируемых товаров было небольшим, а рекламная выдача присутствовала только в разделе Лучшее.


Однако с ростом количества товаров в рекламе и подключением трафика других разделов Joom, 95-й перцентиль времени JoomAds API достиг сотен миллисекунд вместо желаемых десятков. Такая ситуация является результатом несоответствия текущих условий эксплуатации исходным требованиям, использованным при разработке отдельных компонентов.


Например, поиск товаров Inventory не был рассчитан на высокие частоты запросов, но он нам нужен именно таким.


Отказоустойчивость


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


Отказ любой зависимости JoomAds API ведет к некорректной или неповторяемой рекламной выдаче, либо к ее полному отсутствию.


Сложность поддержки


Микросервисная архитектура позволяет снизить сложность поддержки узкоспециализированных приложений, таких как Inventory, но значительно усложняет разработку приложений-медиаторов, таких как JoomAds API.


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


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


Эти наблюдения привели нас к осознанию необходимости изменений. Новый JoomAds должен генерировать экономически эффективную и согласованную рекламную выдачу при отказе JoomAds Campaign, Inventory или хранилища аналитики, а также иметь предсказуемое быстродействие и отвечать на входящие запросы быстрее 100 мс в 95% случаев.


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


Влияние негативных факторов можно снизить защитными техниками: таймауты для запросов внешних сервисов и Circuit Breaker механика, кэширование компонентов состояния товара и готовой рекламной выдачи.


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


Monolith over microservices (kind of)


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


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


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


Materialization


Совместить лучшие качества микросервисов и монолитной архитектуры нам позволил подход, именуемый Materialized View. Материализованные представления часто встречаются в реализациях СУБД. Основной целью их внедрения является оптимизация доступа к данным на чтение при выполнении конкретных запросов.


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


Например, для запросов состояния продукта по его идентификатору (см. Рис. 3) или запросов состояния множества продуктов по идентификатору рекламной кампании.



Рис. 3


Материализованное представление данных расположено во внутреннем хранилище JoomAds API, поэтому замыкание входящих коммуникаций на него положительно сказывается на производительности и отказоустойчивости системы, т.к. доступ на чтение теперь зависит только от доступности / производительности хранилища данных JoomAds, а не от аналогичных характеристик внешних ресурсов. JoomAds API является надежным монолитным приложением!


Но как обновлять данные Materialized View?


Data Sourcing


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


  • Изолировать клиентскую сторону от проблем доступа к внешним ресурсам.
  • Учитывать возможность высокого времени ответа компонентов инфраструктуры JoomAds.
  • Предоставлять механизм восстановления на случай утраты текущего состояния Materialized View.

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


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


Например, мы не запрашиваем метаданные товара в Inventory, а храним их локальную копию, которая обновляется внешним событием, которое нам отправляет сервис Inventory при каждом редактировании метаданных товара. Эта схема более выгодна сервису Inventory, т.к. операция отправки события о редактировании товара относительно дешевая, а сами изменения метаданных происходят значительно реже, чем их чтения со стороны JoomAds.


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


Event Sourcing


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


В результате адаптации Event Sourcing подхода в инфраструктуре JoomAds появились три новых компонента: хранилище материализованного представления (MAT View Storage), конвейер материализации (Materialization Pipeline), а так же конвейер ранжирования (Ranking Pipeline), реализующий поточное вычисление потоварных score'ов ранжирования (см. Рис. 4).



Рис. 4


Discussion, Technologies


Materialized View и Event Sourcing позволили нам решить основные проблемы ранней архитектуры проекта JoomAds.


Специализированные Materialized View значительно повысили надежность и быстродействие клиентских запросов. Обновление данных с использованием Event Sourcing подхода повысило надежность коммуникации с внешними сервисами, предоставило инструменты контроля консистентности данных и позволило избавиться от неэффективных запросов к внешним ресурсам.


Однако у всех решений есть цена. Чем больше несовместимых классов запросов реализует ваше приложение, тем больше материализованных представлений вам требуется собрать. Такой подход увеличивает потребление ресурсов по памяти, системе хранения данных и CPU. Материализованные представления JoomAds располагаются в хранилище Apache Cassandra, поэтому процесс порождения новых представлений, удаления старых или модификации существующих можно назвать безболезненным.


В нашем случае MAT View целиком хранится в одной таблице Cassandra: добавление колонок в таблицы Cassandra безболезненная операция, удаление MAT View осуществляется удалением таблицы. Таким образом, крайне важно выбрать удачное хранилище для реализации Materialized View в вашем проекте.


Event Sourcing предъявляет серьезные требования к своим пользователям. Генерация событий изменения данных во всех интересующих подсистемах в заданном формате с возможностью установления хронологического порядка следования это сложная организационно-техническая задача, которую крайне трудно реализовать в середине жизненного цикла ПО. Можно назвать удачей, что хранилище данных Inventory уже имело функцию генерации событий на обновление метаданных продуктов.


Однако это лишь часть истории. Для завершения системы Event Sourcing требуется механизм регистрации событий, их упорядоченного и отказоустойчивого хранения с очисткой устаревших данных, а также процессор с поддержкой конкурентной обработки данных, управляемой отказоустойчивостью и возможностью горизонтального масштабирования. Заниматься разработкой таких систем с чистого листа сложная и трудоемкая работа, которую мы решили не проводить.


Вместо этого мы воспользовались популярными open-source решениями, развивающимися при участии Apache Software Foundation: Apache Kafka и Apache Flink.


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


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


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


Takeaway


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


P.S. Этот пост был впервые опубликован в блоге Joom на vc, вы могли его встречать там. Так делать можно.

Подробнее..

Неожиданная сложность простых программ

18.05.2021 18:11:11 | Автор: admin
Не раз я сталкивался с удивлением при оглашении оценки сложности проекта: А почему так долго?, Да тут же раз, два и готово!, Можно же просто взять X и сунуть в Y!. Программисты привыкли оценивать сроки как время на написание и отладку кода, хотя в крупные задачи входит ещё много всего.


Знаете ли вы, что в реальности айсберги располагаются в воде горизонтально, а не вертикально, как на большинстве стоковых картинок?

Но даже если забыть про традиционный букет энтерпрайзовых примочек вроде аналитики, поддержки обратной совместимости и 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), также существуют сокращения двух, трёх или даже четырёх слов в одно (Mara 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-теста, конечно.

Заключение


Бойтесь данайцев, дары приносящих разработчиков, оптимистично оценивающих сроки. Оценивать время на разработку очень сложно, как бы просто ни звучала задача, и сюрпризы ждут нас на каждом шагу!
Подробнее..

Знакомимся с Needle, системой внедрения зависимостей на Swift

20.08.2020 18:04:07 | Автор: admin

Привет! Меня зовут Антон, я iOS-разработчик в Joom. Из этой статьи вы узнаете, как мы работаем с DI-фреймворком Needle, и реально ли он чем-то выгодно отличается от аналогичных решений и готов для использования в production-коде. Это всё с замерами производительности, естественно.



Предыстория


Во времена, когда приложения для iOS еще писали полностью на Objective-C, существовало не так много DI-фреймворков, и стандартом по умолчанию среди них считался Typhoon. При всех своих очевидных плюсах, Typhoon приносил с собой и определённый overhead в runtime, что приводило к потере производительности в приложении.


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


Потом на смену Objective-C пришел Swift, и все больше приложений стало переходить на этот новый язык. Ну а что же мы?


Пока все переходили на Swift, мы продолжали писать на Objective-C и пользовались самописным решением для DI. В нем было реализовано все то, что нам нужно было от инструмента для внедрения зависимостей: скорость и надежность.
Скорость обеспечивалась за счет того, что не надо было регистрировать никакие зависимости в runtime. Контейнер состоял из обычных property, которые могли при необходимости предоставляться в виде:


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

Надежность обеспечивалась за счет того, что все проверки проходили в compile time. Поэтому если где-то в header контейнера мы объявили зависимость и забыли реализовать ее создание в implementation, или у зависимости не находилось какое-либо свойство в месте обращения к ней, то об этом мы узнавали на этапе компиляции проекта.


Но у этого решения был один недостаток, который нам мешал жить.


Представьте, что у вас есть граф DI-контейнеров и вам надо из контейнера в одной ветке графа пронести зависимость в контейнер из другой ветки графа. При этом глубина веток запросто может достигать 5-6 уровней.


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


  • сделать forward declaration типа новой зависимости в .h-файле дочернего контейнера;
  • объявить зависимость в качестве входного параметра конструктора в .h-файле дочернего контейнера;
  • сделать #import header с типом зависимости в .m-файле дочернего контейнера;
  • объявить зависимость в качестве входного параметра конструктора в .m-файле дочернего контейнера;
  • объявить свойство в дочернем контейнере, куда мы положим эту зависимость.

Многовато, не правда ли? И это только для проброса на один уровень ниже.


Понятно, что половину этих действий требует сама идеология разбиения кода на заголовочные файлы и файлы с имплементацией в языках семейства Cи. Но это становилось головной болью разработчика и требовало от него по сути бездумного набора copy/paste действий, которые убивают любую мотивацию в процессе разработки.


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


Но это не правильный путь. В таком случае один объект получает больше знаний, чем ему нужно для работы. Все мы проходили интервью, где рассказывали про принципы SOLID, заветы Дядюшки Боба, и вот это вот все, и знаем, что так делать не стоит. И мы достаточно долго жили только с этим решением и продолжали писать на Objective-C.


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


В плане DI настало время еще раз посмотреть на имеющиеся решения.


Нам нужен был framework, который бы обладал теми же преимуществами, что и наше самописное решение на Objective-C. При этом бы не требовал написания большого объема boilerplate кода.


На данный момент существует множество DI framework-ов на Swift. Cамыми популярными на текущий момент можно назвать Swinject и Dip. Но у этих решений есть проблемы.


А именно:


  • Граф зависимостей создается в runtime. Поэтому, если вы забыли зарегистрировать зависимость, то об этом вы узнаете благодаря падению, которое произойдет непосредственно во время работы приложения и обращения к зависимости.
  • Регистрация зависимостей так же происходит в runtime, что увеличивает время запуска приложения.
  • Для получения зависимости в этих решениях приходится пользоваться такими конструкциями языка, как force unwrap ! (Swinject) или try! (Dip) для получения зависимостей, что не делает ваш код лучше и надежнее.

Нас это не устраивало, и мы решили поискать альтернативные решения. К счастью, нам попался достаточно молодой DI framework под названием Needle.


Общая информация


Needle это open-source решение от компании Uber, которое написано на Swift и существует с 2018 года (первый коммит 7 мая 2018).


Главным преимуществом по словам разработчиков является обеспечение compile time safety кода работы для внедрения зависимостей.


Давайте разберемся как это все работает.


Needle состоит из двух основных частей: генератор кода и NeedleFoundation framework.


Генератор кода


Генератор кода нужен для парсинга DI кода вашего проекта и генерации на его основе графа зависимостей. Работает на базе SourceKit.


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


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


Сам генератор поставляется в бинарном виде. Его можно получить двумя способами:


  1. Воспользоваться утилитой homebrew:
    brew install needle
  2. Склонировать репозиторий проекта и найти его внутри:
    git clone https://github.com/uber/needle.git & cd Generator/bin/needle

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


export SOURCEKIT_LOGGING=0 && needle generate ../NeedleGenerated.swift

../NeedleGenerated.swift файл, в которой будет помещен весь генерированный код для построения графа зависимостей.


NeedleFoundation


NeedleFoundation это фреймворк, который предоставляет разработчикам набор базовых классов и протоколов для создания контейнеров с зависимостями.


Устанавливается без проблем через один из менеджеров зависимостей. Пример добавления с помощью CocoaPods:


pod 'NeedleFoundation'

Сам граф начинает строиться с создания root-контейнера, который должен быть наследником специального класса BootstrapComponent.


Остальные контейнеры должны наследоваться от класса Component.
Зависимости DI-контейнера описываются в протоколе, который наследуется от базового протокола зависимостей Dependency и указывается в качестве generic type-а самого контейнера.


Вот пример такого контейнера с зависимостями:


protocol SomeUIDependency: Dependency {    var applicationURLHandler: ApplicationURLHandler { get }    var router: Router { get }}final class SomeUIComponent: Component<SomeDependency> {    ...}

Если зависимостей нет, то указывается специальный протокол <EmptyDependency>.


Все DI-контейнеры содержат в себе lazy-свойства path и name:


// Component.swiftpublic lazy var path: [String] = {        let name = self.name        return parent.path + ["\(name)"]}()private lazy var name: String = {    let fullyQualifiedSelfName = String(describing: self)    let parts = fullyQualifiedSelfName.components(separatedBy: ".")    return parts.last ?? fullyQualifiedSelfName}()

Эти свойства нужны для того, чтобы сформировать путь до DI-контейнера в графе.


Например, если у нас есть следующая иерархия контейнеров:


RootComponent->UIComponent->SupportUIComponent,


то для SupportUIComponent свойство path будет содержать значение [RootComponent, UIComponent, SupportUIComponent].


Во время инициализации DI-контейнера в конструкторе извлекается DependencyProvider из специального регистра, который представлен в виде специального singleton-объекта класса __DependencyProviderRegistry:


// Component.swiftpublic init(parent: Scope) {     self.parent = parent     dependency = createDependencyProvider()}// ...private func createDependencyProvider() -> DependencyType {    let provider = __DependencyProviderRegistry.instance.dependencyProvider(for: self)    if let dependency = provider as? DependencyType {        return dependency    } else {        // This case should never occur with properly generated Needle code.        // Needle's official generator should guarantee the correctness.        fatalError("Dependency provider factory for \(self) returned incorrect type. Should be of type \(String(describing: DependencyType.self)). Actual type is \(String(describing: dependency))")    }}

Для того, чтобы найти нужный DependencyProvider в __DependencyProviderRegistry используется ранее описанное свойство контейнера path. Все строки из этого массива соединяются и образуют итоговую строку, которая отражает путь до контейнера в графе. Далее от итоговой строки берется hash и по нему уже извлекается фабрика, которая и создает провайдер зависимостей:


// DependencyProviderRegistry.swiftfunc dependencyProvider(`for` component: Scope) -> AnyObject {    providerFactoryLock.lock()    defer {        providerFactoryLock.unlock()    }    let pathString = component.path.joined(separator: "->")    if let factory = providerFactories[pathString.hashValue] {        return factory(component)    } else {        // This case should never occur with properly generated Needle code.        // This is useful for Needle generator development only.          fatalError("Missing dependency provider factory for \(component.path)")    }}

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


Пример обращения к зависимости:


protocol SomeUIDependency: Dependency {    var applicationURLHandler: ApplicationURLHandler { get }    var router: Router { get }}final class SomeUIComponent: Component<SomeDependency> {    var someObject: SomeObjectClass {        shared {            SomeObjectClass(router: dependecy.router)        }    }}

Теперь рассмотрим откуда берутся DependecyProvider.


Создание DependencyProvider


Как мы уже было отмечено ранее, для каждого объявленного в коде DI-контейнера создается свой DependencyProvider. Это происходит за счет кодогенерации. Генератор кода Needle анализирует исходный код проекта и ищет всех наследников базовых классов для DI-контейнеров BootstrapComponent и Component.


У каждого DI-контейнера есть протокол описания зависимостей.


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


Зависимость считается найденой только если совпадают имя и тип зависимости.


Если зависимость не найдена, то сборка проекта останавливается с ошибкой, в которой указывается потерянная зависимость. Это первый уровень обеспечения compile-time safety.


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


// NeedleGenerated.swift/// ^->RootComponent->UIComponent->SupportUIComponent->SomeUIComponentprivate class SomeUIDependencyfb16d126f544a2fb6a43Provider: SomeUIDependency {    var applicationURLHandler: ApplicationURLHandler {        return supportUIComponent.coreComponents.applicationURLHandler    }    // ...}

Если по каким-то причинам на этапе построения связей между контейнерами потерялась зависимость и генератор пропустил этот момент, то на этом этапе вы получите не собирающийся проект, так как поломанный DependecyProvider не будет отвечать протоколу зависимостей. Это второй уровень compile-time safety от Needle.


Теперь рассмотрим процесс поиска провайдера зависимостей для контейнера.


Регистрация DependencyProvider


Получив готовые DependecyProvider и зная связь между контейнерами, генератор кода Needle создает для каждого контейнера путь в итоговом графе.


Каждому пути сопоставляется closure-фабрика, внутри которой возвращается провайдер зависимостей. Код сопоставления создается кодогенератором.


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


// NeedleGenerated.swiftpublic func registerProviderFactories() {    __DependencyProviderRegistry.instance.registerDependencyProviderFactory(for: "^->RootComponent") { component in        return EmptyDependencyProvider(component: component)    }    __DependencyProviderRegistry.instance.registerDependencyProviderFactory(for: "^->RootComponent->UIComponent") { component in        return EmptyDependencyProvider(component: component)    }        // ...}   

Сама регистрация внутри глобальной функции происходит с помощью singleton-объекта класса __DependencyProviderRegistry. Внутри данного объекта провайдеры зависимостей складываются в словарь [Int: (Scope) -> AnyObject], в котором ключом является hashValue от строки, описывающий путь от вершины графа до контейнера, а значением closure-фабрика. Сама запись в таблицу является thread-safe за счет использования внутри NSRecursiveLock.


// DependencyProviderRegistry.swiftpublic func registerDependencyProviderFactory(`for` componentPath: String, _ dependencyProviderFactory: @escaping (Scope) -> AnyObject) {    providerFactoryLock.lock()    defer {        providerFactoryLock.unlock()    }    providerFactories[componentPath.hashValue] = dependencyProviderFactory}

Результаты тестирования в проекте


Сейчас у нас порядка 430к строк кода без учета сторонних зависимостей. Из них около 83к строк на Swift.


Все замеры мы проводили на iPhone 11 c iOS 13.3.1 и с использование Needle версии 0.14.


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


Проведенные тесты


Время полной сборки


Номер измерения Без Needle С Needle
1 294.5s 295.1s
2 280.8s 286.4s
3 268.2s 294.1s
4 282.9s 279.5s
5 291.5s 293.4s

Среднее значение без Needle: 283.58s


Среднее значение с Needle: 289.7s


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


Время инкрементальной сборки


Номер измерения Без Needle С Needle
1 37.8s 36.1s
2 27.9s 37.0s
3 37.3s 33.0s
4 38.2s 35.5s
5 37.8s 35.8s

Среднее значение Без Needle: 35.8s


Среднее значение С Needle: 35.48s


В этом тесте мы добавляли и удаляли к контейнеру в самом низу графа одну и ту же зависимость.


Измерения registerProviderFactories()


Среднее значение (секунды): 0.000103


Замеры:


0.00015008449554443360.00009393692016601560.00009000301361083980.00009202957153320310.00012707710266113280.00009500980377197260.00009107589721679680.00009703636169433590.00009691715240478510.0000959634780883789

В этом тесте мы выяснили, что время на запуск нашего приложения при использовании Needle почти не изменилось.


Измерения первого доступа к зависимости


Номер измерения Без Needle С Needle C Needle + FakeComponents
1 0.000069 0.001111 0.002981
2 0.000103 0.001153 0.002657
3 0.000080 0.001132 0.002418
4 0.000096 0.001142 0.002812
5 0.000078 0.001177 0.001960

Среднее значение Без Needle (секунды): 0.000085


Среднее значение C Needle (секунды): 0.001143 (+0.001058)


Среднее значение C Needle + FakeComponents (секунды): 0.002566


Примечание: SomeUIComponent в тестируемом примере лежит на седьмом уровне вложенности графа:^->RootComponent->UIComponent->SupportUIComponent->SupportUIFake0Component->SupportUIFake1Component->SupportUIFake2Component->SupportUIFake3Component->SomeUIComponent


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


Измерения повторного доступа к BabyloneUIComponent c Needle


Номер измерения Без Needle С Needle C Needle + FakeComponents
1 0.000031 0.000069 0.000088
2 0.000037 0.000049 0.000100
3 0.000053 0.000054 0.000082
4 0.000057 0.000064 0.000092
5 0.000041 0.000053 0.000088

Среднее значение без Needle (секунды): 0.000044


Среднее значение с Needle (секунды): 0.000058


Среднее значение с Needle + FakeComponents (секунды):0.000091


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


Выводы


В итоге по результатам тестов мы пришли к выводу, что Needle дает нам именно то, что мы хотели от DI-фреймворка.


Он дает нам надежность благодаря обеспечению compile time safety кода зависимостей.


Он быстрый. Не такой быстрый, как наше самописное решение на Objective-C, но все же в абсолютных цифрах он достаточно быстрый для нас.


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


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


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

Подробнее..

Категории

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

  • Имя: Макс
    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