На прошлой неделе разработчики Yarn (пакетного менеджера для Javascript) анонсировали новую фичу – Plug'n'Play установку. Эта возможность позволяет запускать Node.js проекты без использования папки node_modules, в которую обычно устанавливаются зависимости проекта перед запуском. Описание фичи декларирует, что node_modules больше не понадобится – модули будут загружаться из общего кеша пакетного менеджера.
Одновременно с ними разработчики NPM также анонсировали свое аналогичное решение проблемы.
Давайте посмотрим на эти решения повнимательнее и попробуем протестировать их в реальных проектах.
История проблемы
Изначально модульная система NodeJS была полностью основана на файловой системе. Любой вызов require()
маппится на файловую систему. Для организации third-party модулей была придумана папка node_modules, в которую должны скачиваться и устанавливаться переиспользуемые модули и библиотеки. Таким образом, каждый проект получал свой отдельный набор зависимостей, нерационально расходуя дисковое пространство.
Установка зависимостей отнимает большую часть времени сборки в CI-системах, поэтому ускорение этого шага благоприятно скажется на времени билда в целом.
Упрощенно, установка модулей состоит из следующих шагов:
- Вычисляется конкретная версия модуля из допустимого интервала
- Все модули необходимых версий выкачиваются из репозитория и сохраняются в локальный кеш
- Модули из локального кеша копируются в папку 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)
codemafia
17.09.2018 09:05А чем такой подход отличается от обычной глобальной установки?
firedragon
17.09.2018 09:30Во первых не нужно быть рутом. Во вторых большая гранулярность по версиям.
В третьих сайд-эффекты уменьшаются и четко контролируются версии.
Возможно есть еще какие соображения.
justboris Автор
17.09.2018 10:00- Глобальная установка делает доступными только исполняемые команды, например: eslint, webpack и т.д.
require('module-name')
работать не будет - Даже если исправить первый пункт, все равно останется проблема разных версий. Глобально можно установить только одну версию модуля, а из кеша можно смаппить несколько разных мажорных версий для разных проектов.
- Глобальная установка делает доступными только исполняемые команды, например: eslint, webpack и т.д.
Methos
17.09.2018 10:20+1еще больше запутали логику
потом будут распутывать
потом распутывать нараспутывание
и т. д.
jehy
17.09.2018 11:26+1Очень странная мысль о том, что это как-то поможет CI. Откуда возьмётся локальный кеш-то? Сначала сборку у себя делает разработчик, потом один CI собирает билд, а другой CI раскатывает этот билд на сервера. Ни у одного из CI серверов при этом кеша может не быть, и обычно CI делается в контейнерах, где кеша точно не будет. Так что кому и чем это может помочь — загадка. Ну разве что разработчику локально.
У нас используется утилитка npm-cache, которая позволяет собрать кеш в архив и эти архивы монтировать на CI машинки. Утилита несколько заброшенная, но я её допиливал, если кому интересно.mayorovp
17.09.2018 12:12И что, эти контейнеры еще и сбрасываются в начальное состояние перед каждой сборкой? Ну, хозяин-барин конечно, но не все же так делают.
kalyukdo
17.09.2018 12:12они похоже что то подобное будут делать, когда будет запущен yarn, он положит в свой кеш и потом оттуда будет отдавать зависимости
justboris Автор
17.09.2018 13:07Предполагается, что у CI-системы есть кеш, который сохраняется между билдами. Например, в travis-ci можно включить сохранение кэша Yarn.
Рабочая директория билда всегда новая, а вот кэш из домашней директории пользователя может и сохраняться, ничего плохого в этом нет.
Yeah
17.09.2018 22:57Так это можно делать и с node_modules. В чем профит?
justboris Автор
18.09.2018 00:27node_modules содержит зависимости конкретно под этот проект, описанные в package.json. Если там что-то поменяется, кэширование node_modules билду только навредит. А глобальный кеш –универсальный, это просто локальная копия удаленного репозитория, без зависимости от того, что происходит с package.json.
Acionyx
17.09.2018 13:07Это же зависит от вашего инструмента для CI. Хотя странно, большинство точно умеют в кеш между разными заработало этапами процессов CI&CD
topa
17.09.2018 11:31+2У node_modules есть замечательное преимущество — код импортируемых библиотек всегда под рукой, можно его посмотреть, поставить точку останова, подебажить и так далее. При новом подходе он будет храниться где-то далеко, и очень хорошо, если IDE сумеет найти код библиотек и обработать точки останова.
firedragon
17.09.2018 12:53+1/home/user/.npm_cache/cookie/src
/home/user/project/coolstuff/node_modules/cookie/src
В чем разница для ide?
taliban
17.09.2018 18:08В том что второй находится в «окружении» проекта, первый же отдельно. Эти пути не настраиваются явно у вас в проекте.
Aingis
17.09.2018 21:46Ничто не мешает добавить папку в проект при необходимости. Сейчас же наоборот, как правило, приходится сразу исключать node_modules чтобы не мешали поиску по проекту.
taliban
18.09.2018 00:11я не исключаю, и любая иде/редактор позволяет искать внутри определенной папки. А поиск использования метода в иде обычно исключает «фиктивные папки» ибо это общеизвестны места «левого кода»
Goodkat
17.09.2018 19:41-1Тем, что если при отладке одного проекта я где-то глубоко в коде чужого модуля понаставлю логгеры, хуки, заглушки и точки останова, то они вылезут при сборке какого-нибудь другого проекта, которых у меня может быть параллельно десяток в поддержке.
Ну и, чтобы два раза не вставать:
Отказ от директории node_modules – закономерный шаг, учитывая опыт других языков, где такого подхода не было изначально.
Это шаг назад, и после других языков, где библиотеки/фреймворки устанавливались глобально (а зависимости нужно было разрешать ручками) я был очень рад всегда иметь под рукой локальную копию всего нужного, которую всегда можно резетнуть и подтянуть автоматически.
inoyakaigor
17.09.2018 13:52Поддерживаю. Довольно часто* приходится лезть в node_modules.
_______
* по сравнению с теоретическим отсутствием необходимости вообще туда лезть
niko1aev
18.09.2018 00:45Для IDE это явно не проблема
в том же Ruby есть rvm, rbenv, папочка .bundle и со всеми вариантами IDE прекрасно работает
у rvm есть gemset, и IDE прекрасненько подтягивает нужный gemset и GOTO definition замечательно работает
Не вижу никаких проблем, почему IDE не справятся с таким же подходов в JS
TheShock
17.09.2018 13:20А меня волнует — как можно будет бороться с подобными ошибками, когда приходится вручную править node-modules:
toster.ru/q/561727mayorovp
17.09.2018 13:53Ну так там же файл не просто так дублируется, а из-за конфликта версий. Вот его-то и нужно устранять…
stefashka
17.09.2018 14:07так, по идее, будет легче — один раз исправил и во всех проектах, которые эту ошибку той же версии используют, всё заработает, как надо.
justboris Автор
17.09.2018 14:14Насколько я понимаю, проблема в том, что транзитивные зависимости тянут за собой разные версии тайпингов (что логично, разные мажорные версии обладают разным API).
Typescript схлопывает вложенные зависимости в плоский список и ломает вложенные тайпинги. Это проблема самого Typecscript и от способа доставки модулей не зависит.
Возможно, стоит зарепортить проблему им на Github. Я нашел очень похожую проблему, но там решения так и нет.
TheShock
17.09.2018 14:17А можно зафорсить какую-то из зависимостей использовать другую версию своей зависимости?
justboris Автор
17.09.2018 14:25В ситуации с Yarn — можно попробовать вручную отредактировать
yarn.lock
, все версии там прописаны. Если пользуетесь npm, аналогичное исправлениеpackage-lock.json
теоретически тоже должно сработать
shoomyst
18.09.2018 15:50+1В yarn можно через
resolutions
https://yarnpkg.com/lang/en/docs/selective-version-resolutions/
mayorovp
17.09.2018 14:39+1Добавлю, что это проблема не только Typescript, но и выбранного способа описания модуля у mongoose: они используют вариант
declare module
, который объявляет модуль в глобальном пространстве имен.
Vasily_T
17.09.2018 14:46Мда, интересно будет глянуть как это все в итоге будет работать с каким нибудь электроном
DanilaLetunovskiy
17.09.2018 21:57я тоже добавил в package.json
«scripts»: { «start»: «npm install && node server.js» },
чтобы на сервер не загружать папку node_modules
а чтобы она сама там на сервере уже устанавливалась
а если вообще не будет этой папки, то это будет хорошо.
но покачто этово в npm нет, поэтому и не стоило писать статью.Kain_Haart
18.09.2018 09:37А почему бы не вызывать
npm install && npm start
из того места где вы сейчас вызываетеnpm start
?
Maksym_Zhuk
18.09.2018 14:45«Установка зависимостей отнимает большую часть времени сборки в CI-системах, поэтому ускорение этого шага благоприятно скажется на времени билда в целом.»
В самом деле? Вы либо попадаете в кеш докера, либо все равно вся качаете с нуля, потому что так работает докер, на сколько я знаю. Возможно я ошибаюсь.
COPY package.json.
COPY yarn.lock.
RUN yarn install --frozen-lockfile --ignore-optionaljustboris Автор
18.09.2018 14:47В этом треде уже объяснялось, что даже в идельном закешированном окружении остаются затраты на копирование файлов из кеша в проект.
Новая инициатива минимизирует даже эти затраты.
Maksym_Zhuk
18.09.2018 14:55Докер при каждом шаге создает промежуточные контейнеры, для кеширования и если входящие данные не меняються, тоесть если package.json не меняется, а у вас не должны при каждом обновлении меняться зависимости, то он пропускает эти шаги при следующих билдах, тоесть выиграш для CI там минимальный
justboris Автор
18.09.2018 15:42Это сработает только если вы закешируете package.json отдельно от остальных исходников проекта. Так бывает не всегда, и в других ситуациях глобальный кэш придет на помощь.
Если вам удобнее настроить кастомную обработку package.json – дело ваше.Maksym_Zhuk
18.09.2018 16:57Согласен, ситуации бывают разные, но мы решили эту проблему таким образом. B мне казалось это очевидным и простым решением. Потэтому вызвало легкое недоумение, что у кого то есть такие проблемы в CI.
k12th
То есть теперь установка будет занимать 19+3 (прогрев кэша и установка из кэша) секунды, вместо 19 (напомню, что скачанные модули кэшируются в любом случае). О — оптимизация.
Плюс теперь надо весь тулинг переписывать. Сэкономили человеко-часы, ничего не скажешь.
justboris Автор
Не совсем так. 19 секунд занимает копирование файлов из кеша в node_modules, по сравнению с 3 секундами на создание файла с маппингами. Если вы уже однажды установили модули и их версии сохранились в
yarn.lock
, то установка этого проекта c--pnp
будет всегда занимать ~3 секунды.k12th
Если я уже однажды устанавливал модули и они сохранились в кэше, то повторная установка займет… ну может не 3 секунды, а уже 6, но разница непринципиальная.
mayorovp
Ну вы же сами цитировали:
Не 3, не 6, а целых 19 секунд занимает тупое копирование файлов...
k12th
Нет, это с разрешением зависимостей, скачиванием файлов (и их распаковкой).
mayorovp
Судя по процитированному вами фрагменту, эти оба времени замерялись уже после одного холодного запуска, то есть при полном кеше. Так что скачивание файлов сюда точно не входило.
k12th
Окей, был неправ, признаю и приношу извинения. Выигрыш во времени установки действительно есть.
justboris Автор
mayorovp правильно говорит. Скачивания файлов и распаковки здесь не происходит. В кеше они уже хранятся в распакованном виде.
Для чистоты эксперимента я повторил эти операции с флагом
--offline
и отключенным интернетом, цифры остались те же.firedragon
CI система, да и вы вызывают запуск 10 раз на дню.
Так что экономия есть.
И еще как по мне килер фича: Отсутствие папочки с 10 — 50 — 100 мегабайт мелко нашинкованного ява-скрипта. А если у вас в работе 5-6 проектов?
Focushift
Папка с проектами весит 7Гиг, вот это будет экономия.
tot0ro
100мб ??? Мелко плаваете у нас в проекте node_modules 700mb
TheShock
А зачем? И в какой бандл они потом билдятся?
dzhiriki
node_modules может не только для сборки фронта использоваться ;)