company_banner

Организация кодовой базы и тестирования в монорепозитории

    Всем привет. Меня зовут Илья, я фронтенд-разработчик из юнита 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 строк.



    Сниппет в блоке просмотренных объявлений, который был перенесён сразу на новый стек


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



    Сниппет в категории бытовой электронике



    Сниппет в категории работа



    Сниппет в категории недвижимость



    Сниппет в категории автомобили


    Вынос в npm-пакет — это только часть работы. Для того, чтобы пакет был полезен, он должен быть использован в основной кодовой базе. Поскольку все компоненты — это отдельные пакеты, то в монолите (основной кодовой базе) необходимо подключить их как зависимости, а далее проинициализировать эти пакеты в коде. Так как блок просмотренных объявлений и сниппет были выделены в отдельные npm-пакеты, то в кодовой базе монолита они были подключены как зависимости. Чтобы увидеть изменения, которые появились с новой версией пакета, достаточно поднять его версию без внесения каких-либо дополнительных изменений.


    Как видите, между пакетами уже появляются зависимости: монолит зависит от компонента блока просмотренных и избранных объявлений, который в свою очередь зависит от компонентов сниппета. Но в данном случае связь небольшая, поэтому после внесения изменений в npm-пакет сниппетов необходимо:


    • поднять его версию;
    • опубликовать её;
    • подключить новую версию в компоненте правого блока;
    • поднять его версию;
    • поднять версию в монолите.

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


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


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


    У подхода, когда каждый npm-пакет жил в своём репозитории, был и свой плюс. При поднятии версии зависимости в каком-нибудь пакете, необходимо было убедиться, что этот пакет не сломается при работе на сайте. То есть нужно было протестировать все внесённые изменения. Проверки по большей части приходилось делать руками — существовавшего набора тестов и инструментария было недостаточно для покрытия большинства сценариев. А при нахождении каких-либо багов в тестируемом пакете, их нужно было исправить. Поэтому нередко присутствовала практика, когда версию зависимости поднимали не везде, а только там, где это необходимо. С одной стороны, это усугубляло рассинхронизацию версий зависимых пакетов, а с другой сильно упрощало тестирование за счёт отсутствия некоторых проверок.


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


    Что получилось на этом этапе:


    1. Количество связанных пакетов выросло.
    2. Каждый пакет жил в отдельном репозитории.
    3. В каждый репозиторий создавался пул-реквест с внесением изменений, связанных с поднятием версий зависимости.
    4. Ситуация, когда пакет не имел последнюю актуальную версию зависимости была нормой. Плюс, к этому моменту уже создавался пакет 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. Такие кейсы хотелось бы научиться отлавливать на уровне пул-реквеста, а не спустя какое-то время после мержа кода в мастер.

    Авито
    У нас живут ваши объявления

    Комментарии 8

      0

      Монорепы шагают по планете. Вы на Bazel не смотрели? Если смотрели, то какие плюсы, минусы, подводные камни? С NPM он работает уже достаточно хорошо и умеет не перезапускать незатронутые изменениями тесты, не говоря уже о большей универсальности относительно количества поддерживаемых языков и пакетных репозиториев.
      И ещё, как вы поднимаете версии зависимостей, когда новая версия ломает тесты во многих местах в монорепе? Чинит тесты и код тот, кому нужно версию поднять или созывается весь колхоз и каждый в своей зоне ответственности работает?

        0
        Спасибо за вопросы. Bazel мы не рассматривали как инструмент. Сам о таком инструменте не слышал. Но поизучав документацию, понял, что он не подходит для решения наших проблем. Bazel и Lerna по-разному работают с зависимостями. Lerna создает связь между пакетами (на подобии npm link), благодаря чему изменения, внесенные в зависимом пакете, сразу видны при разработке пакета. Эти изменения могут быть как исполняемым кодом (тогда при разработке сразу будут видны все изменения), а могут быть и изменением версии пакета. Благодаря этому, Lerna позволяет проводить процесс синхронизации зависимостей без участия разработчика по всему дереву зависимостей. Bazel же работает по большей части с node_modules папкой и исходниками, что в ней хранятся. При этом ссылочных связей между пакетами не создается. Bazel позволит решить проблему с неявным описанием зависимостей, но, в отличие от Lerna, не позволит решить проблему с синхронизацией, так как она все еще остается на плечах разработчика. Плюс для Bazel нужна более сложная инфраструктура, чтобы он использовал свои преимущества по полной.

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

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


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


          А с вашим случаем помогите до конца разобраться, потому что не понял в чем описанная проблема.
          Сразу скажу, что в Lerna не шарю, но про Bazel могу сказать, что он совершенно точно синхронизирует все зависимости без участия разработчика. Если на пальцах:
          1) Вы подсовываете Bazel один package.json или вообще lock-файл, в котором описаны все внешние зависимости монорепы, дальше происходит магия и внешние зависимости становятся доступны системе сборки как если бы они лежали локально в монорепе. После этого вы начинаете жить в герметичном мире, где все внешние пакеты нужных версий зафиксированы и живут рядом с вашими внутренними пакетами.
          2) Ваши внутренние пакеты описываются BUILD.bazel файлами. В них указываются зависимости внутреннего пакета от других пакетов, внутренних и импортированных в п.1 внешних. Цель сборки в синтаксисе Bazel выглядит как-то так, немного упрощенно:


          js_library(
              name = "my-shiny-component",
              srcs = "*.js",
              deps = [
                  "//monorepa/components/another-shiny-component",
                  "//monorepa/components/not-so-shiny-one",
                  "@npm//sass-loader",
                  "@npm//vue",
              ],
          )

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

            0
            Ответил отдельным комментарием, так как ошибся в способе добавления комментария.
        0
        Мы не используем практику коллаборативных пул-реквестов. Для понимания масштаба, наш монорепозиторий сейчас насчитывает 37 npm-пакетов. У нас бывают два типа обновлений зависимостей: обновление зависимости, которая лежит внутри монорепозитория, и внешняя для монорепозитория. В первом случае мы поддерживаем актуальные версии зависимостей внутри монорепозитория, то есть во всех пакетах latest-версии всех внутренних к монорепозиторию зависимостей (это еще нужно, чтобы Lerna работала с пакетами). Поэтому тесты если ломаются, то уже из-за изменений, которые внес разработчик. В таком случае разработчик либо правит код, либо правит тест. Внешние зависимости обновляются чаще всего нашей командой и в случае поломок при обновлении, сами правим тесты и код.

        Если я правильно понял, то Bazel — это инструмент для синхронизации и сборки проектов, и работает Bazel в основном как раз с собранными проектом. Сборка проектов в нашем репозитории идет от внутреннего инструмента для бойлерплейтинга (вот здесь можно подробнее о нем узнать ) и этот инструмент учитывает особенности сборки основного монолита. Поэтому нам нужен был инструмент только для синхронизации версий. Lerna работает таким образом, что зависимости внешние могут быть вынесены на корневой уровень монорепозитория, а внутренние зависимости связаны через ссылки. Благодаря такому механизму нам удалось в какой-то момент избавиться от package-lock файлов в наших пакетах, оставив только один lock файл в корне. Плюс, кроме синхронизации, Lerna позволяет автоматизировать работу именно с изменением версий пакетов. Так, к примеру, с помощью двух команд можно узнать какие пакеты были изменены, где и как необходимо изменить версии (поднять патч, минорную или мажорную версию), а затем опубликовать пакеты. И в данном случае синхронизация позволила решить проблему построения явного дерева зависимостей, которое может быть автоматически измененно, а автоматизация упростила работу с монорепозитрием, сведя до работы с небольшим набором команд. Если я правильно понимаю, то в Bazel нет механизма для автоматизации работы именно с версиями пакетов. Кроме того, отмечу что наш монорепозитрой нужен для разработки без сборки, так как пакеты из него еще нужны в основной кодовой базе монолита, в которой настроена и работает своя сборка проекта.
          0

          Кажется понял, у вас есть непреодолимая необходимость выкладывать npm-пакеты именно как npm-пакеты, поэтому с базелем будут проблемы — он больше для деплоя жирными монобинарями подходит.
          Спасибо!

          0
          Как с lerna решаете проблему когда нужно сделать minor бамп единственного пакета, а у остальных бампить patch?

          Также, смотрели rushjs? Если да, почему выбрали lerna? Мы думаем сбежать с lerna в rush.
            0
            Мы не разделяем сейчас бампы пакетов. То есть если в каком-то пакете необходимо поднять patch версию, то во всех зависимых пакетах будет поднята patch версия. Это связано с тем, что наш самописный скрипт не умеет разделять версии, и в юните приняли решение, что сейчас нет необходимости в этом.

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

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

          Самое читаемое