На прошлой неделе разработчики 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
    17.09.2018 01:19

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

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


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


    1. justboris Автор
      17.09.2018 01:36

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

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


      1. k12th
        17.09.2018 11:44
        -1

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


        1. mayorovp
          17.09.2018 12:10
          -1

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


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

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


          1. k12th
            17.09.2018 12:14

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


            1. mayorovp
              17.09.2018 12:16

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


              1. k12th
                17.09.2018 12:27
                +1

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


            1. justboris Автор
              17.09.2018 13:01

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


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


    1. firedragon
      17.09.2018 07:49

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


      1. Focushift
        17.09.2018 14:26

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


      1. tot0ro
        17.09.2018 22:18

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


        1. TheShock
          17.09.2018 23:20

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


          1. dzhiriki
            18.09.2018 10:48

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


  1. codemafia
    17.09.2018 09:05

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


    1. firedragon
      17.09.2018 09:30

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

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


    1. justboris Автор
      17.09.2018 10:00

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


  1. Methos
    17.09.2018 10:20
    +1

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


  1. jehy
    17.09.2018 11:26
    +1

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

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


    1. mayorovp
      17.09.2018 12:12

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


      1. jehy
        17.09.2018 12:25

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


        1. TheShock
          17.09.2018 13:17

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


    1. kalyukdo
      17.09.2018 12:12

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


    1. justboris Автор
      17.09.2018 13:07

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


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


      1. Yeah
        17.09.2018 22:57

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


        1. justboris Автор
          18.09.2018 00:27

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


    1. Acionyx
      17.09.2018 13:07

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


  1. topa
    17.09.2018 11:31
    +2

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


    1. firedragon
      17.09.2018 12:53
      +1

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

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


      1. taliban
        17.09.2018 18:08

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


        1. Aingis
          17.09.2018 21:46

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


          1. taliban
            18.09.2018 00:11

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


      1. Goodkat
        17.09.2018 19:41
        -1

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


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

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


    1. inoyakaigor
      17.09.2018 13:52

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


    1. niko1aev
      18.09.2018 00:45

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

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


  1. TheShock
    17.09.2018 13:20

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


    1. mayorovp
      17.09.2018 13:53

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


      1. TheShock
        17.09.2018 13:56

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


        1. printf
          17.09.2018 14:49

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


    1. stefashka
      17.09.2018 14:07

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


    1. justboris Автор
      17.09.2018 14:14

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


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


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


      1. TheShock
        17.09.2018 14:17

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


        1. justboris Автор
          17.09.2018 14:25

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


        1. shoomyst
          18.09.2018 15:50
          +1

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


      1. mayorovp
        17.09.2018 14:39
        +1

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


    1. maolo
      18.09.2018 11:00

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


      1. TheShock
        18.09.2018 16:19

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


  1. Vasily_T
    17.09.2018 14:46

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


  1. DanilaLetunovskiy
    17.09.2018 21:57

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

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


    1. Kain_Haart
      18.09.2018 09:37

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


  1. Maksym_Zhuk
    18.09.2018 14:45

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


    1. justboris Автор
      18.09.2018 14:47

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


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


      1. Maksym_Zhuk
        18.09.2018 14:55

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


        1. justboris Автор
          18.09.2018 15:42

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

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


          1. Maksym_Zhuk
            18.09.2018 16:57

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