Skip to main content

Command Palette

Search for a command to run...

Переваги інкрементальної збірки в монорепозиторії

Як Rush допомагає з інкрементальними командами в монорепозиторії?

Updated
8 min read
Переваги інкрементальної збірки в монорепозиторії
E

I write in Ukrainian about DevOps, architecture, and everything that makes code (and teams) flow better. Markdown, diagrams, and real-world messes included.

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

В чому проблема?

На невеликих обсягах коду, проблеми ніякої нема. Викликали компілятор, той же TypeScript, а він вам виплюнув JavaScript кудись в dist й ви поїхали працювати далі. Коли за розмірами цей проєкт невеликий, то й проблем немає — це швидко.

Але довгострокові проєкти живуть достатньо довго, щоб обрости такою кількістю коду, компіляція якого починає займати багато часу. Це впливає на швидкість, з якою розробники отримують відповідь на питання “а чи скомпілювався мій код?”.

Додаємо сюди ще й той факт, що існують інші проєкти, які живуть в тому ж репозиторії. Тобто у вас один репозиторій не просто з одним TypeScript проєктом, а з десятками, а то й сотнями. Тож якщо ви викличете TypeScript компілятор в репозиторії для всіх цих проєктів, то чекати ви будете ще довше.

Так і зʼявляється необхідність надати розробникам можливість збирати лише частину репозиторію, а не весь. От подивимось на це з погляду розробника.

Ви клонуєте репозиторій, в якому працюють декілька команд і там зберігаються сотні окремих TypeScript проєктів. Бібліотеки, застосунки, допоміжні скрипти й так далі. Але працюєте ви зараз лише з одним конкретним застосунком.

Після впровадження вами змін у свій застосунок, ви викликаєте збірку репозиторію, а воно вам починає викликати TypeScript компілятор по всьому репозиторію й ви сидите чекаєте 5 хвилин, хоча вам потрібно було викликати лише збірку вашого застосунку та збірку всіх пакетів, від кого ви залежите, чи від вас. Незручно, правда?

Вітаємо концепцію інкрементальної збірки

Для менеджменту нашого репозиторію ми використовуємо Rush. Одна із цікавих можливостей цього інструменту є те, що ви можете задавати свої власні команди, які потім стають командами самого rush. Ці налаштування живуть в окремому файлі command-line.json, де і відбувається опис команд.

До прикладу, скажімо, що ми хочемо зробити команду build, яка буде викликати скрипти збірки для всіх проєктів в репозиторії. Підемо в command-line.json та додамо нову команду build.

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

{
  "commandKind": "bulk",
  "enableParallelism": true,
  "name": "build"
}

Цим описом ми кажемо Rush, що ми хочемо мати команду з назвою build, виклик якої має викликати скрипт build із package.json всіх проєктів в репозиторії (commandKind зі значенням bulk означає, що команда має викликатись в усіх проєктах). А enableParallelism дозволяє запускати їх паралельно.

Тепер, ми можемо зробити в консолі rush build і отримати запуск всіх build скриптів з усіх package.json файлів усіх проєктів в репозиторії, ще й паралельно. Ну а там зазвичай йде виклик TypeScript компілятора, тому де-факто це саме компіляція.

Але ладно якщо ви вперше склонували репозиторій й вперше викликали rush build. В цьому випадку, логічно було б зібрати всіх, бо ніхто ж не зібраний ще. Проте, я б очікував, що наступного разу, коли я викличу rush build, воно не буде наново викликати TypeScript компілятор, якщо в мене вже є зібраний проєкт, який я не чіпав. В Rush це питання одного прапорця incremental:

{
  "commandKind": "bulk",
  "enableParallelism": true,
+ "incremental": true,
  "name": "build"
}

Додавши всього цей прапорець для нашого build, ми вже маємо повністю інкрементальну команду build. Тепер, якщо я викличу цю команду вперше, то, звісно, буде збиратись весь репозиторій. Проте, якщо я викличу її ще раз, то воно взагалі нічого збирати не буде — змін же не було.

