Пет-проєкт №2. Монорепозиторій. Репозиторій на основі пакетів, PNPM Workspace.

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

В цьому ж дописі розповім детальніше про:

  • початковий сетап репозиторія;
  • чому я віддаю перевагу монорепозиторію і pnpm;

Монорепозиторій. Навіщо ускладнювати?

Спочатку може виникнути питання, а навіщо так ускладнювати? Чому просто не створити проєкт-бібліотеку, чи якусь SPA-шку? Коротка відповідь — це зручніше для мене. Довша відповідь складається з ряду причин, про них нижче.

Простір для експериментів

В рамках монорепи я можу створювати, яку завгодно кількість різнотипних проєктів, ніяк себе не обмежуючи. Якщо хочу SPA — будь ласка, хочу бібліотеку, зібрану за допомогою Vite чи Webpack — будь ласка, хочу окремий проєкт з E2E тестами — на здоров’я.

В пет-проєкті критично важливі експерименти, їх кількість і швидкість реалізації. І круто, коли ваша ж система не обмежує в цьому.

Один пакет — одна відповідальність

Ще з часу розробки на .Net/C#, маю звичку розділяти код по певним зонам і яке ж було моє здивування, що в світі JS, цю історію не так і просто реалізувати.

Уявимо, що у нас проєкт з якоюсь кількість неструкторованого коду. Його важко підтримувати, важко розробляти новий функціонал і взагалі хочеться закрити IDE-шку, щоб тільки не бачити цей код. Ми збираємо усю волю в кулак і вирішуємо його, якось структурувати. По шарам, по доменним зонам чи ще якось — не так важливо.

pic

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

pic

Круто, якщо ми все грамотно спроектували і зробили, то тепер зміни проходитимуть легше. Сміливо копошимося в рамках “модуля” і якщо його API не змінюється, то з більшою впененістю можемо сказати, що і решта системи не деградує скоріш за все.

Але, що заважає нам напряму імпортувати код з модуля в модуль?

pic

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

pic

З мого досвіду, якщо тобі нічого не заважає робити неправильно, то ти будеш робити неправильно. І з часом від “модулів”, які ми старались розділити буде мало толку.

pic

Чи тут може врятувати тільки монорепозиторій — ні, або точніше не на 100%. Але він точно створить більше перешкод для неправильних або ж нечітких зв’язків.

Контрольовані обмеження

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

..бахнути малюнок. На одній частині “подумали — зробили”, на іншій “подумали — зробили — подумали — викинули — переробили”.

І так, монорепозиторій в цьому плані, by default, дає такий механізм. А саме на рівні вашої бібліотеки ви точно знаєте, які саме сторонні залежності ви можете використовувати. Це може бути інший пакет в рамках репозиторія, або ж стороння бібліотека.
Але в будь-якому випадку, щоб її використати ви зобов’язані додати цю залежність в package.json вашої бібліотеки, інакше нічого не спрацює. Тут важливо, що використовуємо саме pnpm і нижче розповім чому.

І саме цей невеличкий факт вже вберігає нас від необдуманих залежностей. Якби це був звичайний проєкт на Nuxt і ми б потребували в нашій функції, для прикладу, змінити cookie на нашому сайті, ми б без докору сумлінь імпортнули композабл useCookie, і зробили б свої справи.

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

По суті сама ж система б’є нас по рукам, якщо ми робимо щось не так і це чудово ☺️

Остання філософська думка

Плюси, які я вище описав, доступні тільки при умові, якщо ви правильно “приготували” монорепозиторій 🫠

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

У цьому весь фронтенд — 100500 способів зробити одне і теж, а тільки декілька з цих способів справді хороші.

Package-based репозиторій

Виділяють два основні підходи до організації монорепозиторія: integrated repos і package-based repos.

Більше деталей можна прочитати в доці nx. Я ж хочу зосередитися на package-based підході. А що стосується integrated, то я не розумію його плюсів, чому і в яких випадках його варто використовувати. На мою думку, простіше і ефективніше — це використання полірепозиторія, де є тільки один проєкт.

Структура

Базова структура нашого репозиторія — дві директорії:

pic

В apps ми тримаємо всі застосунки. Для прикладу, web застосунок, або CLI тула.

В packages тримаємо усі пакети, в моєму випадку - це TypeScript-бібліотеки.

pic

Структура пакета

Спускаємось на рівень конкретного пакети і розглянемо, як він може виглядати з середини:

