Node.js без node_modules +35


На прошлой неделе разработчики Yarn (пакетного менеджера для Javascript) анонсировали новую фичу – Plug'n'Play установку. Эта возможность позволяет запускать Node.js проекты без использования папки node_modules, в которую обычно устанавливаются зависимости проекта перед запуском. Описание фичи декларирует, что node_modules больше не понадобится – модули будут загружаться из общего кеша пакетного менеджера.


Одновременно с ними разработчики NPM также анонсировали свое аналогичное решение проблемы.


Давайте посмотрим на эти решения повнимательнее и попробуем протестировать их в реальных проектах.


История проблемы


Изначально модульная система NodeJS была полностью основана на файловой системе. Любой вызов require() маппится на файловую систему. Для организации third-party модулей была придумана папка node_modules, в которую должны скачиваться и устанавливаться переиспользуемые модули и библиотеки. Таким образом, каждый проект получал свой отдельный набор зависимостей, нерационально расходуя дисковое пространство.


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


Упрощенно, установка модулей состоит из следующих шагов:


  1. Вычисляется конкретная версия модуля из допустимого интервала
  2. Все модули необходимых версий выкачиваются из репозитория и сохраняются в локальный кеш
  3. Модули из локального кеша копируются в папку node_modules проекта

Если первые два шага уже достаточно соптимизированы и выполняются быстро, когда у вас уже есть закешированные модули, то третий шаг так и остался работать почти без изменений по сравнению с первыми версиями node и npm.


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


Использование симлинков


Вместо реального копирования модулей, можно добавить симлинк на их местоположение в кеше. Такой подход реализован в PNPM, еще одном альтернативном пакетном менеджере. Подход вполне может работать, но с симлинками возникает множество проблем, связанных с двойственным местоположением файла, поиском смежных модулей и т.п. Кроме того, создание симлинков – это файловые операции, которых хотелось бы избежать в идеальном способе работы.


Пробуем Yarn PNP


Подробнее об этой фиче можно почитать в официальном описании. В этом параграфе содержится его краткий пересказ.


Версия Yarn с поддержкой PNP сейчас находится в feature-branch yarn-pnp.


Склонируем репозиторий локально с нужной веткой


git clone git@github.com:yarnpkg/yarn.git --branch yarn-pnp

Инструкция по сборке yarn находится здесь, набор шагов очень тривиальный.


После окончания сборки, добавляем себе алиас на кастомную версию yarn и можем начать c ней работать:


alias yarn-local="node $PWD/lib/cli/index.js"

Plug'n'play включается двумя способами: либо через флаг: yarn --pnp, либо дополнительной конфигурацией в package.json: "installConfig": {"pnp": true}.


В качестве примера разработчики Yarn уже подготовили демо-проект. В нем есть Webpack, Babel и другие типичные для современного фронтенда инструменты. Попробуем установить его зависимости разными способами и получаем следующие результаты:


  • Обычная установка yarn: 19s
  • Установка через yarn --pnp: 3s

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


Давайте теперь разберемся как это работает. После pnp-установки в корне проекта создается дополнительный файл .pnp.js который содержит переопределение нативной логики во встроенном в Node.js классе Module. Загружая этот файл в свой код, мы наделяем функцию require() возможностью доставать модули из глобального кеша и не смотреть в node_modules. Все встроенные yarn-команды, вроде yarn start или yarn test по умолчанию предзагружают этот файл, так что никаких изменений в вашем коде не потребуется, если вы уже использовали Yarn до этого.


В дополнение к маппингу модулей, pnp.js выполняет дополнительную валидацию зависимостей. Если вы попытаетесь вызвать require('test'), без задекларированной зависимости в package.json, вы получите следующую ошибку: Error: You cannot require a package ("test") that is not declared in your dependencies. Это улучшение должно повысить надежность и предсказуемость кода.


Из недостатков нового подхода стоит отметить, что потребуется дополнительная интеграция для инструментов, которые работали с директорией node_modules напрямую без встроенных механизмов Node. Например, для Webpack и других сборщиков фронтенда понадобятся дополнительные плагины, чтобы они смогли находить нужные файлы для бандлинга.


В демо-проекте есть наброски резолверов, для Eslint, Jest, Rollup и Webpack.


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


Также будут проблемы с postintall-скриптами. Поскольку модуль остаётся в кеше, postinstall-скрипты, меняющие его состояние (например, докачивающие дополнительные файлы) могут повредить кеш и сломать остальные проекты, зависящие от него. Разработчики Yarn рекомендуют отключать исполнение скриптов флагом --ignore-scripts. Они уже экспериментировали с включением этого флага по умолчанию для всех проектов внутри Facebook и не обнаружили серьезных проблем. В долгосрочной перспективе отказ от postinstall-скриптов кажется хорошим шагом в виду известных проблем с безопасностью.