На прикладі нашого репозиторію, з яким ми зараз працюємо, це виглядає так. Перший виклик цієї команди на пустому репозиторії (маю на увазі, що всі кеші холодні), збірка займає приблизно хвилину. Найдовше йдуть проєкти на Next.js чи Svelte.

$ rush build
Rush Multi-Project Build Tool 5.153.2 - https://rushjs.io
Node.js version is 20.19.1 (LTS)

Starting "rush build"
Analyzing repo state... DONE (0.06 seconds)

==[ SUCCESS: 43 operations ]===================================================
rush build (1 minute 2.2 seconds)

Тепер, якщо я одразу ж викликаю rush build ще раз, ми вже маємо зовсім інші числа.

$ rush build
Rush Multi-Project Build Tool 5.153.2 - https://rushjs.io
Node.js version is 20.19.1 (LTS)

Starting "rush build"
Analyzing repo state... DONE (0.06 seconds)

==[ SKIPPED: 43 operations ]===================================================
rush build (0.10 seconds)

Як бачите, Rush взагалі пропустив виклик build скриптів в усіх проєктах, бо, логічно, вони не були змінені, а значить і сенсу їх наново збирати — немає.

Тож, як це взагалі працює?

Як реалізована інкрементальна збірка в Rush?

От уявімо, що станом на зараз в нас холодний репозиторій (таке враження, наче я щойно термін вигадав, якого ніде в книгах не бачив, якісь репозиторії холодні, а не кеші… коротше я знову про те, що репозиторій ще ніколи не збирався).

Ми викликаємо команду rush build і він починає лізти в кожен проєкт в репозиторії та викликати там скрипт build із package.json. Як тільки збірка одного з проєктів завершується, Rush робить доволі класичну річ — бере хеші всіх файлів цього проєкту.

Через те що Rush знає рамки вашого проєкту (ви явно вказуєте шлях до нього при реєстрації), то він знає і хеші яких файлів йому треба дістати. В результаті цієї операції він отримує JSON, де він записує, який файл і який хеш він мав. Це виглядає якось так:

{
  "files": {
    "repo/services/lookout/Dockerfile": "b6bbd8d808a7b0d26692ca8b2324e89e25296f11",
    "repo/services/lookout/eslint.config.js": "c36008e6107cc988c554a8ffd5e8913c796bbe4a",
    "repo/services/lookout/src/App.ts": "1784f1b34f84478a042a757702632f585a8ddc3c",
    // and so on...
  }
}

Окрім цього, він ще додає команду, яка викликалась на цих файлах. Це робиться для того, щоб у випадку зміни скрипту, він викликався, а не пропускався.

{
   "files": {
     "repo/services/lookout/Dockerfile": "b6bbd8d808a7b0d26692ca8b2324e89e25296f11",
     "repo/services/lookout/eslint.config.js": "c36008e6107cc988c554a8ffd5e8913c796bbe4a",
     "repo/services/lookout/src/App.ts": "1784f1b34f84478a042a757702632f585a8ddc3c",
     // and so on...
   }
+  "arguments": "tsc --project tsconfig.build.json"
}

Тепер, Rush знає, що раніше викликалась команда tsc --project tsconfig.build.json на файлах з ось такими хешами. Залишилось лише зберегти цю інформацію в тимчасовій теці, що він і робить — зберігає цей файл у теці з проєктом repo/services/lookout/.rush/temp/package-deps_build.json.

Наступний виклик rush build бачить, що вже є файл зі станом попередньої збірки, тож звіряємось… Якщо цього разу аргументи команди не змінились, тобто все та ж tsc --project tsconfig.build.json й при цьому хеші всіх файлів зараз такі ж самі, як і записані у файл — це значить, що проєкт не змінювався, як і його скрипт збірки. А тому виклик tsc можна повністю пропустити.

Чи обмежені ми де його використовувати?

На моєму досвіді — ні. В результаті, ми зробили три інкрементальні команди build, lint та test. Майже в усіх наших проєктах є TypeScript, тому build це в нас tsc. Аналогічно майже всі використовують ESLint, а тому lint це в нас eslint. Ну і test це в нас jest або mocha на старіших проєктах.

