В попередньому дописі я розповів, що почав заново займатись своїм пет-проєктом codata. Суть проєкту — перевести код в дані і в подальшому полегшити аналіз цих даних.
В цьому ж дописі розповім детальніше про:
- початковий сетап репозиторія;
- чому я віддаю перевагу монорепозиторію і pnpm;
Монорепозиторій. Навіщо ускладнювати?
Спочатку може виникнути питання, а навіщо так ускладнювати? Чому просто не створити проєкт-бібліотеку, чи якусь SPA-шку? Коротка відповідь — це зручніше для мене. Довша відповідь складається з ряду причин, про них нижче.
Простір для експериментів
В рамках монорепи я можу створювати, яку завгодно кількість різнотипних проєктів, ніяк себе не обмежуючи. Якщо хочу SPA — будь ласка, хочу бібліотеку, зібрану за допомогою Vite чи Webpack — будь ласка, хочу окремий проєкт з E2E тестами — на здоров’я.
В пет-проєкті критично важливі експерименти, їх кількість і швидкість реалізації. І круто, коли ваша ж система не обмежує в цьому.
Один пакет — одна відповідальність
Ще з часу розробки на .Net/C#, маю звичку розділяти код по певним зонам і яке ж було моє здивування, що в світі JS, цю історію не так і просто реалізувати.
Уявимо, що у нас проєкт з якоюсь кількість неструкторованого коду. Його важко підтримувати, важко розробляти новий функціонал і взагалі хочеться закрити IDE-шку, щоб тільки не бачити цей код. Ми збираємо усю волю в кулак і вирішуємо його, якось структурувати. По шарам, по доменним зонам чи ще якось — не так важливо.
Самі по собі елементи коду несуть мало цінності, потрібно, щоб вони якось взаємодіяли між собою. І ми б хотіли, щоб наші “модулі” могли спілкуватись тільки через певний публічний API.
Круто, якщо ми все грамотно спроектували і зробили, то тепер зміни проходитимуть легше. Сміливо копошимося в рамках “модуля” і якщо його API не змінюється, то з більшою впененістю можемо сказати, що і решта системи не деградує скоріш за все.
Але, що заважає нам напряму імпортувати код з модуля в модуль?
Ну подумаєш, нічого ж страшного не сталось, все одно код гарно структурований і від двох прямих імпортів нічого не буде. Але це поки…
З мого досвіду, якщо тобі нічого не заважає робити неправильно, то ти будеш робити неправильно. І з часом від “модулів”, які ми старались розділити буде мало толку.
Чи тут може врятувати тільки монорепозиторій — ні, або точніше не на 100%. Але він точно створить більше перешкод для неправильних або ж нечітких зв’язків.
Контрольовані обмеження
Правда сучасної розробки в тому, що у вас немає великої кількості часу, щоб якісно задизайнити свою систему і тільки після цього переходити до реалізації. Скоріш за все, ви будете, щось малювати на аркуші паперу, далі експериментувати в коді і знову повторювати цей процес. І оскільки ми недостатньо часу витрачаємо на етап “подумати”, то потім виявляється, що щось прогавили чи не врахували, десь помилились, коротше налажали. І я переконаний, що не потрібно присипати собі голову попелом, називаючи себе поганим розробником. Потрібно змирити з цією реальністю і краще придумати превентивні механізми, які будуть вберігати нашу систему від найбільшого її ворога — розробника.
..бахнути малюнок. На одній частині “подумали — зробили”, на іншій “подумали — зробили — подумали — викинули — переробили”.
І так, монорепозиторій в цьому плані, by default, дає такий механізм. А саме на рівні вашої бібліотеки ви точно знаєте, які саме сторонні залежності ви можете використовувати. Це може бути інший пакет в рамках репозиторія, або ж стороння бібліотека.
Але в будь-якому випадку, щоб її використати ви зобов’язані додати цю залежність в package.json вашої бібліотеки, інакше нічого не спрацює. Тут важливо, що використовуємо саме pnpm
і нижче розповім чому.
І саме цей невеличкий факт вже вберігає нас від необдуманих залежностей. Якби це був звичайний проєкт на Nuxt і ми б потребували в нашій функції, для прикладу, змінити cookie на нашому сайті, ми б без докору сумлінь імпортнули композабл useCookie, і зробили б свої справи.
У монорепозиторії ж наша функція лежить в окремому пакеті і ми звичайно спробуємо також імпортувати useCookie. Але тут виявимо, що не можемо цього зробити, оскільки пакет взагалі нічого не знає про Nuxt. В цей момент нас осяює і ми згадуємо, що два тижні назад ми і задумували цей пакет незалежним від фреймворка. Саме подібні ситуації зі мною постійно стаються, коли я повертаюсь до розробки після певної перерви.
По суті сама ж система б’є нас по рукам, якщо ми робимо щось не так і це чудово ☺️
Остання філософська думка
Плюси, які я вище описав, доступні тільки при умові, якщо ви правильно “приготували” монорепозиторій 🫠
В цьому якраз вбачаю основний недолік підходу з монорепою — її важко зробити “нормально” і тим більше “добре”. Якщо ви затягнете цей підхід на проєкт і для вас, і команди це буде перший такий досвід, то з високою ймовірністю ви пошкодуєте про своє рішення.
У цьому весь фронтенд — 100500 способів зробити одне і теж, а тільки декілька з цих способів справді хороші.
Package-based репозиторій
Виділяють два основні підходи до організації монорепозиторія: integrated repos і package-based repos.
Більше деталей можна прочитати в доці nx. Я ж хочу зосередитися на package-based підході. А що стосується integrated, то я не розумію його плюсів, чому і в яких випадках його варто використовувати. На мою думку, простіше і ефективніше — це використання полірепозиторія, де є тільки один проєкт.
Структура
Базова структура нашого репозиторія — дві директорії:
В apps
ми тримаємо всі застосунки. Для прикладу, web застосунок, або CLI тула.
В packages
тримаємо усі пакети, в моєму випадку - це TypeScript-бібліотеки.
Структура пакета
Спускаємось на рівень конкретного пакети і розглянемо, як він може виглядати з середини:
- директорія
/src
- містить основний код пакета. Структура всередині залежить від фантазії розробника; - директорія
/node_modules
- містить усі залежні бібліотеки. Детальніше поговоримо про це нижче; - файл
index.ts
- точка входу для нашого білда на vite; - файл
vite.config.ts
- vite конфіг; - файл
package.json
- конфігураційний файл пакета;
Припустимо, що якимось магічним чином ми налаштували білд цього пакета, що ж буде в результаті? Насправді не багато. Утворилась нова директорія /dist
, в ній лежатиме js-файл index.js
, який буде містити усю логіку пакета:
Верхньорівнево зі структурою пакета розібрались, тепер спробуємо використати один пакет в іншому.
Залежність
Для початку глянемо, як це виглядатиме в файловій системі:
І нехай залежність виглядає наступним чином:
В pkg1
маємо функцію bar
, яка знаходиться в однойменному файлі. Ми хочемо викликати функцію foo
, яка знаходиться в пакеті pkg2
.
Як ще це працює під капотом?
Спочатку подивимось, що відбувається в pkg2
:
- Точкою входу для білда є
/pkg2/index.ts
, то в цьому файлі потрібно імпортувати функціюfoo
, а потім експортувати її. Якщо простіше, то зробити re-export. - Після цього запускаємо білд пакета і отримуємо
/pkg2/dist/index.js
. Цей файл міститеме валідний JS-код зі всією логікою пакета. - І останній штрих. Потрібно десь вказати, що імпорт з
pkg2
це насправді файл/pkg2/dist/index.js
. Це можна зробити вpackage.json
.
Тепер процес імпорту виглядатиме так:
Підсумок
Описаний вище кейс ілюструє усю суть package-base підходу:
- ми не імпортуємо функції з пакетів на пряму;
- все проходить через конфігурацію в
package.json
; - маємо змогу отримати доступ тільки до того функціоналу, який експортований в
./dist/index.js
і не більше;
З іншого боку розробнику нічого не заважає зробити такий експорт: pkg2/src/foo
.
Але:
- встроєне в IDE автодоповнення (auto-completion) достатньо рідко робить такі імпорти;
- і по суті, розробник має сам усвідомлено це написати вручну, а це вже перемога.
Якщо ж ми захочемо опублікувати пакет в npm, то можна додатково налаштувати, що в реєстр попадуть тільки файли з /dist
. В такому варіанті цю систему вже точно не обійти.
PNPM + Workspace
Моє знайомство з pnpm
відбулось близько 5 років назад, ще на першій ітерації пет-проєкта. Зараз всі репозиторії в нашій компанії переведені на pnpm і це мій пакетний менеджер за замовчуванням. Якщо звести до однієї тези чому мені так подобається pnpm, то: pnpm - це те, як мав би працювати npm.
На головній сторінці документації pnpm
згадується чотири ключових selling points:
- швидкість;
- ефективність;
- підтримка монорепозиторіїв;
- строгість.
Якраз про останні два пункти поговоримо нижче.
NPM Workspaces
Розглянемо на прикладі дуже простого монорепозиторія з npm workspaces.
В нашому репозиторії є два пакета. pkg1
використовує бібліотеку lodash
. Код в pkg1/index.js
виглядає наступним чином:
Отримали функцію add
з бібліотеки, викликали її з аргументами 1 і 2, і вивели в консоль отриманий результат. Все максимально просто. При цьому pkg2
нічого не знає про lodash
, в його package.json
немає ні єдиної залежності.
Після установки залежностей через npm install
картина буде наступна:
- на верхньому рівні з’явилась директорія
node_modules
зі всіма залежностями (тут тількиlodash
), які вказані в проєкті і також пакетами самого репозиторія (pkg1
,pkg2
).
Уявімо, що в pkg2/index.js
ми хочемо виконати такий же код, як і в pkg1/index.js
. По логіці, такий код мав би впасти з помилкою, оскільки pkg2
нічого не знає про lodash
. Але натомість і перший, і другий пакет виконуються ідентично:
Якщо на цьому прикладі це не виглядає, як велика проблема, то на більшому проєкті від цього дуже боляче.
З’являється багато неконтрольованих залежностей і будь-який пакет може використати будь-яку підключену бібліотеку.
Чому так відбувається? Причина криється в тому, як npm
зберігає залежності:
- на верхньому рівні маємо директорію
node_modules
, де знаходяться усі підключені залежності; - коли ми з
pkg2
шукаємо бібліотекуlodash
, то спочатку нода шукатимеnode_modules/lodash
локально вpkg2
і очікувано нічого не знайде; - далі нода підніметься на директорію вище і буде шукати там, і вуаля — вона знайде там
lodash
.
PNPM workspaces
Розглянемо, як в такій же ситуації працює pnpm:
Така ж структура, як і попередньому випадку, єдина різниця — файл pnpm-workspace.yaml
.
Після установки залежностей, через pnpm install
, картинка буде така:
Як бачимо pnpm
зовсім по іншому працює з залежностями:
- в глобальній директорії
node_modules
немає бібліотекиlodash
, але є директорія.pnpm
, де якраз і лежать усі залежності; - в кожному з пакетів створюється локальна директорія
node_modules
. У випадку зpkg1
там знаходитьсяlodash
, яка є symbolic link (символічне посилання) на.pnpm/lodash
. А вpkg2
немаєnode_modules
, оскільки цей пакет не використовує ніяких сторонніх залежностей;
Що ж відбудеться, якщо виконати код в пакетах?
Отримаємо наступну картинку:
- у випадку з
pkg1
отримуємо в консолі очікуване значення; - у випадку
pkg2
отримуємо помилку, щоlodash
не знайдено. Це також очікувана поведінка - перемога 💪
Погляньмо, чому в цьому випадку ми отримали помилку:
Принцип пошуку залежності не змінився, але оскільки залежності зберігаються по іншому, то і результат пошуку є більш логічним.
Але не був би це фронтенд, якби все було так просто 😏
Часто буває ситуація, що якась з внутрішніх бібліотек працює з залежністю, яка явно не прописана в package.json
. У випадку npm
це буде працювати, оскільки всі залежності “валяються” в глобальному node_modules
. Але у випадку pnpm
, це не працюватиме, причину якраз розглянули на кейсі вище.
Для такої ситуації в pnpm
передбачив режим сумісності з npm
. Для цього можна вказати параметр shamefully-hoist=true
в .npmrc
. Назва, як на мене супер 😅
Благо таких випадків стає все менше і менше. Більшість популярних бібліотек, або стараються недопускати таких помилок, або ж просто мігрують на pnpm
(або ж yarn
, там є свої приколи).
Що далі?
В цьому дописі я сконцентрувався на причинах вибору package-based монорепозиторія і пакетного менеджера pnpm.
Якщо підсумувати, то я дуже люблю цей підхід до організації репозиторія і якщо ваш пет-проєкт більший ніж бібліотека, то точно можу рекомендувати його до використання. Що стосується продакшн проєктів, то тут я більш стриманий, оскільки сетап і робота з монорепозиторієм потребує досвіду. Я бачив багато розробників, які недолюблюють цей підхід, але поки схиляюсь до думки, що у них було мало часу, щоб якісно з ним попрацювати і отримати бенефіти, натомість вони отримали багато болю і розчарування.
Наступний допис буде про nx та білд пакетів за допомогою vite.
Перекладено з: Pet-проєкт #2. Монорепозиторій. Package-base repo, PNPM Workspace