Множество JS-пакетов в одном репозитории

в 9:23, , рубрики: javascript, lerna, monorepo, npm, ReactJS, архитектура, Блог компании Амперка

image

Хабрадевелоперам, привет! Не так давно мы начали разрабатывать комплексный проект, у которого есть или планируется несколько видов фронт-енда, множество сервисов бэк-енда, интерфейс командной строки, демоны и много ещё чего. У всего этого в свою очередь есть шареный код, а совершенно новые приложения должно быть возможным собирать из имеющихся кирпичиков простым и понятным образом.

Если не занудствовать с терминологией, мы делаем платформу. Платформу для визуального программирования под DIY-электронику.

Несмотря на то, что проект находится на ранней стадии, кодовая база уже грозилась превратиться в кашицу. Чтобы это присечь, мы перевели проект на так называемый monorepo-подход. На Хабре не оказалось материалов на эту тему, поэтому попытаюсь восполнить пробел.

Что было вначале

Начиналось всё довольно традиционно. Наш репозиторий выглядел примерно так:

dist/
node_modules/
src/
  assets/
  components/
  containers/
  reducer/
  actions.js
  actionTypes.js
  constants.js
test/
package.json

Имевшие дело со стеком React + Redux моментально узнают шаблон. В src/ лежат исходники фронт-енда, по команде они собираются Webpack’ом в dist/ откуда фронт-енд можно сервить, как простую статику.

Расширяемся

Такая структура хорошо работает, если приложение не очень большое. Но мы довольно быстро получили множество React-компонентов и -контейнеров, Redux-редюсеров и -экшенов, которые начали толпиться в своих директориях.

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

Пришло время делиться. В этот момент встал выбор перед двумя общепринятыми подходами разделения.

Rails style:

src/
  components/
    project/
    projectBrowser/
    editor/
    messages/
  containers/
    project/
    projectBrowser/
    editor/
    messages/
  reducers/
    project/
    projectBrowser/
    editor/
    messages/
  actions/
    project.js
    projectBrowser.js
    editor.js
    messages.js
  ...

Или pod style:

src/
  project/
    components/
    containers/
    reducers/
    actions.js
    actionTypes.js
    constants.js
  projectBrowser/
    components/
    containers/
    reducers/
    actions.js
    actionTypes.js
    constants.js
  editor/
    components/
    containers/
    reducers/
    actions.js
    actionTypes.js
    constants.js
  messages/
    components/
    containers/
    reducers/
    actions.js
    actionTypes.js
    constants.js

Rails-подход хорош тем, что слои чётко очерчены. Структура «пакетов» регламентирована и не провоцирует на изобретательство.

Но в этом кроется и проблема. Хотим мы, допустим, теперь CLI-интерфейс. React для утилит командной строки имеет не много смысла: слои components и containers не нужны. Зато нужно куда-то положить модули для красивого вывода в терминал, для парсинга аргументов и т.п. Для этого слоёв нет, придётся добавить только для CLI.

Дальше придумываем ещё что-нибудь и видим, что опять не лезет в структуру. Придётся снова раздувать. Неминуемо появится помойка с именем utils, helpers, tools, shared или как там обычно маскируют непойми-что. Плохой вариант.

Ну и самое главное: не существует простого способа выдрать какой-то «пакет» из кодовой базы, сказать, что теперь это нечто самостоятельное, скинуть на дискетку и отправить почтой.

Поэтому мы остановились на pod-концепции.

Пути наверх

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

// src/editor/containers/Editor.jsx
import { validateProject } from '../../core/project/selectors'

В этом есть что-то противоречивое. Пакеты хоть и разнесены по директориям, сохраняется строгое предположение об их размещении. Количество «точечек» варьируется в зависимости от вложенности модуля, который импортирует. Кроме прочего это ещё и затрудняет рефакторинг.

Хочется как с библиотеками: начинать импорт с названия библиотеки, и чтоб кто-нибудь за нас разобрался, где эту библиотеку брать:

// src/xod-editor/containers/Editor.jsx
import { validateProject } from 'xod-core/project/selectors'

Core превратился xod-core, чтобы исключить возможность конфликтов со сторонними библиотеками в случае использования простых названия. XOD — это название проекта, который мы делаем.

Итак, как к этому прийти? Верно, сделать настоящие JS-пакеты, которые прогонять через NPM и node_modules, ровно как это происходит с библиотеками.

Классический подход — это на каждый JS-пакет иметь по репозиторию со своим package.json, версированием и т.п.