Кожна з них описана в Rush аналогічно до того, як я розповідав вище. Продублюю ще раз, але вже повний опис однієї із таких команд, наприклад lint:

{
  "allowWarningsInSuccessfulBuild": false,
  "commandKind": "bulk",
  "description": "The `rush lint` command typically runs ESLint to check code quality across all packages in parallel. The linting process can be customized in each package's scripts.",
  "disableBuildCache": false,
  "enableParallelism": true,
  "ignoreDependencyOrder": true,
  "ignoreMissingScript": false,
  "incremental": true,
  "name": "lint",
  "safeForSimultaneousRushProcesses": false,
  "summary": "Lints all projects using ESLint.",
  "watchForChanges": false
}

З такою конфігурацією, якщо дуже коротко, поведінка команди rush lint наступна. Викликай lint команду з усіх package.json скриптів. Роби це паралельно на всіх доступних ядрах процесора, ігноруючи топологію залежностей (для аналізу це не потрібно). Якщо автор проєкту не надавав команду lint у своєму package.json - кинь помилку. Ну і, звісно, роби це інкрементально!

Додаємо трохи цукру для розробників

Я врешті додав ще одну команду rush all. Але вона дуже проста за своїм визначенням:

{
  "commandKind": "global",
  "description": "The `rush all` command is a convenience command that runs build, lint, and test in sequence across all packages, ensuring code is built, linted, and tested in one go.",
  "name": "all",
  "safeForSimultaneousRushProcesses": true,
  "shellCommand": "rush build && rush lint && rush test",
  "summary": "Runs build, lint, and test commands for all projects."
}

Тобто тепер, замість того, щоб розробники викликали всі три команди окремо, коли вони хочуть переконатись, що все працює, тепер вони роблять це однією. Зручно ж!

До того, оскільки всі ці три команди мають інкрементальну збірку, команда rush all теж такою можна назвати. І тепер розробник під час своєї роботи, щоразу як йому треба щось зібрати, просто викликає rush all.

Запустімо rush all на холодному репозиторії:

$ rush all
Rush Multi-Project Build Tool 5.153.2 - https://rushjs.io
Node.js version is 20.19.1 (LTS)

Starting "rush all"

Starting "rush build"
Analyzing repo state... DONE (0.06 seconds)
==[ SUCCESS: 43 operations ]===================================================
rush build (1 minute 5.6 seconds)

Starting "rush lint"
Analyzing repo state... DONE (0.07 seconds)
==[ SUCCESS: 43 operations ]===================================================
rush lint (40.22 seconds)

Starting "rush test"
Analyzing repo state... DONE (0.07 seconds)
==[ SUCCESS: 43 operations ]===================================================
rush test (31.00 seconds)

На холодному репозиторії повна збірка всього репозиторію зайняла приблизно дві хвилини. Але наступний виклик цієї ж команди, без змін в проєктах, вже набагато швидший:

$ rush all
Rush Multi-Project Build Tool 5.153.2 - https://rushjs.io
Node.js version is 20.19.1 (LTS)

Starting "rush all"

Starting "rush build"
Analyzing repo state... DONE (0.06 seconds)
==[ SKIPPED: 43 operations ]===================================================
rush build (0.10 seconds)

Starting "rush lint"
Analyzing repo state... DONE (0.07 seconds)
==[ SKIPPED: 43 operations ]===================================================
rush lint (0.09 seconds)

Starting "rush test"
Analyzing repo state... DONE (0.07 seconds)
==[ SKIPPED: 43 operations ]===================================================
rush test (0.09 seconds)

Всього забираючи лише дві секунди й ті, для того, щоб Node.js запустити :)

Наступного разу, коли розробник на повністю зібраному репозиторії, внесе деякі зміни в якісь застосунки чи пакети, то його rush all збере тільки необхідну для цього частку, пропускаючи будь-які процеси повʼязані з кодом, який він не змінив.

Які плюси для розробників?

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

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