Node.js без node_modules

    На прошлой неделе разработчики 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 еще не влит, стоит ожидать изменений. Но мне было интересно попробовать альфа-версию фичи в деле и протестировать их на паре своих личных проектов и убедиться, что этот подход действительно работает, делая установку быстрее.


    Ссылки


    Поделиться публикацией

    Похожие публикации

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

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


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

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

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

          –2

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

            0

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


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

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

              –1

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

                +1

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

                  +4

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

                  0

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


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

            +2
            CI система, да и вы вызывают запуск 10 раз на дню.
            Так что экономия есть.
            И еще как по мне килер фича: Отсутствие папочки с 10 — 50 — 100 мегабайт мелко нашинкованного ява-скрипта. А если у вас в работе 5-6 проектов?
              0
              Папка с проектами весит 7Гиг, вот это будет экономия.
                0
                100мб ??? Мелко плаваете у нас в проекте node_modules 700mb
                  0
                  А зачем? И в какой бандл они потом билдятся?
                    0
                    node_modules может не только для сборки фронта использоваться ;)
              0

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

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

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

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

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

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

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

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

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


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

                            0

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

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

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

                            +3
                            У node_modules есть замечательное преимущество — код импортируемых библиотек всегда под рукой, можно его посмотреть, поставить точку останова, подебажить и так далее. При новом подходе он будет храниться где-то далеко, и очень хорошо, если IDE сумеет найти код библиотек и обработать точки останова.
                              +2
                              /home/user/.npm_cache/cookie/src
                              /home/user/project/coolstuff/node_modules/cookie/src

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

                                0
                                В том что второй находится в «окружении» проекта, первый же отдельно. Эти пути не настраиваются явно у вас в проекте.
                                  0
                                  Ничто не мешает добавить папку в проект при необходимости. Сейчас же наоборот, как правило, приходится сразу исключать node_modules чтобы не мешали поиску по проекту.
                                    0
                                    я не исключаю, и любая иде/редактор позволяет искать внутри определенной папки. А поиск использования метода в иде обычно исключает «фиктивные папки» ибо это общеизвестны места «левого кода»
                                  0

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


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

                                  Отказ от директории node_modules – закономерный шаг, учитывая опыт других языков, где такого подхода не было изначально.
                                  Это шаг назад, и после других языков, где библиотеки/фреймворки устанавливались глобально (а зависимости нужно было разрешать ручками) я был очень рад всегда иметь под рукой локальную копию всего нужного, которую всегда можно резетнуть и подтянуть автоматически.
                                  –1
                                  Поддерживаю. Довольно часто* приходится лезть в node_modules.
                                  _______
                                  * по сравнению с теоретическим отсутствием необходимости вообще туда лезть
                                    0
                                    Для IDE это явно не проблема
                                    в том же Ruby есть rvm, rbenv, папочка .bundle и со всеми вариантами IDE прекрасно работает
                                    у rvm есть gemset, и IDE прекрасненько подтягивает нужный gemset и GOTO definition замечательно работает

                                    Не вижу никаких проблем, почему IDE не справятся с таким же подходов в JS
                                    0
                                    А меня волнует — как можно будет бороться с подобными ошибками, когда приходится вручную править node-modules:
                                    toster.ru/q/561727
                                      0
                                      Ну так там же файл не просто так дублируется, а из-за конфликта версий. Вот его-то и нужно устранять…
                                        0
                                        Ну и как устранить конфликт версий у двух typescript-зависимостей? Уж простите, я nodejs редко настраиваю, а беглый гуглинг по «nodejs устранить конфликт версий» ничего не дает.
                                          0
                                          На практике обычно форкнуть одну из них.
                                        0
                                        так, по идее, будет легче — один раз исправил и во всех проектах, которые эту ошибку той же версии используют, всё заработает, как надо.
                                          0

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


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


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

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

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

                                                0
                                                Работает.
                                                Но только такие resolution'ы чреваты тем, что при каждом коммите придётся очень внимательно смотреть на изменения в `package-lock.json`, так как любой `npm install` это сбросит.
                                                Хотя как времянка, когда PR в проект, который подобным образом фиксится, уже создан и просто ждёшь аппрува — вполне сгодится.
                                                +1

                                                В yarn можно через resolutions
                                                https://yarnpkg.com/lang/en/docs/selective-version-resolutions/

                                                +1

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

                                                0
                                                Не решение проблемы, но как вариант — форкнуть проект и опубликовать версию пакета для своих целей без проблемной папки?
                                                  0
                                                  Она ведь проблемная не сама по себе, а при соединении с другим пакетом
                                                0
                                                Мда, интересно будет глянуть как это все в итоге будет работать с каким нибудь электроном
                                                  –2
                                                  я тоже добавил в package.json
                                                  «scripts»: { «start»: «npm install && node server.js» },
                                                  чтобы на сервер не загружать папку node_modules
                                                  а чтобы она сама там на сервере уже устанавливалась

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

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

                                                      0
                                                      потомучто я загружаю на хостинг, и он сам там как-то запускает
                                                        0

                                                        можете поделиться, что за хостинг? имхо очень странно что он не делает install самостоятельно

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

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


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

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

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

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

                                                    Самое читаемое