Суть нашей работы в Practica.js — выбор правильных библиотек и фреймворков для пользователей. В этой статье мы поделимся соображениями по поводу выбора инструментария для монорепозитория.

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

Эта заметка посвящена бэкенду и Node.js, а также ориентирована на типичные бизнес-решения. Если вы разработчик Google/FB, столкнувшийся с 8000 пакетов — вам нужен специальный инструментарий. Соответственно, монструозные монорепозиторные инструменты, такие как Bazel, остаются за бортом. Мы рассмотрим некоторые популярные монорепозиторные инструменты, включая Turborepo, Nx, PNPM, Yarn/npm workspace и Lerna (хотя она уже не поддерживается, это хорошая точка отсчета для сравнения).

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

Слой 1: Старые добрые папки для всего кода

При отсутствии инструментария и наличии всех микросервисов и библиотек в одной корневой папке разработчик получает полезные фишки в управлении и массу преимуществ: 

  • навигация, 

  • поиск по компонентам, 

  • мгновенное удаление библиотеки,

  • отладка, 

  • быстрое добавление новых компонентов. 

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

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

При этом некоторые новые инструменты дополняют этот опыт интересными фичами:

  • Turborepo и Nx, а также Lerna дают визуальное представление зависимостей пакетов.

  • Nx позволяет использовать «правила видимости», которые помогают определить, кто и что может использовать. Например, библиотека "checkout", к которой должен обращаться только “микросервис заказов» — отклонение от этого правила приведет к ошибке во время разработки (а не во время выполнения).

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

Уровень 2: Задачи и пайплайн для эффективной сборки кода

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

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

Это лишь несколько примеров. Все инструменты для монорепозиториев поддерживают базовую функциональность вызова команды над группой пакетов. Например, Lerna, Nx и Turborepo.

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

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

Если рассмотреть решение с сотнями пакетов, которые преобразуются и связываются в единое целое, то придется ждать несколько минут, пока не запустятся интеграционные тесты. Хотя полагаться на E2E-тесты не всегда хорошо, это тем не менее довольно распространенная практика. Именно здесь и проявляет себя новая волна монорепозиторного инструментария — глубокая оптимизация процесса сборки. Эти инструменты дают прекрасные и инновационные оптимизации сборки:

  • Распараллеливание — если две команды или пакета ортогональны друг другу, то команды будут выполняться в двух разных потоках или процессах. Обычно контроль качества включает в себя тестирование, выкладку, проверку лицензии, проверку CVE — почему бы не распараллелить?

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

Рассмотрим сборку, включающую A, B, C; где A и C зависят от B. Наивно полагать, что система сборки будет ждать, пока соберется B, и только потом запускать тесты A и C. Этот процесс можно оптимизировать, если запускать изолированные юнит-тесты A и C во время сборки B, а не после. 

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

Преимущество современного инструмента перед старой "Лерной". Взято с сайта Turborepo
Преимущество современного инструмента перед старой "Лерной". Взято с сайта Turborepo
  • Определите, что было затронуто изменением — даже в системе с высокой связью между пакетами обычно имеет смысл запускать не все пакеты, а только те, которых изменение затронуло. Что значит «затронуло»? Это пакеты/микросервисы, которые зависят от изменившегося пакета. Некоторые инструменты игнорируют незначительные изменения, которые вряд ли приведут к поломке других пакетов. Это не только повышает производительность, но и является замечательной фичей тестирования — разработчики быстро получают фидбэк о поломке клиентов. И Nx, и Turborepo поддерживают эту фичу. Lerna может определить только то, какой из монорепозиторных пакетов изменился.

  • Подсистемы (т.е. проекты) — современные инструменты могут сформировать взаимосвязанные части графика (проект или приложение), в то время как другие недоступны для компонента в контексте (другой проект), поэтому они знают, что нужно задействовать только пакеты релевантной группы.

  • Кэширование — это серьезный фактор повышения скорости: Nx и Turborepo кэшируют результаты/выводы задач и не запускают их повторно при последующих сборках, если в этом нет необходимости. Например, при длительном выполнении тестов микросервиса при команде на пересборку этого микросервиса инструментарий может понять, что ничего не изменилось, и тест будет пропущен. Для этого генерируется hashmap всех зависимых ресурсов — если ни один из них не изменился, то hashmap будет тем же самым, и задача будет пропущена. Они даже кэшируют stdout команды, так что при выполнении кэшированной версии она ведет себя как настоящая. Запустите 200 тестов, посмотрите все логи тестов, получите результаты через терминал за 200 мс, все выглядит как «настоящее тестирование», в то время как на самом деле тесты вообще не выполнялись, а были в кэше!

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