Пробуем NPM tink


Команда NPM также анонсировала свое альтернативное решение. Их новый инструмент, tink поставляется отдельным, независимым от NPM, модулем. На вход tink принимает файл package-lock.json, который автоматически генерируется при запуске npm install. На основании lock-файла tink генерирует файл node_modules/.package-map.json, в котором хранится проекция локальных модулей на их реальное местоположение в кеше.


В отличие от Yarn, здесь нет хук-файла, который можно предзагрузить в свой проект, чтобы пропатчить require. Взамен предлагается использовать команду tink вместо node, чтобы получить правильное окружение. Такой подход менее эргономичный, поскольку потребует модификаций в вашем коде, чтобы заставить его работать. Однако в качестве proof-of-concept подойдет.


Я попробовал сравнить скорость установки модулей командами npm ci и tink, но tink оказался даже медленнее, поэтому результаты приводить не буду. Очевидно, что этот проект намного более сырой по сравнению с Yarn и совсем не оптимизирован. Что ж, будем ждать новых релизов.


Заключение


Отказ от директории node_modules – закономерный шаг, учитывая опыт других языков, где такого подхода не было изначально. Это благоприятно скажется на скорости сборки с CI-системах, где есть возможность сохранить кеш пакетов между билдами. Кроме того, если перенести кеш пакетов и файл .pnp.js с одного компьютера на другой, то можно воспроизвести окружение даже не запуская Yarn. Это может быть полезным в контейнерных системах сборки: монтируем директорию с кешем, кладем .pnp.js файл, и можно сразу запускать тесты.


Новый подход выглядит непривычно и ломает некоторые устоявшиеся практики, основанные на том, что все модули всегда в наличии в node_modules. Но .pnp.js файл предлагает API, которое позволит абстрагироваться от реального положения файлов и работать с виртуальным деревом. Кроме того, на крайний случай, есть команда yarn unplug --persist, которая извлечет модуль из кеша и разместит его локально в node_modules.


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


Ссылки


Вы можете помочь и перевести немного средств на развитие сайта