Однако при динамичной разработке жонглирование десятком репозиториев с npm install, build, publish, npm link, git pull, git push даже по ощущениям выглядит адово. Нужно как-то оставить всё в одном репозитории.

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

node_modules/
xod-cli/
  bin/
  src/
  test/
xod-client/
  dist/
  src/
  test/
xod-client-browser/
  ...
xod-client-electron/
xod-core/
xod-espruino/
xod-fs/
xod-server/
package.json

Линковка

По идее для подобных сценариев есть npm link, но элементарно правильно навести все линки на новой машине, воспроизвести структуру проекта уже не просто: npm link — не stateless. А всё делается простоты ради. Поэтому нет, спасибо.

Есть трюк, который основан на том, как Node ищет модули. А именно: нода бежит по файловому дереву вверх в поисках node_modules/, начиная с директории, где лежит импортирующий модуль.

Таким образом, мы можем сделать нужные нам симлинки руками внутри src/. Мы с одной стороны и собственные пакеты сделаем видимыми для импорта и никак не сконфликтуем с обычными зависимостями из package.json.

node_modules/
  react/
  redux/
  webpack/
xod-client/
  dist/
  src/
    node_modules/
      xod-core -> ../../../xod-core/src
    this-package-js-files.js
xod-core/
  src/
    project/
      selectors.js
package.json

Ура, независимо от положения импортирующего модуля мы можем делать:

import { validateProject } from 'xod-core/project/selectors'

Симлинки можно совершенно спокойно хранить в Git-репозитории. И пока среди разработчиков не встречается Windows, всё будет хорошо работать: код сразу готов к сборке после клона.

Доворачивание до пакета

То, что получилось, пока ещё не является полноценными JS-пакетами. Для того, чтобы пакет мог быть залит на NPM и на равных со всеми правами использоваться в сторонних проектах, нужно каждый снабдить собственным package.json и прописать его единоличные зависимости. Сейчас же у нас единственное описание мега-пакета находится в корне. Туда же свалены все зависимости всех пакетов. Исправляем:

xod-client/
  dist/
  node_modules/
    babel/
    ramda/
    react/
    redux/
    webpack/
  src/
    node_modules/
  package.json
xod-core/
  node_modules/
    babel/
    ramda/
    webpack/
  src/
  package.json
package.json
Makefile ← опаньки

История с симлинками продолжает работать, как работала, зато каждый пакет получил собственную мета-информацию, собственные зависимости, нужные только ему. Структура стала управляемее.

Только вот теперь, имея 10 пакетов, чтобы сделать тот же npm install, необходимо заходить в каждую директорию и запускать скрипт для каждого пакета. Не круто.

Чтобы вернуть возможность делать сборку, тестирование, линтинг или запуск в одну команду, мы добавили Makefile. С содержимым типа:

install:
    npm install
    cd xod-cli && npm install
    cd xod-client && npm install
    cd xod-client-browser && npm install
    cd xod-client-electron && npm install
    cd xod-core && npm install
    cd xod-espruino && npm install

И так для каждого действия. Немного неуклюже, но работает.

Бестолковые билды

Проблема такой структуры всплыла довольно быстро. То, что каждый пакет стал обладать собственными зависимостями с академической точки зрения хорошо, но с практической привело к тому, что одни и те же зависимости стали устанавливаться по нескольку раз. На один только make install уходило под 10 минут.

Львиную долю времени отъедала установка Webpack, Babel и их друзей.

Дополнительно, при билде одни и те же исходные файлы транспилировались/паковались по нескольку раз: по разу на собираемый пакет. Не продуктивно.

Решение: пусть каждый пакет билдит себя в свой dist/ один раз, а зависимые пакеты пользуются уже готовыми артефактами. Сами билд-инструменты можно ставить единожды в корневые node_modules/.

При таком подходе симлинки между пакетами достаточно перенавести с src/ на dist/ и чуть подправить конфиги Webpack’а, чтобы он не процессил «чужие» исходники.

Также следует отдельно проследить, чтобы порядок билда не был нарушен: пакеты от которых зависят должны билдиться перед зависимыми пакетами.

В корень переехали все инструменты из dev-dependencies: Webpack, Babel, Mocha, ESLint.

Эта пара мер вернула полную сборку и проверку на CI-сервере в три минуты. Соответсвтенно и на localhost’е дела пошли бодрее.

Lerna