Уровень 3: Вынесение зависимостей для ускорения установки npm

Описанная выше оптимизация скорости не поможет, если есть узкое место в виде большого комка грязи под названием «установка npm». Возьмем для примера типичный сценарий: десятки компонентов, которые должны быть собраны, могут легко вызвать установку тысяч подзависимостей. Несмотря на то, что они используют довольно схожие зависимости (например, тот же логгер, тот же ORM), если версии зависимостей не совпадают, то npm будет дублировать (проблема двойников NPM) установку этих пакетов, что может вылиться в длительный процесс.

Именно здесь в дело вступает линейка инструментов workspace (например, Yarn workspace, npm workspaces, PNPM), которая вносит некоторую долю оптимизации — вместо установки зависимостей в папку 'NODE_MODULES' каждого компонента она создает одну централизованную папку и связывает все зависимости в ней. Это помогает сократить время установки для огромных проектов. С другой стороны, если вы всегда сосредоточены на одном компоненте, то установка пакетов одного микросервиса/библиотеки не должна вызывать проблем.

И Nx, и Turborepo могут полагаться на менеджер пакетов/рабочее пространство для обеспечения этого уровня оптимизаций. Другими словами, Nx и Turborepo — это уровень над менеджером пакетов, который заботится об оптимизации установки зависимостей.

Вдобавок к этому Nx вводит еще одну нестандартную, может быть, даже спорную технику: в корневой папке всего монорепозитория может быть только ОДИН package.json. По умолчанию, при создании компонентов с помощью Nx, они не будут иметь собственных package.json. Вместо этого все они будут совместно использовать корневой package.json. 

Таким образом, все микросервисы/библиотеки совместно используют зависимости, и время установки сокращается. Примечание: Можно создавать «публикуемые» компоненты, у которых есть package.json, просто это не делается по умолчанию.

Меня беспокоит следующее. Обмен зависимостями между пакетами увеличивает связанность. Что если Microservice1 захочет изменить версию dependency1, а Microservice2 в данный момент не может этого сделать? Кроме того, package.json является частью среды выполнения Node.js, и исключение его из корня компонента лишает нас таких важных фичей, как основное поле package.json или экспорт ESM. 

На прошлой неделе я проводил POC с Nx и столкнулся с проблемой — библиотека B была запакована, я пытался импортировать ее из библиотеки A, но не мог заставить оператор 'import' указать правильное имя пакета. Естественным действием было открыть package.json библиотеки B и проверить имя, но там нет Package.json... Как мне определить его имя? Документация Nx — это здорово, наконец-то я нашел ответ, но пришлось потратить время на изучение нового «фреймворка».

Остановитесь на минуту: все дело в вашем рабочем процессе

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

Рассмотрим следующий пример с тремя компонентами: Library1 вносит серьезные изменения, Microservice1 и Microservice2 зависят от Library1 и должны реагировать на эти изменения. Каким образом?

Вариант A — синхронизированный рабочий процесс — при таком стиле разработки все три компонента будут вместе разрабатываться и развертываться одним куском. Практически, разработчик будет кодировать изменения в Library1, тестировать Library1, а также проводить широкие интеграционные или E2E тесты, включающие Microservice1 и Microservice2. Когда они будут готовы, версии всех компонентов будут повышены. Наконец, они будут развернуты вместе.

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

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

