Всем привет. Меня зовут Илья, я фронтенд-разработчик из юнита BuyerX в Авито. Хочу поделиться тем, каким образом у нас в команде организовано хранение кодовой базы, почему мы пришли к использованию монорепозитория и как улучшаем DX-работы с ним, а также кратко рассказать про организацию тестирования.
Текущее положение дел
Юнит BuyerX отвечает за страницы и функциональность, которая помогает пользователю выбирать товары на Авито. То есть мы стараемся сделать пользователя покупателем. К нашей зоне ответственности относятся главная страница, страницы объявлений и страницы с поисковой выдачей. Страницы с поисковой выдачей могут быть специфичными под определённые категории: например, где-то не нужны поисковые фильтры или нужен особый заголовок страницы и расположение самих элементов.
Главная страница Авито
Страница поисковой выдачи
Страница объявления
Разработка этих страниц вызывала определённые проблемы:
- Все шаблоны жили в огромной основной кодовой базе, где было сложно ориентироваться и вносить изменения.
- После внесения даже маленьких изменений сборка проекта занимала продолжительное время — до 5-10 минут в худшем случае — и потребляла большое количество ресурсов компьютера.
- Когда хотелось, чтобы отдельные компоненты работали на React-е, приходилось дублировать вёрстку и в шаблоне, и в компоненте для того, чтобы сделать механизм серверного рендеринга.
Кроме того, главная страница, страницы объявлений и страницы с поисковой выдачей позволяют создать непрерывный сценарий покупки для пользователя. Поэтому в своё время мы приняли решение переписать их с легаси-технологии Twig на React, чтобы в будущем перейти на SPA-приложение, а также оптимизировать процесс загрузки компонентов для каждой из страниц. Так, к примеру, сниппет объявления на главной и странице поиска одинаковый. Тогда зачем грузить его два раза?
В связи с этим появился npm-пакет, который мы гордо назвали single-page. Задача этого пакета — содержать внутри себя верхнеуровневое описание каждой из страниц. Это описание того, из каких React-компонентов состоит страница, и в каком порядке и месте они должны быть расположены. Также пакет призван управлять загрузкой нужных и выгрузкой уже ненужных компонентов, которые представляют собой небольшие React+Redux приложения, вынесенные в отдельные npm-пакеты.
Например, при переходе на страницу с результатами поиска, пользователю необходимы поисковые фильтры, которые позволят сузить выдачу. В данном случае single-page определяет, что необходимо запросить пакет с фильтрами и делает соответствующий запрос. Если пользователь уже перешёл на страницу объявления, то поисковые фильтры ему не нужны, а значит и сам пакет можно не использовать, чтобы он не ухудшал производительность страницы.
Сам пакет single-page уже работает как описано. Однако для того, чтобы все описанные механики приносили пользу, переход между страницами должен быть бесшовным, как в SPA-приложениях. К сожалению, на момент написания статьи бесшовных переходов между страницами нет. Но это не значит, что их не будет. То, каким образом сейчас работает пакет single-page — это основа, которую мы будем развивать дальше. Она уже задала определённые правила, связанные с организацией кодовой базы, которых мы в юните придерживаемся.
Расскажу, как именно происходила эволюция работы с репозиториями npm-пакетов, и к чему мы пришли.
Отдельные репозитории для npm-пакетов
Как я упоминал ранее, изначально главная страница и страница с поисковой выдачей были написаны с использованием шаблонизатора Twig. Был один большой шаблон страницы, который импортировал в себя более мелкие шаблоны отдельных компонентов страницы.
Это немного похоже на вёрстку через React-компоненты, но проблема была в том, что во всех шаблонах было много бизнес-логики, которая усложняла процесс чтения и внесения изменений. Плюс, из-за того, что каждая страница — это отдельный шаблон, при переходах пользователя между страницами приходилось грузить всю страницу заново, даже если некоторые компоненты переиспользовались между шаблонами. Это увеличивало время ответа при обращении пользователя за какой-либо страницей, что не лучшим образом сказывалось на UX и SEO.
Стало понятно, что необходимо переходить на новый стек — React. Переход проходил поэтапно. Сначала в юните договорились, что все новые компоненты будут написаны сразу на React-е и будут рендериться на страницу после её загрузки. Эти компоненты слабо влияли на SEO, поэтому подход имел право на жизнь. Пример такого компонента — блок с просмотренными и избранными объявлениями на главной странице:
Для работы новых компонентов на React-е иногда нужны были уже существующие компоненты, написанные на Twig-е. Поэтому мы начали переносить некоторые из них на React в урезанном виде. Так, к примеру, сниппет, который используется в блоке просмотренных объявлений, был частично перенесён на новый стек, хотя на Twig весь шаблон с различными условиями, влияющими на информацию в сниппете, содержал более 1000 строк.
![](https://habrastorage.org/webt/v-/kd/tr/v-kdtrhlmi8sps4yvheyyyraxse.png)
Сниппет в блоке просмотренных объявлений, который был перенесён сразу на новый стек
А вот как выглядят сниппеты в различных категориях:
Сниппет в категории бытовой электронике
Сниппет в категории работа
Сниппет в категории недвижимость
Сниппет в категории автомобили
Вынос в npm-пакет — это только часть работы. Для того, чтобы пакет был полезен, он должен быть использован в основной кодовой базе. Поскольку все компоненты — это отдельные пакеты, то в монолите (основной кодовой базе) необходимо подключить их как зависимости, а далее проинициализировать эти пакеты в коде. Так как блок просмотренных объявлений и сниппет были выделены в отдельные npm-пакеты, то в кодовой базе монолита они были подключены как зависимости. Чтобы увидеть изменения, которые появились с новой версией пакета, достаточно поднять его версию без внесения каких-либо дополнительных изменений.
Как видите, между пакетами уже появляются зависимости: монолит зависит от компонента блока просмотренных и избранных объявлений, который в свою очередь зависит от компонентов сниппета. Но в данном случае связь небольшая, поэтому после внесения изменений в npm-пакет сниппетов необходимо:
- поднять его версию;
- опубликовать её;
- подключить новую версию в компоненте правого блока;
- поднять его версию;
- поднять версию в монолите.
Это может звучать сложно, но на деле процесс занимал минуты две и не требовал особых навыков.
Но продукт не стоял на месте. Он развивался и появлялись новые компоненты, например, компонент технических характеристик для автомобилей. Кроме того, активно переписывались такие старые компоненты как поисковые фильтры. Количество пакетов постепенно росло, и начали появляться новые проблемы.
Между пакетами стало появляться больше зависимостей, и не каждый инженер знал о них всех. Иногда даже мы в юните могли о чём-то забыть, а разработчик из соседней команды вполне мог не знать о существовании отдельного npm-пакета, который содержит в зависимостях изменяемый пакет. Из-за этого в пакетах могли использоваться разные версии зависимого пакета. Плюс, так как каждый npm-пакет был отдельным репозиторием, количество пул-реквестов сильно выросло: в каждый репозиторий npm-пакета делался пул-реквест с изменением версии самого пакета и его зависимостей.
У подхода, когда каждый npm-пакет жил в своём репозитории, был и свой плюс. При поднятии версии зависимости в каком-нибудь пакете, необходимо было убедиться, что этот пакет не сломается при работе на сайте. То есть нужно было протестировать все внесённые изменения. Проверки по большей части приходилось делать руками — существовавшего набора тестов и инструментария было недостаточно для покрытия большинства сценариев. А при нахождении каких-либо багов в тестируемом пакете, их нужно было исправить. Поэтому нередко присутствовала практика, когда версию зависимости поднимали не везде, а только там, где это необходимо. С одной стороны, это усугубляло рассинхронизацию версий зависимых пакетов, а с другой сильно упрощало тестирование за счёт отсутствия некоторых проверок.
Однако в какой-то момент рассинхронизация версий зависимостей привела к новым проблемам: пакет мог содержать сильно старую зависимость и простое её обновление могло сломать работу всего пакета. Из-за этого приходилось тратить лишнее время на актуализацию зависимостей, внесение правок в сам пакет и, возможно, в зависимые тоже, а также на тестирование этого пакета. Конечно, в каждом репозитории были тесты, чтобы проверить, что всё работает корректно. Но тесты были только скриншотные, что ограничивало круг покрываемых вещей. Плюс не во всех репозиториях был подключен CI, чтобы тесты прогонялись на каждом пул-реквесте.
Что получилось на этом этапе:
- Количество связанных пакетов выросло.
- Каждый пакет жил в отдельном репозитории.
- В каждый репозиторий создавался пул-реквест с внесением изменений, связанных с поднятием версий зависимости.
- Ситуация, когда пакет не имел последнюю актуальную версию зависимости была нормой. Плюс, к этому моменту уже создавался пакет single-page, который в зависимостях имел все ранее созданные пакеты.
Всё это привело к тому, что мы начали искать пути для улучшения работы с репозиториями и всеми нашими пакетами.
Переход к монорепозиторию
Поиск инструментов привёл нас к Lerna. Lerna — опенсорсный инструмент для работы с монорепозиториями. Он позволил бы нам хранить все пакеты в монорепозитории, и забрал бы часть работы по синхронизации версий на себя. Тогда мы смогли бы не заботиться о том, чтобы поднимать все версии пакетов руками, а главное — иметь прозрачную кодовую базу со всеми зависимостями.
После небольшого эксперимента и ресёрча, стало понятно, что Lerna поможет решить часть наших проблем:
- С ней не будет необходимости в каждом пакете самим поднимать версии зависимых пакетов.
- Отпадёт необходимость постоянно помнить про зависимости, потому что Lerna знает, какие пакеты какими версиями связаны.
- Количество пул-реквестов уменьшится до одного, в котором видны все зависимости, которые будут изменены.
Мы завели отдельный репозиторий, в который начали переносить все пакеты и связывать их зависимостями через Lerna.
Но, при всех своих преимуществах, Lerna требует достаточно глубокого понимания. Даже в нашем юните стали возникать ситуации, когда мы не понимали, как правильно с ней работать. Но кроме нас есть соседние юниты, которые тоже вносят изменения в наш код. И им тоже необходимо погружаться в магию работы Lerna. В итоге мы избавились от проблем хранения кода, но не от проблем работы с ним.
Для решения этой задачи внутри нашего юнита нашелся доброволец, который хорошо разобрался в особенностях работы Lerna и поделился знаниями на мастер-классе. Но как быть с остальными? Как быть с новыми разработчиками, которые приходят в компанию и вносят свои изменения? Проводить мастер-класс каждый раз не будешь. В ReadMe инструмента, как и внутри нашего монорепозитория, есть инструкция для работы, но она не всегда помогает. Из-за непонимания случалось следующее:
- в репозитории обновлялись лишние пакеты;
- в основной кодовой базе монолита поднято меньше зависимостей, чем должно быть;
- или наоборот зависимостей поднято больше, чем должно быть.
Кроме того, перед тем, как опубликовать пакет, необходимо обновить для него changelog. В большинстве случаев он является копипастой, но её необходимо повторить в нескольких репозиториях. Для одного-двух это не проблема, но когда пакетов становится десяток, легко где-то допустить ошибку или забыть добавить запись в changelog. А это значит, что придётся вносить исправления и ждать, когда завершит свою работу билд CI.
Возникшие проблемы мы решили через автоматизацию. Для этого написали специальный скрипт, который:
- находил все зависимые пакеты;
- поднимал в них dev или latest версию в зависимости от потребности;
- добавлял changelog всем пакетам;
- заменял новые версии в package.json файле монолита на новые и, в случае dev пакетов, ещё и публиковал их.
Так мы убрали барьер, связанный с пониманием работы Lerna под капотом. Инструкция по работе с монорепозиторием свелась к простому действию: запустить скрипт и проверить, что нет лишних зависимостей. Последнее, к сожалению, всё ещё случается, поэтому проверку, что нет лишнего, мы тоже планируем автоматизировать.
Работа CI
Остаётся ещё один вопрос, который хочется подсветить: CI.
Так как репозиторий один, то и все тесты гоняются на нём сразу. Кроме того, появился внутренний инструмент, который позволяет писать компонентные тесты с использованием библиотеки Enzyme. Его написали ребята из платформенной команды Авито и рассказали о нём в докладе «Жесть для Jest». Это позволило покрыть сценарии, которые раньше нельзя было проверить. Так, например, появилась возможность проверить вызов попапа при клике на кнопку «все характеристики» в пакете с техническими характеристиками для автомобилей.
В результате на каждый пул-реквест прогоняется весь набор имеющихся тестов. Теперь тесты могут дать определённую гарантию того, что при поднятии версии зависимого пакета ничего не сломается. Но есть и минусы: количество пакетов в монорепозитории растёт, с ним растёт и количество тестов. Как следствие, растёт и время их прогона. При этом кажется, что нет смысла гонять тесты там, где нет никаких изменений. А значит, можно сократить время выполнения билда в будущем. Внутренний инструмент продолжает развиваться, и уже сейчас в нём появился механизм для прогона определённого скоупа тестов. Именно этот механизм и будет использоваться для уменьшения выборки тестов и сокращения времени выполнения билда.
Итоги и планы
За два года мы прошли процесс перехода от большого количества слабо связанных репозиториев до автоматизации работы с монорепозиторием через Lerna. При этом, есть гарантия того, что всё будет работать корректно благодаря прогону тестов на каждом пул-реквесте. Количество самих пул-реквестов уменьшилось с 8-9 в худшем случае до двух: в монолит и в монорепозиторий. При этом пул-реквест в монорепозиторий содержит сразу все изменения.
В будущем мы хотим уменьшить время прогона тестов, убрав лишние. Кроме того, возникают ситуации, когда кто-то случайно ломает связь между пакетами, которую сделала Lerna. Такие кейсы хотелось бы научиться отлавливать на уровне пул-реквеста, а не спустя какое-то время после мержа кода в мастер.