Diarrhea для вашего бэкенда на Node.JS — уменьшаем вес сборки

    Наверняка вы часто замечали, сколько всякого мусора лежит внутри node modules. Это тесты, бенчмарки, ридми файлы, лицензии, тайпскрипт, и ещё безумное количество мусора, который можно более-менее безопасно удалить. Что мы собственно и сделаем в этом посте.
    Картинку про вес node module я и так упоминал последние несколько публикаций, так что вот вам другая, которая в целом отражает текущую ситуацию. В качестве саундтрека к посту рекомендуется Little Big, “Life in da trash”.



    Начало


    Итак, в очередной раз случайно зайдя внутрь node modules, я погрустил, и начал я с того, что для эксперимента руками написал несколько скриптов, которые прогнал через свой проект. Выглядели они примерно так:


    find ./ -iname "tests" -exec rm -rf {} \;
    find ./node_modules -iname "test" -exec rm -rf {} \;
    find ./node_modules -iname "*.gif" -exec rm -rf {} \;
    find ./node_modules -iname "*.jpg" -exec rm -rf {} \;
    find ./node_modules -iname "*.jpeg" -exec rm -rf {} \;
    find ./node_modules -iname "*.png" -exec rm -rf {} \;
    find ./ -iname "*.md" -exec rm -rf {} \;
    find ./ -iname "*.log" -exec rm -rf {} \;
    find ./ -iname "LICENSE*" -exec rm -rf {} \;
    find ./ -iname "README*" -exec rm -rf {} \;
    find ./ -iname "LICENCE*" -exec rm -rf {} \;
    find ./ -iname "AUTHORS*" -exec rm -rf {} \;
    find ./ -iname "NOTICE*" -exec rm -rf {} \;
    find ./ -iname "changelog*" -exec rm -rf {} \;
    find ./ -iname ".travis.yml" -exec rm -rf {} \;
    find ./ -iname ".coveralls.yml" -exec rm -rf {} \;
    find ./ -iname ".npmignore" -exec rm -rf {} \;
    find ./ -iname ".gitignore" -exec rm -rf {} \;
    find ./ -iname ".jshintrc" -exec rm -rf {} \;
    find ./ -iname ".eslintrc" -exec rm -rf {} \;
    find ./ -iname ".jscs.json" -exec rm -rf {} \;
    find ./ -iname ".editorconfig" -exec rm -rf {} \;
    find ./ -iname ".vs" -exec rm -rf {} \;
    find ./ -iname ".babelrc" -exec rm -rf {} \;

    В целом, это было уже неплохо, и давало некую экономию, но хотелось какого-то более элегантного и общего решения. И достаточно быстро я нашёл модуль, который делает именно то, что мне хотелось — ModClean.


    Modclean


    У модуля есть куча настроек, а ещё списки “лишних файлов” в виде плагинов, которые подключаются как npm пакеты. По умолчанию идёт вот такой список.


    В нём есть три уровня файлов по безопасности удаления — очень рекомендую посмотреть список и увидеть, что может вызвать у вас проблемы. Например, я не понял, с какой стати в список включили “semver”...


    Единственный минус — почему-то автор модуля не подумал, что вы так же можете захотеть удалить весь мусор перед сборкой в собственном проекте — например, вам абсолютно не нужны файлы тестов на продакшне…
    Правда, оказалось, что это легко обойти, указав в качестве директории модулей директорию проекта:


    modclean --modules-dir .

    Результаты


    Понятно, что результаты могут быть самые разные на разных проектах, но у меня вышло от 10% до 20% экономии. В запущенных случаях может быть и больше. Это может показаться не самой большой цифрой, но это уменьшение требуемого для хранения места на 20% и более быстрая на 20% раскатка проектов буквально из ничего — поэтому почему бы и нет?
    Для любопытных на сайте модуля так же есть бенчмарки по удалению, но лучше просто попробовать его у себя.


    Diarrhea


    На этой позитивной ноте хотелось бы сказать, что я решил свою задачу, и всем рекомендую использовать этот прекрасный модуль. Но вы же видели в заголовке слово “diarrhea”, а не “modclean”, да? Это значит, что остался ещё один нюанс. Нюанс довольно толстый в прямом смысле слова — оказалось, что у модуля 123 транзитивных зависимости и он весит 4МБ. Что в целом уже считается более-менее стандартом для модулей ноды, но не может не доставить душевной боли.


    При этом 1.2 мегабайта занимает дико популярный модуль update-notifier, всё что он делает — это отрисовывает вот такую рамочку в случае, если доступны обновления:

    1 мегабайт на это, Карл! Мне, как Node.JS разработчику, хочется провалиться от стыда.


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


    В общем, я не смог этого вынести, и сделал свою сборку — без блекджека и всего остального. Уведомление об апдейтах я оттуда полностью выпилил, а нужные функции из толстых модулей с большим количеством зависимостей я собрал в минифицированный bundle при помощи webpack. В целом, можно было ещё немного поиграть и ещё уменьшить вес модуля — но я дошёл до веса в 700 килобайт и пока успокоился. Так же я добавил опцию --root, которая позволяет более очевидно чистить не зависимости, а корень вашего проекта.


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


    Установить мою версию можно, как всегда, из npm.


    Предупреждение


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


    Интеграция в CI


    Лучше всего использовать diarrhea так:


    1. Сначала сносим весь мусор из node modules;
    2. Потом прогоняем юнит тесты, чтобы убедиться, что не снесли ничего лишнего;
    3. Потом сносим мусор из основного проекта, включая собственно тесты;
    4. Делаем npm prune --production;
    5. Собираем артефакт.

    ToDo


    Модуль делался на скорую руку для личного использования, поэтому в перспективе хотелось бы добавить следующее:


    • Тесты. Как ни странно, в modClean их вообще нет;
    • Совместимость с нодой до 8ой версии. Так как там уже webpack, то не сложно;
    • Выпилить ещё несколько зависимостей, в которых нет особого смысла;
    • Минимальную версию без терминальных украшательств для CI

    Выводы


    • В который раз пугаюсь тому, какие жуткие вещи творит низкий порог вхождения в мою любимую экосистему. С этим определённо нужно что-то делать, и я надеюсь, что это будет как-то решено на уровне npm. Но прямо сейчас я вижу, что этот прекрасный update-notifier находится в зависимостях самого npm, что очень сильно огорчает. У этого несчастного нотифаера — 2 миллиона загрузок в неделю! 2 миллиона мегабайт (почти 2 терабайта) потрачено на долбаную рамочку с сообщением об обновлении!
    • Всегда проверяйте вес своих зависимостей, даже на бэкенде;
    • Чистите ваши зависимости;
    • Как ни странно, понемного прихожу к мысли о том, что бэкендовые зависимости тоже нужно собирать в общий бандл. Что сейчас делается довольно легко и непринуждённо любым выбранным вами инструментом, и не добавляет боли при отладке — ведь можно сгенерировать сурсмапы. Единственный минус — далеко не каждый пакет можно включить в бандл, регулярно сталкиваюсь с какими-то проблемами в этом, по большей части связанными с тем, что все любят как попало подключать динамические зависимости. Например, тот же update-notifier в бандл засунуть нельзя.

    Ну что же, на этом всё.


    Если вам было интересно, то вам так же могут понравиться мои другие статьи по этой теме:



    Так же могу рассказать ещё о различных случаях упоротого использования Node.JS на бэкенде, участвуйте в опросе, если вам интересно.

    Only registered users can participate in poll. Log in, please.

    О чём ещё рассказать?

    • 11.6%Зачем и как использовать mocha в несколько потоков5
    • 27.9%Как внедрять линтинг в проекте, где 4000 файлов содержат огромное количество ошибок12
    • 7.0%Зачем и как грузить логи из кибаны в MySQL или Slack бота3
    • 41.9%Зачем собирать backend модули в bundle18
    • 11.6%Ни о чём, прекратите уже эту ересь и переходите на Go5
    Ads
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More

    Comments 31

      0
      Что думаете о сборке всего бекенда в 1 файл вебпаком?
        0
        Вот примерно это и думаю делать в будущем. Как уже сказал — вполне можно это делать с сурсмапами, отладка при этом не страдает. И вроде бы ничего не ломается при минификации. Вроде бы. Но, как опять же сказал, многие модули не готовы к сборке — то динамические зависимости, то ещё какая-то фигня. Но я знаю людей, которые успешно это делают в продакшне — а заодно это даёт им использовать все последние возможности JS вне зависимости от версии и поддержки Node.JS (правда, оптимальность таких транспиляций — это отдельный вопрос).

        И я не уверен, что webpack для этого оптимален, надо сверять различные инструменты. Например, свой хитрый минификатор есть у фейсбука — но у меня он с грохотом ломал всё на самых примитивных вещах.
          0

          В связи с пассажем про динамические зависимости был очень удивлен, что пункт "Как правильно делать динамические зависимости" не включен в опрос. Кстати, я правильно понимаю, что это о том, как использовать опциональные зависимости в своем проекте?

            0
            Прошу прощения, не понял вопрос, уточните, пожалуйста.
              0

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


              Единственный минус — далеко не каждый пакет можно включить в бандл, регулярно сталкиваюсь с какими-то проблемами в этом, по большей части связанными с тем, что все любят как попало подключать динамические зависимости. Например, тот же update-notifier в бандл засунуть нельзя.

              Я об этом

                +1
                А, понял. На отдельный пост вряд ли потянет, поэтому дам развёрнутый комментарий:

                1. Следует избегать динамических require() — то есть, тех, которые осуществляются не при подгрузке скрипта, а внутри функций.

                Потому что динамическими реквайрами вы замедляете приложение (ему приходится догружать модули в процессе работы), а так же делаете процесс загрузки потенциально различным. Чем это опасно:

                1.1 Если у вас не дай бог мутируют глобальные объекты или прототипы, то финальная мутация будет зависеть от последовательности загрузки.
                1.2. Очень легко в какой-то момент наступить на всякие противные вещи вроде циклических зависимостей — особенно если вы используете частичные экспорты.
                1.3 Может выйти так, что в какой-то момент у вас не подгружен или не инициализирован тот модуль, который вы считали уже работающим.

                2. Стоит избегать загрузки модулей из переменных. Это мало того, что небезопасно, но приводит к тем же проблемам, что и в пункте 1, плюс даёт возможность в какой-то момент не найти нужный модуль. Ну и у вас умирает половина возможностей по статическому анализу, включая линтинг, покрытие тестами, автоматический рефакторинг, подсказки IDE и так далее.

                3. Стоит избегать загрузки сущностей по строковым именам. Например, видел примеры фабрик, которые инстанциируют классы по переданным им строкой именам. Не надо так! Опасности в целом те же, что и в пункте 2.

                Ну и плюс к этому — практики 2 и 3 ведут к тому, что из вашего модуля нельзя собрать бандл — это может быть менее критично для бэка, но очень критично, если модуль может быть использован во фронте.

                Конечно, всегда могут быть исключения — например, если вы хотите сделать отложенную загрузку модулей для более быстрого старта приложений. Но надо осознавать, что этим вы втыкаете в проект костыль, на конце которого находятся грабли — и рано или поздно вы на это наступите. И вообще, если у вас слишком долго стартует приложение — это просто повод разбить его на несколько приложений.
        0
        При этом 1.2 мегабайта занимает дико популярный модуль update-notifier

        update-notifier@2.5.0 — итого 803 кб, 75 зависимостей, включая транзитивные. С учётом размера кластера да, может быть и мегабайт, и два.


        Однако же, справедливости ради, update-notifier не просто показывает окошко, но сначала лезет в реестр NPM и смотрит там версии, а затем сравнивает с локально установленной. Вот эта вся инфраструктура и весит столько. В самом npm она, наверное, тоже используется.

          0

          Почему-то все инструменты мне показывают разные результаты, так что сделал так:


          mkdir test
          cd test
          npm init
          npm i update-notifier --only=production
          du -sh --apparent-size ./node_modules/

          Выходит "1,2M ./node_modules/"


          Если что не так делаю — поправьте, пожалуйста.


          Насчёт "лезть в реестр" — ну блин, это ж один http запрос, который должен делаться нативными средствами.

            0

            А в какой реестр делать этот запрос?

              +1
              Посмотрел, как это делает update-notifier.
              1. В его зависимостях есть www.npmjs.com/package/latest-version
              2. latest-version делает это через www.npmjs.com/package/package-json
              3. А он обращается к регистри через www.npmjs.com/package/registry-url

              Для публичных модулей это всего лишь запрос к registry.npmjs.org/ — например, такой:
              registry.npmjs.org/diarrhea?version=3.0.5
              Для непубличных — да, чуть сложнее — надо подставить свой регистри и передать токен.

              Но это вот вообще не повод цеплять столько транзитивных зависимостей с таким весом.
              Как минимум, можно было бы собирать бандл.
              А я бы вообще их импортировал копи-пейстом, потому что очень важно минимизировать размер таких модулей.
                0

                Там основной вес дают got и registry-auth-token.


                Кстати говоря, got (от того же Sindre Sorhus) весит 853K. Альтернативы: request (4,8M), axios (443K), node-fetch (111K).

                  0
                  Да это вообще жесть, что для такой базовой вещи, как http запросы, приходится ставить сторонние модули. Причём в большом проекте обычно стоит пачка разных модулей и их версий в транзитивных зависимостях.
                    0
                    А знаете что забавнее?
                    То — зачем 'request' и 'node-fetch', когда этот функционал пишется самостоятельно из уже существующего API node.js в 10-15 строк? Объясните, пожалуйста, кто-нибудь: зачем так делать, и что это за наркомания, где на каждый «чих» ставить из NPM пакеты, к-е тянут другие пакеты, к-е тянут д…
                      0
                      Всякий там request это офигительно удобно, когда у тебя огромный бэкенд проект, и надо в куче разных форматах обращаться к разным сервисам и обрабатывать ответ.

                      Но в данном случае, когда у тебя всего полтора варианта запроса — всё именно так, как вы описали — наркомания.
                        +1

                        Решение в 10-15 строк, повторенное в нескольких проектах, рискует инкапсулироваться в пакет, где начнёт обрастать фичами :)

                          0

                          И да, просто оставлю здесь пример из официальной документации:


                          http.get('http://nodejs.org/dist/index.json', (res) => {
                            const { statusCode } = res;
                            const contentType = res.headers['content-type'];
                          
                            let error;
                            if (statusCode !== 200) {
                              error = new Error('Request Failed.\n' +
                                                `Status Code: ${statusCode}`);
                            } else if (!/^application\/json/.test(contentType)) {
                              error = new Error('Invalid content-type.\n' +
                                                `Expected application/json but received ${contentType}`);
                            }
                            if (error) {
                              console.error(error.message);
                              // consume response data to free up memory
                              res.resume();
                              return;
                            }
                          
                            res.setEncoding('utf8');
                            let rawData = '';
                            res.on('data', (chunk) => { rawData += chunk; });
                            res.on('end', () => {
                              try {
                                const parsedData = JSON.parse(rawData);
                                console.log(parsedData);
                              } catch (e) {
                                console.error(e.message);
                              }
                            });
                          }).on('error', (e) => {
                            console.error(`Got error: ${e.message}`);
                          });

                          "Наркомания с пакетами" идёт как раз оттого, что системное API слишком низкоуровневое для большинства приложений. Вдобавок, конечным пользователям хочется не только GET-запросы делать, но и POST, и чтобы редиректы автоматически обрабатывались, и сжатый контент правильно распознавался. Плюс какая-никакая, но поддержка, фиксы багов, документация.

                            0

                            Так если нужны редиректы, POST, PUT, и прочее прочее — никто не спорит.


                            Но если мы говорим о модулях, в которых один(!!!) стандартный запрос, то получение версии пакета для update-notifier может выглядеть вот так:


                            const https = require('https');
                            
                            function getPackageVersion(packageName) {
                              return new Promise((resolve, reject) => {
                                let rawData = '';
                                https.get(`https://registry.npmjs.org/${packageName}`, (res) => {
                                  res.on('data', (d) => rawData += d)
                                    .on('error', (e) => reject(e))
                                    .on('end', () => {
                                    try {
                                      const parsedData = JSON.parse(rawData);
                                      resolve(parsedData['dist-tags'].latest);
                                    }
                                    catch (e) {
                                      reject(e);
                                    }
                                  });
                                });
                              })
                            }
                            
                            getPackageVersion('diarrhea')
                              .then((version) => console.log(`last version: ${version}`))
                              .catch((err) => {
                                console.log(`fetch error: ${err}`)
                              });

                            И это ещё и с промисами.
                            Собственно, для полного варианта не хватает только переключения http/https и подстановки токена. И не надо никакую библиотеку тащить.

                              0

                              Не спорю и двумя руками за. Как думаете, есть возможность протащить условный better-update-notifier в npm?

                                0
                                Попробовать стоит — как минимум, написать его, написать куда-нибудь на медиум статью, закинуть нескольким авторитетным разрабочикам и создать issue в npm (вряд ли на них особо обращают внимание, но всё же).
                              0
                              При всём при этом обратите внимание, что в итоге всё равно не выйдет 2МБ кода. :).
                              Фишки и плюшки для каждого проекта можно оставить свои.
                0
                Давным давно существует node-prune от прекрасного разработчика. В чем эта поделка выигрывает?
                  0

                  Если честно, просто не нашёл node-prune. Но он какой-то совсем куцый, и вообще никак не настраивается, из самого важного:


                  • Нельзя настроить шаблоны для удаляемых файлов
                  • Нельзя прогнать в тестовом режиме и узнать, что ты удаляешь
                  • Нельзя залогировать, что ты удалил
                    –1
                    Ну вот с первым пунктом не соглашусь точно. Исходники перед глазами, даже первокурсник сможет поправить.
                    Остальные можно реализовать, под свои нужды.
                      +1
                      1. Зачем тащить ещё и Go на свои сервера? Даже если в вида бинарника. Кстати, архив бинарника весит 640 килобайт, а разархивированный — 2 метра.
                      2. Зачем править исходники на Go, когда есть внятная кастомизация на уровне модулей и опций cli?
                  0

                  А как вы оптимизируете update-notifier, если он у вас вот здесь закомменчен?

                    0

                    Очень сурово оптимизирую, о чём честно написал в посте.


                    Уведомление об апдейтах я оттуда полностью выпилил

                    Будет время — может, соберу для демонстрации человеческую версию update-notifier, которая весит, сколько должна.

                      0

                      Мне почему-то показалось, что ваш тул умеет заменять update-notifier на его упакованную версию без потерь функциональности.


                      Теперь перечитал повнимательнее, оказалось, что вы просто удалили update-notifier из зависимостей совсем. Так неитересно, npm таким образом похудеть не заставишь.

                        0
                        Хотел его оптимизировать, но это оказалось небыстро — в бандл его не соберёшь, надо форкать и править. Будет время — обязательно это сделаю.
                    0
                    прочитал и ничего не понял:
                    вы сначала выкачиваете все это а потом что-то удаляете???
                    зачем? если уже выкачали?
                    не ставьте всякий мусор или вас таки очень маленький диск?
                    так идите и купите нормальный — их много и они не дорогие

                    вопрос кардинально решается установкой нужных и правильных зависимостей
                      +1
                      Вы правда ничего не поняли.
                      Выкачать зависимости без включаемого в них мусора нельзя.
                      Удаляется оно перед сборкой артефакта, который потом будет раскатан по серверам.
                      Экономия в 100 мегабайт для каждого собираемого билда, с учётом, что мы собираем этак по 300 билдов в сутки — 30 гигабайт в сутки (на самом деле больше, так как это только для одного проекта). Плюс более быстрый деплой засчёт меньшего размера артефакта.
                        0
                        Прошу прощения, но данная идея не была так раскрыта как вы описали ее в комментарии поэтому так и прокомментировал поэтому и не понял

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