pic

  • директорія /src - містить основний код пакета. Структура всередині залежить від фантазії розробника;
  • директорія /node_modules - містить усі залежні бібліотеки. Детальніше поговоримо про це нижче;
  • файл index.ts - точка входу для нашого білда на vite;
  • файл vite.config.ts - vite конфіг;
  • файл package.json - конфігураційний файл пакета;

Припустимо, що якимось магічним чином ми налаштували білд цього пакета, що ж буде в результаті? Насправді не багато. Утворилась нова директорія /dist, в ній лежатиме js-файл index.js, який буде містити усю логіку пакета:

pic

Верхньорівнево зі структурою пакета розібрались, тепер спробуємо використати один пакет в іншому.

Залежність

Для початку глянемо, як це виглядатиме в файловій системі:

pic

І нехай залежність виглядає наступним чином:

pic

В pkg1 маємо функцію bar, яка знаходиться в однойменному файлі. Ми хочемо викликати функцію foo, яка знаходиться в пакеті pkg2.
Як ще це працює під капотом?

Спочатку подивимось, що відбувається в pkg2:

pic

  • Точкою входу для білда є /pkg2/index.ts, то в цьому файлі потрібно імпортувати функцію foo, а потім експортувати її. Якщо простіше, то зробити re-export.
  • Після цього запускаємо білд пакета і отримуємо /pkg2/dist/index.js. Цей файл міститеме валідний JS-код зі всією логікою пакета.
  • І останній штрих. Потрібно десь вказати, що імпорт з pkg2 це насправді файл /pkg2/dist/index.js. Це можна зробити в package.json.

Тепер процес імпорту виглядатиме так:

pic

Підсумок

Описаний вище кейс ілюструє усю суть 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:

pic

  • швидкість;
  • ефективність;
  • підтримка монорепозиторіїв;
  • строгість.

Якраз про останні два пункти поговоримо нижче.

NPM Workspaces

Розглянемо на прикладі дуже простого монорепозиторія з npm workspaces.

pic

В нашому репозиторії є два пакета. pkg1 використовує бібліотеку lodash. Код в pkg1/index.js виглядає наступним чином:

pic

Отримали функцію add з бібліотеки, викликали її з аргументами 1 і 2, і вивели в консоль отриманий результат. Все максимально просто. При цьому pkg2 нічого не знає про lodash, в його package.json немає ні єдиної залежності.

Після установки залежностей через npm install картина буде наступна:

pic

  • на верхньому рівні з’явилась директорія node_modules зі всіма залежностями (тут тільки lodash), які вказані в проєкті і також пакетами самого репозиторія (pkg1, pkg2).

Уявімо, що в pkg2/index.js ми хочемо виконати такий же код, як і в pkg1/index.js. По логіці, такий код мав би впасти з помилкою, оскільки pkg2 нічого не знає про lodash. Але натомість і перший, і другий пакет виконуються ідентично:

pic

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

Чому так відбувається? Причина криється в тому, як npm зберігає залежності:

pic

  • на верхньому рівні маємо директорію node_modules, де знаходяться усі підключені залежності;
  • коли ми з pkg2 шукаємо бібліотеку lodash, то спочатку нода шукатиме node_modules/lodash локально в pkg2 і очікувано нічого не знайде;
  • далі нода підніметься на директорію вище і буде шукати там, і вуаля — вона знайде там lodash.

PNPM workspaces

Розглянемо, як в такій же ситуації працює pnpm:

pic

Така ж структура, як і попередньому випадку, єдина різниця — файл pnpm-workspace.yaml.

Після установки залежностей, через pnpm install, картинка буде така:

pic

Як бачимо pnpm зовсім по іншому працює з залежностями:

  • в глобальній директорії node_modules немає бібліотеки lodash, але є директорія .pnpm, де якраз і лежать усі залежності;
  • в кожному з пакетів створюється локальна директорія node_modules. У випадку з pkg1 там знаходиться lodash , яка є symbolic link (символічне посилання) на .pnpm/lodash. А в pkg2 немає node_modules, оскільки цей пакет не використовує ніяких сторонніх залежностей;

Що ж відбудеться, якщо виконати код в пакетах?

Отримаємо наступну картинку:

pic

  • у випадку з pkg1 отримуємо в консолі очікуване значення;
  • у випадку pkg2 отримуємо помилку, що lodash не знайдено. Це також очікувана поведінка - перемога 💪

Погляньмо, чому в цьому випадку ми отримали помилку:

pic

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

Але не був би це фронтенд, якби все було так просто 😏

Часто буває ситуація, що якась з внутрішніх бібліотек працює з залежністю, яка явно не прописана в 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

Leave a Reply

Your email address will not be published. Required fields are marked *