Вот как это происходит: разработчик вносит изменения в Library1, они тщательно тестируются. Как только Library1 готова, SemVer переводится на новую major версию, а библиотека публикуется в реестре менеджера пакетов (например, npm). А что же с клиентскими микросервисами? Ну, команда Microservice2 сейчас очень занята другими приоритетами и пока пропускает это обновление (точно так же, как мы все откладываем многие обновления npm). Однако Microservice1 очень заинтересован в этом изменении. Команда должна проактивно обновить эту зависимость и взять последние изменения, провести тесты и развернуть, когда они будут готовы.

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

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

Синхронизированные и независимые рабочие процессы
Синхронизированные и независимые рабочие процессы

Об иллюзии синхронности

В распределенных системах невозможно добиться 100% синхронности — убеждение в обратном может привести к ошибкам в проектировании. Представим, что в Microservice1 произошло разрушающее изменение, и теперь его клиент — Microservice2 — адаптируется и готов к этому изменению. Эти два микросервиса развертываются вместе, но в силу природы микросервисов и распределенной среды выполнения (например, Kubernetes) развертывание только Microservice1 заканчивается неудачей. Теперь код Microservice2 не согласован с продакшен кодом Microservice1, и мы сталкиваемся с багом в продакшене. С этой чередой сбоев можно в некоторой степени справиться и с помощью синхронизированного рабочего процесса — развертывание должно оркестровать внедрение каждого модуля, чтобы они все развернулись одновременно. Хотя такой подход вполне осуществим, он повышает вероятность крупномасштабного отката и усиливает страх перед развертыванием.

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

Уровень 4: Связывание пакетов для получения немедленного фидбэка

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

Вариант 1: npm. Каждая библиотека является стандартным пакетом npm, и клиент устанавливает ее с помощью стандартных команд npm. Если взять Microservice1 и Library1, то в итоге получится две копии Library1: одна внутри Microservices1/NODE_MODULES (т.е. локальная копия потребляющего Microservice), а вторая — в папке разработки, где команда занимается кодированием Library1.

Вариант 2: Обычная папка. В этом случае Library1 является лишь логическим модулем в папке, который Microservice1,2,3 просто локально импортируют. NPM здесь не задействован, это просто код в выделенной папке. Так, например, представляются модули Nest.js.

При использовании варианта 1 команды получают все преимущества пакетного менеджера — SemVer(!), инструментарий, стандарты и т.д. Однако если обновить Library1, то изменения не будут отражены в Microservice1, поскольку он берет свою копию из реестра npm, а изменения еще не опубликованы. Это фундаментальная проблема монорепозитория и менеджеров пакетов — нельзя просто написать код для нескольких пакетов и протестировать/запустить изменения.

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

Как же принести пользу из обоих миров (предположительно)? С помощью связывания. Lerna, Nx, различные менеджеры пакетов (Yarn, npm и т.д.) позволяют использовать библиотеки npm и при этом связывать клиентов (например, Microservice1) с библиотекой. Под капотом создается символическая ссылка. В режиме разработки изменения распространяются немедленно, в режиме развертывания — копия берется из реестра.

Связывание пакетов в монорепозитории
Связывание пакетов в монорепозитории

Если у вас синхронизированный рабочий процесс, то все готово. Только теперь любое рискованное изменение, вносимое Library3, должно быть СРАЗУ обработано 10 микросервисами, которые ее потребляют.

Если вы отдаете предпочтение независимому рабочему процессу, то это, конечно, вызывает серьезные опасения. Кто-то может назвать этот стиль прямого связывания «монолитом-монорепо» или, возможно, «монолитом». Однако, если не линковать, то сложнее отлаживать небольшие проблемы между микросервисом и библиотекой npm. Обычно я создаю временную связь (с помощью npm link) между пакетами, отлаживаю, кодирую, и наконец, удаляю связь.

