Как выстроить удобные процессы в работе с монорепой
Как настроить общее рабочее пространство для команды без запуска сборки в watch-режиме и ожидания старта dev-сервера, чтобы подхватить изменения? Для этого нужно удобным для всех образом настроить переиспользование кода внутри монорепы.
Поможет нам в этом Андрей Кочеров, старший разработчик интерфейсов в Яндекс Такси и техлид фронтэнд команды партнёрских продуктов. Он расскажет как сделать готовый к работе репозиторий сразу после чекаута, на основе методологии Trunk Based Development.
Проекты
Команда Андрея разрабатывает веб-приложения для таксопарков, коллег Яндекса, внешних сотрудников и исполнителей. Изначально она состояла из 5 человек, работающих над одним проектом. Потом выросла до 17 человек и 9 активных проектов с общими библиотеками и инструментами в едином репозитории. Причём речь идёт о раздельных приложениях, а не о частях одного и того же. То есть это отдельные продукты для разных пользователей.
Самый первый проект стал самым сложным. Он представляет собой диспетчерскую, в которой таксопарки могли управлять данными о своих водителях, машинах, заказах, отчётах, визуализацию, статистику и прочее. Со временем этот проект сильно вырос и перестал помещаться в одно приложение как концептуально, так и технически. Сценариев стало слишком много, некоторые из них были взаимоисключающими. Поэтому проект разделили на два независимых приложения с переиспользованием общих частей, вплоть до целых разделов, которые подключались из общей библиотеки. Параллельно появлялись новые проекты. Например, приложение для всевозможных сотрудников, работающих по сдельной схеме оплаты, где они могут управлять своим рабочим временем и получать всю нужную информацию.
Между ними нужно было переиспользовать не только общую часть, как в случае с диспетчерской, но и в целом наработки, библиотеки и инструменты. Например, дизайн-систему, систему локализации, инструменты деплоя и прочее.
Цель
Чтобы успешно разрабатывать несколько проектов одновременно, разработчикам нужно уметь эффективно запускать новые проекты, масштабировать существующие и переключаться между ними. Конечно, переключение контекста — это дорогостоящая операция, и команда не ожидает, что разработчик будет менять проект каждый день или неделю. Но они могут снизить накладные расходы с помощью удобных процессов и инструментов. Тогда в идеальной ситуации переключение между проектами будет мало чем отличаться от перехода от одной продуктовой задачи к другой в рамках одного проекта.
Очевидно, между проектами нужно легко уметь переиспользовать общие решения, но за это хотелось бы не платить слишком высокую цену. Если переключиться с работы над продуктовой задачей на разработку общего решения будет слишком сложно, такие решения будут появляться и развиваться редко. Если же использовать такое решение в своём проекте будет значительно сложнее, чем локальное, то использоваться они будут не везде и не смогут исключить дублирование. В целом эта задача уже привычно решается с помощью монорепозитория. Речь идёт, конечно, не просто о коде, размещённом в одном месте, но и об инструментах и процессах для удобной работы с этим кодом.
Андрей поделится опытом настройки общего репозитория с помощью следующих инструментов:
Это достаточно стандартные инструменты. Хоть некоторые из них ещё и не очень широко используются. В первую очередь важен их совокупный эффект.
Trunk Based Development
В команде партнёрских продуктов Яндекс Такси используют методологию Trunk Based Development для того, чтобы черпать из неё основные принципы работы с монорепозиторием. Она описывает опыт успешных в этом смысле команд, но разберём по порядку.
Trunk — это общая для всех и единственная долгоживущая ветка.
Это значит, что не будет разделения на, например, master и develop, как это делается в Gitflow. Не будет постоянных мержей одной ветки в другую без закрытия одной из них. Вместо этого любая работа начинается от вершины trunk’а и вливается назад в trunk. Даже релизные ветки отводятся только по необходимости прямо из trunk’а до коммита с прошлым релизом, и служат только для того, чтобы собрать коммиты, нужные для стабилизации следующего релиза. Причём они никогда не вливаются назад.
Таким образом, Trunk — это единая точка синхронизации для всех разработчиков в команде и единственный источник истины как для исходного кода, так и для любой сопровождающей его конфигурации. Все видят одну и ту же версию кода с минимальной задержкой. Чтобы эту минимальную задержку обеспечить, нужно, чтобы пулл-реквесты, вносящие изменения в trunk, были частыми и маленькими. Это не значит, что нужно вводить жёсткие ограничения на количество изменённых строк или файлов. Главное, чтобы этот пулл-реквест помещался в голову ревьювера. В идеальной ситуации ревьювер должен понимать, что происходит в пулл-реквесте ещё до того, как начнёт смотреть код. Для этого важно сопровождать пулл-реквест понятным описанием. В таком случае ревью можно проводить одновременно быстро и качественно, получая тем самым нужную скорость вливания в trunk.
Целая фича добавляется по частям в несколько пулл-реквестов, а чтобы частично реализованные фичи не попадали в продакшен, они скрываются за релизным флагом. Такой флаг изначально может быть включен только локально или в тестовом окружении и уже только потом по готовности (после тестирования) в продакшн.
Общие части подключаются прямо из исходного кода, из trunk’а. Это важно для того, чтобы эффективно их переиспользовать без дополнительных накладных расходов на публикацию, синхронизацию и прочее.
Чтобы устанавливать внешние зависимости и оркестировать локальные workspace’ы, команда использует Yarn Modern.
Оркестрация: Yarn Modern
Сейчас актуальна третья версия. Yarn1 — называется Yarn Classic. Во вторую версию внесены значительные изменения.
В целом это не только пакетный менеджер, но менеджер проекта, репозитория. Он предоставляет всё, что нужно, для того чтобы ими управлять. Поэтому дополнительные инструменты не нужны.
Чтобы использовать новый Yarn, достаточно установить любую версию, в том числе и первую. При этом локально будет использоваться нужная, сохранённая рядом с вашим кодом. Устанавливается и обновляется она с помощью встроенной команды:
Yarn также предоставляет встроенную возможность работать с workspace’ами:
Workspace’ы в Yarn могут использовать специальный протокол для указания зависимостей:
Это гарантирует, что зависимость всегда подключается из локальной директории, даже если она опубликована в удалённом реестре. Таким образом, еще можно легко различать внешние и локальные зависимости.
Команды
Yarn также предоставляет набор команд в контексте workspace’а, которые можно выполнить из любой рабочей директории в проекте.
Рассмотрим самые полезные из них:
→ yarn workspace
Позволяет выполнить любую команду в нужном workspace из любой рабочей директории в рамках проекта. Это удобно для автоматизации и написания скриптов, чтобы не прыгать по директориям.
Здесь в примере мы, находясь в корневой директории проекта, указываем имя workspace’ом и запускаем команду «добавление новой зависимости».
→ yarn workspace foreach
Позволяет выполнить любую команду в контексте всех нужных workspace’ов с учётом фильтрации, которую можно указать последовательно или параллельно с учётом структуры зависимостей.
Рассмотрим на примере.
Здесь мы, находясь в конкретном workspace «app», запускаем команду build. При это мы исключаем один определённый workspace с помощью флага --exclude. Есть и другие флаги:
--recursive, значит, что помимо настоящего workspace’а команда запускается во всех его зависимостях;
--topological означает, что команда сначала завершится выполнением во всех зависимостях, а уже потом начнёт выполняться в самом workspace.
--parallel позвляет запускать команду в воркспейсах параллельно, но с сохранением порядка, заданного с помощью флагов выше.
С помощью этих команд также можно использовать инструменты, не имеющие встроенной поддержки монорепозитория в контексте workspace’ов. Нет необходимости использовать специальную систему сборки для того, чтобы собирать workspace’ы в нужном порядке.
Плагины
Yarn поддерживает плагины. Они могут добавлять новые команды с доступом к структуре зависимостей проекта, что позволяет делать полезные вещи, не дублируя логику самого пакетного менеджера. Также они могут расширять поведение Yarn с помощью хуков. Например, есть плагин TypeScript в Yarn:
@yarnpkg/plugin-typescript
Он автоматически добавляет пакет с тайпингами в ваши зависимости, которые не поставляют тайпинги внутри себя.
А вот так выглядит API написания плагинов:
Это фабричная функция, которая возвращает объект. Он может указывать команды. Эти команды должны быть реализованы с помощью CLI-библиотеки clipanion, которая используется в самом Yarn.
Также можно возвращать хуки.
В этом примере из документации производится подсчёт количества уникальных пакетов, а также их дискрипторов — имя пакета с его версией и указанием на зависимости во всем проекте. Он выводит их после каждой установки с помощью другого хука: afterAllInstalled.
Plug’n’Play
Но самая главная особенность Yarn — это новый механизм Plug’n’Play, или pnp. Он реализует строгий алгоритм разрешения зависимостей и возможность установки без использования директории node_modules. Работает это так:
Зависимости сохраняются в директорию .yarn/cache в виде zip-архивов. По одному архиву на пакет;
В специальном файле сохраняется карта всех зависимостей с точными версиями по всем пакетам в проекте, то есть по workspace’ам и их зависимостям;
При старте NodeJS инжектится загрузчик модулей, который позволяет работать с этим кэшем.
В результате разрешение зависимостей становится полностью детерминистичным. Каждый пакет имеет доступ к только к тому, что явно указывает в своём манифесте. Это значит, что не будет больше фантомных зависимостей.
Фантомные зависимости — это пакеты, которые каким-то образом появляются в node_modules, хотя они не были указаны в зависимостях. Например, это может быть транзитивная зависимость. Это опасно, потому что этот пакет может в любой момент исчезнуть.
Рассмотрим на примере. Допустим, вы используете пакет из is-even, который проверяет на чётность. У него есть одна зависимость — is-odd. Когда вы его устанавливаете, у вас появляется в node_modules сразу оба этих пакета. То есть ваш код может начать использовать is-odd напрямую и всё будет работать.
Представим, что разработчики решили в следующей версии дропнуть зависимости и всё упростить. Им не нужно будет даже выпускать новую мажорную версию, так как изменение внутренних зависимостей не вносит обратно несовместимых изменений. К вам просто придёт новая версия пакета, в которой уже нет is-odd в node_modules. В результате код ломается. Pnp позволяет такие проблемы обнаруживать сразу.
Также поскольку нет node_modules, нет этой вложенной структуры, в которой нужно в файловой системе дублировать как весь список пакетов, так и всю структуру, получается достаточно аккуратный список архивов.
В отличие от node_modules этот кэш с архивами можно без проблем закоммитить прямо в репозиторий. Так осуществляется концепция zero-installs.
Это значит, что проект будет готов к работе сразу после чекаута без необходимости устанавливать зависимости. Даже если вы запустите команду install, она выполнится моментально, потому что ей просто нечего будет делать.
Это особенно полезно при частом переключении между ветками, в которых зависимости могут меняться, что сильно упрощает работу с маленькими и частыми пулл-реквестами. Дополнительно, не понадобится, например, отделение кэш зависимостей в CI, потому что актуальные зависимости всегда приходят вместе с кодом.
Из-за строгости этого алгоритма, некоторые пакеты, которые всё-таки полагаются на фантомные зависимости, могут ломаться. Если вы с такой проблемой сталкиваетесь, лучше сообщить мейнтейнером, потому что это ошибка в зависимостях. Понятно, что не всегда можно быстро пофиксить проблему на стороне пакета. На такой случай Yarn предлагает механизм, позволяющий добавить недостающие зависимости в проблемный пакет прямо на уровне вашего проекта через конфигурационный файл .yarnrc.yml с помощью поля packageExtensions. Известные поломки в популярных пакетах чинятся автоматически с помощью встроенного плагина plugin-compat. Для этого ничего делать не нужно. И обычно проблем не возникает, то есть довольно редко приходится фиксить.
Общий редактор кода
Все в команде партнёрских продуктов используют Visual Studio Code. Это удобно для того, чтобы share’ить настроенное окружение для работы вместе с самим проектом. Чтобы использовать это вместе с Yarn PnP, нужно сгенерировать слой совместимости, то есть конфигурацию для редактора для работы с зависимостями в архивах. Делается это с помощью поставляемой с Yarn утилиты:
Результат точно так же можно закоммитить в репозиторий, чтобы другим разработчикам в команде не приходилось это делать. Можно опять же сразу приступить к работе после чекаута. Чтобы заглянуть в эти архивы есть расширение:
Кроме этого, в репозиторий коммитятся и общие настройки, полезные для всей команды, для visual studio code и список рекомендуемых для удобной работы с проектом расширений. При первом открытии проекта разработчик увидит уведомление о том, что в этом проекте используются такие расширения, и сможет их установить одной кнопкой. В итоге получается не только готовый к работе проект после чекаута, но ещё и настроенное окружение.
Dev-сервер на Vite
Чтобы приступить к работе, разработчику остаётся только запустить dev-сервер. В команде партнёрских продуктов для этого используется Vite, который переводится с французского как «быстрый». Сам Эван Ю. — создатель данного проекта — назвал его фронтэнд туллингом нового поколения.
Его новшество заключается в том, что он ничего не бандлит в dev-режиме. Поэтому dev-сервер может стартовать моментально. Вместо классического механизма бандлинга каждый модуль трансформируется по требованию с помощью ESBuild. Это JavaScript и TypeScript трансформер, написанный на Go. Он компилируется в нативный код и работает значительно быстрее, чем стандартный компилятор TypeScript.
Этот подход, конечно, не серебряная пуля, есть trade-off’ы. В частности, если у вас очень много модулей и все они нужны для того, чтобы отрисовать страницу, первая загрузка после старта dev-сервера может быть непривычно долгой. Скорее всего, быстрее, чем потребовалось бы бандлеру для сборки этого кода. Так что выигрыш всё равно есть. Тут больше дело привычки. По опыту разработчиков в большом проекте, где нужно было загрузить 2000 модулей, чтобы что-то нарисовалось на экране в dev-режиме, требовалось до пяти секунд.
После этого уже перезагрузка страницы и обновление без перезагрузки страницы работают без таких задержек. Это только после старта dev-сервера.
Vite — это не только dev-сервер, но и бандлер на основе Rollup. Он поддерживает настройку и расширение с помощью плагинов одновременно и для dev-сервера, и для продакшн сборки. Система плагинов тоже основана на Rollup, поэтому Vite, по большей части совместим с существующими Rollup-плагинами. В целом документация рекомендует использовать Rollup-плагины, если они уже есть, а не делать специфичные для Vite.
Поскольку API у Rollup вполне удачный, использовать, настраивать и расширять Vite очень легко. В сравнении с печально известным в этом плане Webpack.
Команда Vite поддерживает плагины для использования своего инструмента популярными фреймворками. Например, плагин @vitejs/plugin-react добавляет Vite в поддержку JSX. По умолчанию JSX не обрабатывается там, потому что он нужен не всем. В том числе, автоматически JSX runtime, который позволяет, чтобы не импортировать React для JSX в каждом файле. Этот плагин также интегрирует React Fast Refresh во встроенный Vite механизм горячей замены модулей (HMR). React Fast Refresh позволяет обновлять исходный код без перезагрузки страницы и даже без потери локального состояния компонентов, в том числе введённого в input на странице значения. Это тоже работает моментально и не замедляется с ростом проекта из-за использованного подхода. Vite имеет встроенную поддержку workspace’ов и pnp, то есть он будет работать со схемой монорепозитория из этой статьи.
Поддержка workspace’ов означает, что код из локальной зависимости обрабатывается точно так же, как исходный код самого приложения. Это также включает и горячую замену модулей. Если у вас есть переиспользуемая React библиотека, то при изменении кода в ней, обновления точно так же будут доставляться в браузер. Это довольно сильно облегчает работу над общими библиотеками, устраняет ненужное отвлечение.
Vite умеет загружать переменное окружение из env-файлов.
Причём поддерживается специальный флаг, который позволяет задать суффикс для этих файлов. С помощью них можно легко:
Завести файлы с нужными суффиксами, которые будут являться профилями конфигурации;
Использовать переменные окружения для того, чтобы выбрать нужный код;
Запустить Vite с указанием флага, чтобы в итоговую сборку попал только нужный код.
Таким образом, мёртвый, ненужный, код, скрытый всегда за ложным условием, просто вырезается.
Скорее всего, рано или поздно потребуется более сложная система управления флагами, если вы практикуете trunk based development. Например, возможность их включать динамически без сборки и перевыкатки приложения. Но этот подход позволит покрыть довольно много кейсов вообще без вложений.
Наконец, Vite может собирать не только приложения, но и библиотеки в разных форматах, в том числе сразу в нескольких.
Это полезно, если вам нужно опубликовать пакет сразу в ESM и CommonJS без необходимости поддерживать альтернативную систему сборки.
Форматирование
Как только dev-сервер и сборка заработали, нужно перейти к инструментам обеспечения качества кода. Начнём с простого.
Prettier
Prettier отлично интегрируется в редактор. Его можно отдельно запустить в CI для проверки форматирования, и он в целом снимает все вопросы по оформлению кода.
По опыту разработчиков, проще использовать Prettier как отдельный инструмент, а не интегрировать его в линтер. Иначе придётся синхронизировать конфликтующие правила — размер отступа, расстановка отступов и прочее.
Зато настраивать Prettier, скорее всего, не придётся. Он предоставляет не так много опций. Если уж вы используете Prettier, то можно использовать дефолты. Однако потребуется общий .prettierigore в корневой директории, чтобы не форматировать код, который автогенерируется и прочее.
И интересный момент. Prettier умеет загружать настройки из файлов .editorconfig. Такой файл может просто случайно оказаться где-то выше по иерархии директорий над вашим проектом, в результате получится, что Prettier отформатирует иначе. Чтобы это исключить, можно добавить рядом в ваш проект .editorconfig с единственной директивой — root=true. Тогда всё будет работать предсказуемо.
Стоит также включить автоматическое форматирование при сохранении в редакторе кода. Это важный момент, потому что он позволяет реально забыть о том, что код форматируется.
TypeScript
Важно, чтобы во всём репозитории использовалась одна и та же версия TypeScript. Это нужно для того, чтобы работа с зависимостями по исходному коду была консистентна. Они будут обрабатываться одним и тем же компилятором, поэтому должны работать на одной и той же версии. Также нужно открывать весь проект в редакторе кода для удобного переключения между частями.
Одно дело — версия языка, другое дело — конкретные настройки компилятора. В команде партнёрских продуктов есть код, который выполняется в браузере, и код — в NodeJS. Для них нужны отдельные настройки, чтобы они не смешивались в одну кучу. Для этого разработчики используют композитные проекты, известные, как project references. Они позволяют изолировать части кодовой базы друг от друга и использовать в них разные настройки. Потом они свяжут их в единое целое для того, чтобы компилятор и редактор кода могли их найти, даже если конфиги названы нестандартно. Также они позволяют компилировать в общей части только один раз. Это увеличивает производительность как при использовании компилятора напрямую, так и в редакторе кода. Если у вас проект растёт, но он разбит на подпроекты с помощью project references вместо того, чтобы сваливать все в один, то замедления вы особо не почувствуете. Потому что они загружаются в редактор по требованию.
Для использования каждый такой подпроект должен указывать свои зависимости в поле references.
Это, по сути, пути tsconfig’ов.
Важно включить опцию composite и генерацию декларации типов. Без этого сборка композитного проекта работать не будет, потому что нужно уметь переиспользовать общие части результата компиляции. При этом генерировать JS необязательно. Можно просто генерировать тайпинги. Сборка композитного проекта производится с помощью флага --build.
ESLint
ESLint, как в Prettier, имеет .eslintignore, который нужно разместить в руте по тем же самым причинам. Там же удобно разместить конфигурационный файл, в котором можно подключить плагины для всего проекта, потому что вы, скорее всего, будете использовать одни и те же везде.
Также нужно написать общие настройки. Разработчики не используют сторонние конфиги типа airbnb и прочие. Они формируют свой собственный только с нужными правилами. Эта практика полезна для того, чтобы лучше контролировать кодстайл и не нагружать линтер ненужной работой. Он и так достаточно медленный.
Кроме этого, разработчики не включают стилистические правила, потому что за это отвечает Prettier.
Поддержка TypeScript в ESLint добавляет проект typescript-eslint. Он поставляет парсер, который добавляет поддержку синтаксиса, и плагин, который расширяет встроенные стандартные правила для работы с новыми языковыми конструкциями. Дополнительно он предоставляет свой собственный набор правил, в том числе с доступом к информации о типах. Эти правила могут быть более полезны, могут находить больше ошибок.
К сожалению, такие правила ещё и более медленные, потому что для того, чтобы для них собрать информацию о типах, нужно, по сути, запустить TypeScript.
Настраивается TypeScript примерно так:
Подключается плагин, настраивается парсер. Для этого нужно указать пути для всех tsconfig’ов, которые используются. Тут поддержка project references не идеальная, поэтому нужно указать действительно все. Он внутри не найдёт по no references связанные проекты. Также нужно указывать экспериментальную опцию для того, чтобы project references работали консистентно, то есть вне зависимости от того, собран ли проект или нет.
В целом фича слишком полезная для того, чтобы её не использовать.
Тестирование
Наконец, осталось настроить инструменты тестирования. Изначально разработчики использовали Jest.
Он гибко настраивается с помощью кастомных раннеров, окружений и трансформеров кода. Но в нём нет ничего особенного. Стоит только сказать, что Jest нужно конфигурировать отдельно, частично дублируя конфигурацию сборки. Частично в этом помогают кастомные трансформеры — vite-jest и esbuild-jest. Но они давно не обновлялись.
Поэтому разработчики в последнее время стали использовать Vitest.
Это фреймворк для тестирования на основе Vite. Он переиспользует конфигурацию Vite. Поэтому если вы используете проект, то у вас уже всё настроено. Он добавляет в существующий конфиг, расширяет его специфичными для тестирования настройками. Если вам нужно, вы их донастраиваете.
В целом несмотря на раннюю стадию разработки, это уже очень удобный инструмент как альтернатива для jsdom- и node-тестов для Jest. В таком сценарии делать вообще ничего не нужно. Вы пишете тест и всё работает.
Есть примеры end-to-end тестирования с использованием puppeteer и playwright. Их можно найти в репозитории Vitest на Гитхабе.
Итоги
В итоге получается проект, который готов к работе сразу после чекаута. Разработчик может закончить работу над одной фичей, получить свежие изменения и тут же начать работу над следующей. При этом нет необходимости устанавливать зависимости и вручную что-то синхронизировать, запускать dev-сервер. Переключения происходят за секунды.
Помимо этого, проект имеет общие для всех настройки. Поэтому стираются технические границы между проектами. Это позволяет команде эффективно коллаборировать над общими решениями и разрабатывать их без помех, в том числе прямо в ходе работы над продуктовыми задачами. То есть нет необходимости набирать специальную команду для разработки общих библиотек и инструментов.
Вот демо-репозиторий проекта.