Комментарии (54):

  1. k12th
    /#19118899

    Обычная установка yarn: 19s
    Установка через yarn --pnp: 3s
    Перед измерением была проведена одна холодная установка, чтобы все нужные модули уже были в кеше.

    То есть теперь установка будет занимать 19+3 (прогрев кэша и установка из кэша) секунды, вместо 19 (напомню, что скачанные модули кэшируются в любом случае). О — оптимизация.


    Плюс теперь надо весь тулинг переписывать. Сэкономили человеко-часы, ничего не скажешь.

    • justboris
      /#19118937

      То есть теперь установка будет занимать 19+3

      Не совсем так. 19 секунд занимает копирование файлов из кеша в node_modules, по сравнению с 3 секундами на создание файла с маппингами. Если вы уже однажды установили модули и их версии сохранились в yarn.lock, то установка этого проекта c --pnp будет всегда занимать ~3 секунды.

      • k12th
        /#19120211 / -1

        Если я уже однажды устанавливал модули и они сохранились в кэше, то повторная установка займет… ну может не 3 секунды, а уже 6, но разница непринципиальная.

        • mayorovp
          /#19120371 / -1

          Ну вы же сами цитировали:


          Обычная установка yarn: 19s

          Не 3, не 6, а целых 19 секунд занимает тупое копирование файлов...

          • k12th
            /#19120393

            Нет, это с разрешением зависимостей, скачиванием файлов (и их распаковкой).

            • mayorovp
              /#19120403

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

              • k12th
                /#19120443 / +1

                Окей, был неправ, признаю и приношу извинения. Выигрыш во времени установки действительно есть.

            • justboris
              /#19120641

              mayorovp правильно говорит. Скачивания файлов и распаковки здесь не происходит. В кеше они уже хранятся в распакованном виде.


              Для чистоты эксперимента я повторил эти операции с флагом --offline и отключенным интернетом, цифры остались те же.

    • firedragon
      /#19119321

      CI система, да и вы вызывают запуск 10 раз на дню.
      Так что экономия есть.
      И еще как по мне килер фича: Отсутствие папочки с 10 — 50 — 100 мегабайт мелко нашинкованного ява-скрипта. А если у вас в работе 5-6 проектов?

      • Focushift
        /#19121055

        Папка с проектами весит 7Гиг, вот это будет экономия.

      • tot0ro
        /#19123225

        100мб ??? Мелко плаваете у нас в проекте node_modules 700mb

        • TheShock
          /#19123433

          А зачем? И в какой бандл они потом билдятся?

          • dzhiriki
            /#19124605

            node_modules может не только для сборки фронта использоваться ;)

  2. codemafia
    /#19119467

    А чем такой подход отличается от обычной глобальной установки?

    • firedragon
      /#19119571

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

      Возможно есть еще какие соображения.

    • justboris
      /#19119675

      1. Глобальная установка делает доступными только исполняемые команды, например: eslint, webpack и т.д. require('module-name') работать не будет
      2. Даже если исправить первый пункт, все равно останется проблема разных версий. Глобально можно установить только одну версию модуля, а из кеша можно смаппить несколько разных мажорных версий для разных проектов.

  3. Methos
    /#19119749 / +1

    еще больше запутали логику
    потом будут распутывать
    потом распутывать нараспутывание
    и т. д.

  4. jehy
    /#19120111 / +1

    Очень странная мысль о том, что это как-то поможет CI. Откуда возьмётся локальный кеш-то? Сначала сборку у себя делает разработчик, потом один CI собирает билд, а другой CI раскатывает этот билд на сервера. Ни у одного из CI серверов при этом кеша может не быть, и обычно CI делается в контейнерах, где кеша точно не будет. Так что кому и чем это может помочь — загадка. Ну разве что разработчику локально.

    У нас используется утилитка npm-cache, которая позволяет собрать кеш в архив и эти архивы монтировать на CI машинки. Утилита несколько заброшенная, но я её допиливал, если кому интересно.

    • mayorovp
      /#19120381

      И что, эти контейнеры еще и сбрасываются в начальное состояние перед каждой сборкой? Ну, хозяин-барин конечно, но не все же так делают.

      • jehy
        /#19120427

        Не сбрасываются, они каждый раз просто новые. Чтобы ничего не могло накопиться и повлиять на билд.

        • TheShock
          /#19120727

          Можно создать новый контейнер с заполненным кешем.

    • kalyukdo
      /#19120383

      они похоже что то подобное будут делать, когда будет запущен yarn, он положит в свой кеш и потом оттуда будет отдавать зависимости

    • justboris
      /#19120671

      Предполагается, что у CI-системы есть кеш, который сохраняется между билдами. Например, в travis-ci можно включить сохранение кэша Yarn.


      Рабочая директория билда всегда новая, а вот кэш из домашней директории пользователя может и сохраняться, ничего плохого в этом нет.

      • Yeah
        /#19123353

        Так это можно делать и с node_modules. В чем профит?

        • justboris
          /#19123611

          node_modules содержит зависимости конкретно под этот проект, описанные в package.json. Если там что-то поменяется, кэширование node_modules билду только навредит. А глобальный кеш –универсальный, это просто локальная копия удаленного репозитория, без зависимости от того, что происходит с package.json.

    • Acionyx
      /#19120673

      Это же зависит от вашего инструмента для CI. Хотя странно, большинство точно умеют в кеш между разными заработало этапами процессов CI&CD

  5. topa
    /#19120153 / +2

    У node_modules есть замечательное преимущество — код импортируемых библиотек всегда под рукой, можно его посмотреть, поставить точку останова, подебажить и так далее. При новом подходе он будет храниться где-то далеко, и очень хорошо, если IDE сумеет найти код библиотек и обработать точки останова.

    • firedragon
      /#19120595 / +1

      /home/user/.npm_cache/cookie/src
      /home/user/project/coolstuff/node_modules/cookie/src

      В чем разница для ide?

      • taliban
        /#19122297

        В том что второй находится в «окружении» проекта, первый же отдельно. Эти пути не настраиваются явно у вас в проекте.

        • Aingis
          /#19123137

          Ничто не мешает добавить папку в проект при необходимости. Сейчас же наоборот, как правило, приходится сразу исключать node_modules чтобы не мешали поиску по проекту.

          • taliban
            /#19123561

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

      • Goodkat
        /#19122669 / -1

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


        Ну и, чтобы два раза не вставать:

        Отказ от директории node_modules – закономерный шаг, учитывая опыт других языков, где такого подхода не было изначально.
        Это шаг назад, и после других языков, где библиотеки/фреймворки устанавливались глобально (а зависимости нужно было разрешать ручками) я был очень рад всегда иметь под рукой локальную копию всего нужного, которую всегда можно резетнуть и подтянуть автоматически.

    • inoyakaigor
      /#19120897

      Поддерживаю. Довольно часто* приходится лезть в node_modules.
      _______
      * по сравнению с теоретическим отсутствием необходимости вообще туда лезть

    • niko1aev
      /#19123649

      Для IDE это явно не проблема
      в том же Ruby есть rvm, rbenv, папочка .bundle и со всеми вариантами IDE прекрасно работает
      у rvm есть gemset, и IDE прекрасненько подтягивает нужный gemset и GOTO definition замечательно работает

      Не вижу никаких проблем, почему IDE не справятся с таким же подходов в JS

  6. TheShock
    /#19120737

    А меня волнует — как можно будет бороться с подобными ошибками, когда приходится вручную править node-modules:
    toster.ru/q/561727

    • mayorovp
      /#19120903

      Ну так там же файл не просто так дублируется, а из-за конфликта версий. Вот его-то и нужно устранять…

      • TheShock
        /#19120921

        Ну и как устранить конфликт версий у двух typescript-зависимостей? Уж простите, я nodejs редко настраиваю, а беглый гуглинг по «nodejs устранить конфликт версий» ничего не дает.

        • printf
          /#19121209

          На практике обычно форкнуть одну из них.

    • stefashka
      /#19120965

      так, по идее, будет легче — один раз исправил и во всех проектах, которые эту ошибку той же версии используют, всё заработает, как надо.

    • justboris
      /#19120995

      Насколько я понимаю, проблема в том, что транзитивные зависимости тянут за собой разные версии тайпингов (что логично, разные мажорные версии обладают разным API).


      Typescript схлопывает вложенные зависимости в плоский список и ломает вложенные тайпинги. Это проблема самого Typecscript и от способа доставки модулей не зависит.


      Возможно, стоит зарепортить проблему им на Github. Я нашел очень похожую проблему, но там решения так и нет.

      • TheShock
        /#19121015

        А можно зафорсить какую-то из зависимостей использовать другую версию своей зависимости?

        • justboris
          /#19121047

          В ситуации с Yarn — можно попробовать вручную отредактировать yarn.lock, все версии там прописаны. Если пользуетесь npm, аналогичное исправление package-lock.json теоретически тоже должно сработать

      • mayorovp
        /#19121129 / +1

        Добавлю, что это проблема не только Typescript, но и выбранного способа описания модуля у mongoose: они используют вариант declare module, который объявляет модуль в глобальном пространстве имен.

    • maolo
      /#19124679

      Не решение проблемы, но как вариант — форкнуть проект и опубликовать версию пакета для своих целей без проблемной папки?

      • TheShock
        /#19126471

        Она ведь проблемная не сама по себе, а при соединении с другим пакетом

  7. Vasily_T
    /#19121181

    Мда, интересно будет глянуть как это все в итоге будет работать с каким нибудь электроном

  8. DanilaLetunovskiy
    /#19123171

    я тоже добавил в package.json
    «scripts»: { «start»: «npm install && node server.js» },
    чтобы на сервер не загружать папку node_modules
    а чтобы она сама там на сервере уже устанавливалась

    а если вообще не будет этой папки, то это будет хорошо.
    но покачто этово в npm нет, поэтому и не стоило писать статью.

    • Kain_Haart
      /#19124235

      А почему бы не вызывать npm install && npm start из того места где вы сейчас вызываете npm start?

  9. Maksym_Zhuk
    /#19126001

    «Установка зависимостей отнимает большую часть времени сборки в CI-системах, поэтому ускорение этого шага благоприятно скажется на времени билда в целом.»
    В самом деле? Вы либо попадаете в кеш докера, либо все равно вся качаете с нуля, потому что так работает докер, на сколько я знаю. Возможно я ошибаюсь.
    COPY package.json.
    COPY yarn.lock.
    RUN yarn install --frozen-lockfile --ignore-optional

    • justboris
      /#19126011

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


      Новая инициатива минимизирует даже эти затраты.

      • Maksym_Zhuk
        /#19126059

        Докер при каждом шаге создает промежуточные контейнеры, для кеширования и если входящие данные не меняються, тоесть если package.json не меняется, а у вас не должны при каждом обновлении меняться зависимости, то он пропускает эти шаги при следующих билдах, тоесть выиграш для CI там минимальный

        • justboris
          /#19126277

          Это сработает только если вы закешируете package.json отдельно от остальных исходников проекта. Так бывает не всегда, и в других ситуациях глобальный кэш придет на помощь.

          Если вам удобнее настроить кастомную обработку package.json – дело ваше.

          • Maksym_Zhuk
            /#19126609

            Согласен, ситуации бывают разные, но мы решили эту проблему таким образом. B мне казалось это очевидным и простым решением. Потэтому вызвало легкое недоумение, что у кого то есть такие проблемы в CI.