У Nx более разрушительный подход — он использует пути TypeScript, чтобы связать компоненты. Когда Microservice1 импортирует Library1, чтобы избежать полного локального пути, он создает TypeScript mapping между именем библиотеки и полным путем. Но подождите, в продакшене нет TypeScript, так как же это может работать? Ну, во время компоновки он создает веб-пакеты и сшивает компоненты вместе. Не самый стандартный способ работы с Node.js.

Заключение: что использовать?

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

Сценарий A — Если ваша архитектура диктует синхронизированный рабочий процесс, в котором все пакеты развертываются вместе или, по крайней мере, разрабатываются совместно, то возникает острая необходимость в сильном инструменте для управления этим взаимодействием и повышения производительности. В этом случае Nx может стать отличным выбором.

Например: 

  • если ваш микросервис должен поддерживать одинаковые версии, 

  • или команда очень маленькая и все компоненты обновляют одни и те же люди, 

  • или модульность основана не на менеджере пакетов, а на модулях, принадлежащих фреймворку (например, Nest.js), 

  • или вы делаете фронтенд, где компоненты по своей природе публикуются вместе, 

  • или ваша стратегия тестирования в основном опирается на E2E — 

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

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

Сценарий B. Если вы придерживаетесь независимого рабочего процесса, в котором каждый пакет разрабатывается, тестируется и развертывается независимо, то нет необходимости иметь инструменты для оркестровки сотен пакетов. Чаще всего в фокусе находится всего один пакет. Это заставляет выбрать более простой и легкий инструмент — Turborepo. При таком подходе монорепозиторий не влияет на архитектуру, а скорее является инструментом для более быстрого выполнения сборки. Одним из конкретных инструментов, поощряющих независимый рабочий процесс, является Bilt от Гила Таяра (Gil Tayar). Он еще не стал достаточно популярным, но может стать совсем скоро, и это отличный источник, чтобы узнать больше о такой философии работы.

В любом сценарии следует обратить внимание на рабочие пространства — если вы сталкиваетесь с проблемами производительности, вызванными установкой пакетов, то различные инструменты рабочих пространств Yarn/npm/PNPM могут значительно минимизировать эти расходы, занимая при этом мало места. При этом, если вы работаете в автономном рабочем процессе, вероятность столкнуться с подобными проблемами меньше. Используйте инструменты, если появится реальная проблема.

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

Бонус: Сравнительная таблица

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

Полную таблицу можно найти на github.

В заключение всех желающих приглашаем на открытое занятие в OTUS 15 ноября. На этой встрече рассмотрим прогрессивный фреймворк для создания масштабируемых и эффективных веб-приложений Nest.js. Записывайтесь на странице онлайн-курса Node.js.

Комментарии (2)


  1. krig
    07.11.2023 11:33

    lerna живее всех живых - https://github.com/lerna/lerna/issues/2703#issuecomment-1146006326 и активно выпускает новые версии, включая мажорный релиз летом


  1. pavelsc
    07.11.2023 11:33

    Если вы разработчик Google/FB, столкнувшийся с 8000 пакетов

    То у вас все плохо с процессами и тимлид не ткнет вас мордочкой в инструкцию, сказав "У нас вот так делают" и вам надо искать на Хабре статью

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

    Само собой, вы же в Гугле работаете, а там только с двузначным IQ разработчики

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

    Обожемой, да всем нас*ать. У вас в Гугле что, на морозе и ветру ждут запуск? )

    то возникает острая необходимость в сильном инструменте для управления этим взаимодействием и повышения производительности

    Как в анекдоте про солдата, который траву косил:

    ФАРУ МНЕ НА ЛОБ, БЛЯ*Ь!!! ФАРУ!!! ЧТОБЫ НОЧЬЮ КОСИЛ!!!

    Не поверите, не возникает такой необходимости у разработчиков.