Пока мы перемещали директории с пакетами туда-сюда, я наткнулся на Lerna. Это инструмент, который был в своё время вычленен из Babel’а и как раз помогает держать множество пакетов в одном репозитории. Так сделано, конечно же, и в самом Babel’е.

Среди полезностей Lerna позволяет запустить npm-команду внутри каждого пакета, бампнуть версию каждого пакета, а главное она позволяет сделать так называемый bootstraping.

Бутстрапинг — это создание симлинков на локальные пакеты, как это делали мы, только автоматически (основываясь на package.json пакета) и в его штатный node_modules/, а не в src/. Финальный шаг бутстрапинга — установка третьих зависимостей каждым из пакетов. И всё это кроссплатформенно.

Всё бы хорошо, только Lerna не совместима с текущей структурой по двум статьям:

  • Пакеты должны быть в поддиректории packages/
  • Симлинки создаются прямо на директорию пакета, а не на его поддиректорию вроде dist/ или src/

Первая проблема решается тривиально. Со второй всё сложнее.

Дело в том, что мы не сможем писать:

import { validateProject } from 'xod-core/project/selectors'

Придётся всюду писать:

import { validateProject } from 'xod-core/dist/project/selectors'

В этом есть что-то противоестественное. А что, если какой-нибудь пакет захочет билдиться для нескольких видов таргетов, и в его dist/ появятся соответствующие поддиректории? Придётся переписывать абсолютно все пути импорта. Плохо-плохо.

Файл package.json позволяет указать так называемый main-файл, например, dist/index.js, но не позволяет указать «main-директорию». Исходя из того, что я прочитал, это официальная позиция ноды и меняться не будет. Чтобы не баловались.

Как быть? Выдыхаем, смотрим на опыт других. А опыт таков, что практически нигде вы не найдёте импортов с путями. Т.е. если есть библиотека foo, вы просто импортируете непосредственно из неё: import { blabla } from 'foo'. Никаких import {blabla } from 'foo/bla/bla'.

И ведь это чертовски неплохо, подумали мы. Пакет обретает понятные, чёткие рамки: у него есть API из какого-то количества функций, констант, классов, которыми могут пользоваться соседи. Этот API можно описать в его собственном README.md, выпилить из этого репозитория, поместить в отдельный, опубликовать самостоятельно и т.д.

Внутри, условно, хоть трава не расти, а внаружу, будь добр: хороший и красивый API.

В итоге все наши многочисленные импорты вида:

import { validateProject } from 'xod-core/project/selectors'

превратились в элегантные:

import { validateProject } from 'xod-core'
validateProject(...)
// или
import core from 'xod-core'
core.validateProject(...)

Сами пакеты, в своих корневых index.js просто реэкспортируют необходимые символы внаружу.

Заключение

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

node_modules/
  webpack/
  babel/
  mocha/
  eslint/
packages/
  xod-cli/
  xod-client/
    dist/
    node_modules/
    src/
      api/
      editor/
      messages/
      processes/
      projectBrowser/
      user/
      index.js
    test/
    package.json
    weback.config.js
  xod-client-browser/
  xod-client-electron/
  xod-core/
  xod-espruino/
  xod-fs/
    .babelrc
    dist/
    node_modules/
    src/
      backup.js
      index.js
      load.js
      save.js
    package.json 
  xod-server/
package.json
lerna.json

Самые внимательные могли заметить, что я начал примеры с разнесения фронт-енд составляющих, а продолжил какими-то более крупными пакетами. Так и есть. Весь фронт у нас и сейчас лежит внутри одного xod-client. Там он организован в стиле pod’ов. Оказалось, что пока ему так не жмёт. А когда начнёт жать, мы знаем, что делать: выносить на верхний уровень, в отдельные пакеты.

TODO:

  • Lerna пока не дружит с Yarn. Ждём пока разработчики договорятся и npm install станет ракетой и для монорепозиториев.
  • Lerna умеет выполнить npm-скрипт в каждом пакете, но не может сделать этого, учитывая кросс-зависимости. Поэтому приходится вручную прописывать порядок сборки в корневом package.json. Стоит попробовать упростить это через Gulp.

Не претендую на то, что представленный подход «правильный». Так сложилось у нас и сложилось оно на основе эволюционных изменений, которые проходил проект. Если кому-то окажутся полезными изложенные мысли, я буду рад ;)

P.S. В проект ищем full-stack разработчика JS. Если можете порекомендовать кого-нибудь на эту вакансию, буду безмерно благодарен.

Автор: Амперка

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js