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

    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. Если можете порекомендовать кого-нибудь на эту вакансию, буду безмерно благодарен.

    Амперка
    0.00
    Company
    Share post
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 14

      0
      > Наш репозиторий выглядел примерно так

      Так вроде node_modules не принято в репу тащить?
        0

        Да, я не верно сформулировал. node_modules действительно вне репы. Так (упрощённо) выглядит песочница, когда с ней работают. Тот же dist тоже не лежит в репозитории.


        Спасибо за замечание.

        0

        Вы хорошо описали проблемы, которые мы решили чуть по другому:


        1. Файлы ложатся в /имяКомпании/путь/к/модулю/исходник.*.
        2. Используются модули обращением к $имяКомпании_путь_к_модулю или $имяКомпании.путь.к.модулю.
        3. Соответственно никаких импортов не надо, но если очень хочется, то const алиас = $имяКомпании.путь.к.модулю.
        4. К npm-модулям идёт обращение через $node.имяМодуля или $node['имя-модуля'].
        5. Любой модуль можно "сбилдить" как для веба, так и для ноды. То есть выполняем npm start имяКомпании/путь/к/модулю и получаем для него бандлы web.js, node.js и package.json, для публикации в npm.

        Генерация package.json ещё правда не допилена до конца. Сейчас только выписываются все зависимости попавших в node.js mam-модулей от npm-модулей. Нужно ещё добавить прописывание правильных версий.


        В целом, получилось обойтись без babel, webpack и прочих огромных штук — лишь один раз ставится маленький и быстрый сборщик, который транспилирует через typescript и postcss, а бандлит через concat-with-sourcemaps, и в одной кодовой базе можно работать с огромным числом модулей, собирая и деплоя любой из них.

          0

          Хмм… Я правильно понимаю, что вы полностью заменили штатный механизм модулей собственным?


          Если так, вы заставляете отказаться от инструментов, которые предполагают работу со стандартными модулями: статические чекеры, там, tree-shake’ры всякие. И для NodeJS бандлить пакет в один файл контрпродуктивно.

            0
            Похоже на обычный DI, тоже ничего криминального не вижу.
              0
              Я правильно понимаю, что вы полностью заменили штатный механизм модулей собственным?

              И да и нет. Исходники пишутся "в своём формате", а из них собираются бандлы под разные целевые платформы. Для веба — web.js без зависимостей, а для ноды — node.js в формате node-модуля.


              статические чекеры

              Их можно прикрутить, если надо. Но как правило они не особо полезны. От typescript больше пользы.


              tree-shake’ры

              А вот это для MAM модулей совсем не нужно. В отличие от традиционного подхода со включением всего подряд, а потом вырезанием лишнего, тут лишнее изначально не включается в бандл.


              Типичный модуль выглядит так:


              namespace $ {
              
                  export class $acme_server extends $mol_server { // тут сборщик понимает, что предварительно надо подгрузить модуль '/mol/server'
                      // ...
                  }
              
              }
              
              > И для NodeJS бандлить пакет в один файл контрпродуктивно.
              
              Почему? Наоборот, получается компактный модуль без лишнего мусора, с минимумом зависимостей, который уже не нужно транспилировать (однако сорсмапы, если что, лежат рядом), который крайне быстро устанавливается.
            0

            А alias вебпака не решают вашу задачу?


            У меня например все компоненты подгружаются как


            import XXX from 'component/XXX'

            Тоже самое actions, reducers, less etc. и никаких относительных путей.


            А если еще в корне components сделать index.js вида


            import XXX from './XXX'
            import YYY from './YYY'
            
            export { XXX, YYY }

            то можно в дальнейшем импортить удобно список компонентов через деструктуризацию


            import  { XXX, YYY } from 'components'

            Особенно помогает уменьшить кол-во строк и визуального мусора, когда импортиш большой список в одном месте (например где-нибудь в роутере).

              0

              В теории решает. Но это фича вебпака. А им билдится только клиентский код.

                0

                Тоже верно :)

                  0

                  Им можно и backend и отдельные библиотеки билдить, получается не так уж и плохо. Один минус — скорость сборки, но это можно частично обойти правильной конфигурацией webpack. Но в целом же, все равно это все грязные хаки и костыли :(

                0
                import { validateProject } from 'xod-core'

                Этим импортом вы подключите весь большой пакет xod-core себе в бандл. Для браузерного кода это может быть плохо. Tree-shaking может помочь, но насколько я понял, вы прогоняете исходный код через Babel, так что ES6 модули заменятся на обычные CommonJS, с которыми tree-shaking пока не умеет работать.


                Еще приведу пример, как в React-router рекомендуют подключать отдельные модули, если вам не нужен весь роутер целиком:


                https://github.com/ReactTraining/react-router/blob/master/docs/guides/MinimizingBundleSize.md

                  0
                  Рекомендую использовать не Lerna, а его форк Asini (https://github.com/asini/asini), который гораздо быстрее развивается и более гибкий в настройке. Не обязует размещать все пакеты в папке packages. И по поддержке Yarn там уже начата работа.

                Only users with full accounts can post comments. Log in, please.