Предисловие


Не так давно проект, на котором я работаю в данный момент, начал использовать модульную систему ES2015. Я не буду заострять внимание на этой технологии JavaScript, т.к статья совсем не об этом, а о том как технология сподвигла меня к одной мысли.


Как многие знают, ES2015 Modules представляют собой импортирование/экспортирование скриптов крайне схожее по синтаксису с python и многими другими языками программирования. Пример:


// Helper.js
export function includes(array, variable) {
    return array.indexOf(variable) !== -1;
}

// main.js
import {includes} from 'Helper';

assets(includes([1,2,3], 2), true);

Все, кто интересовался модулями JavaScript знают, что импортирование и экспортирование возможно только на верхнем уровне модуля (файла с кодом).


Следующий грубый пример кода вызовет ошибки:


// sendEmail.js
export default function sendEmails(emails_list) {
    import sender from 'sender';
    export sender;

    // сделать что-то
}

Exception: SyntaxError: import/export declarations may only appear at top level of a module

В отличие от ES2015 Modules — в модульной системе node.js импортирование и экспортирование возможны на любом уровне вложенности.


Аналогичный код на node.js не вызовет ошибку:


// sendEmail.js
module.exports = function sendEmails(emails_list) {
    const sender = require('sender');
    exports.sender = sender;

    // сделать что-то
}

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


Основные минусы:


  1. Об отсутствии модуля вы узнаете только во время вызова соответствующего обработчика
  2. Путь к импортироемому модулю может измениться, что приведет к изменению в каждом месте импортирования (например, в вашем модуле, в различных обработчиках используется lodash/object/defaults и вы решили обновиться до 4.x версии, где подключать нужно lodash/defaults).

Разбор полетов


В большинстве задач для которых используется node.js — front-end или основной веб-сервер, и высокая нагрузка на node.js частое явление. Пропуская способность вашего сервера должны быть максимально возможная.


Измерение пропускной способности


Для измерения пропускной способности веб-сервера используется великолепная утилита от Apache — ab. Если вы еще с ней не знакомы, то настоятельно рекомендую это сделать.


Код веб-сервера одинаков за исключением обработчиков.
Тест запускался на node.js 6.0 с использованием модуля ifnode, сделанного на базе express


Импортирование модулей непосредственно в обработчик


Код:


const app = require('ifnode')();
const RequireTestingController = app.Controller({
    root: '/',
    map: {
        'GET /not_imported': 'notImportedAction'
    }
});

RequireTestingController.notImportedAction = function(request, response, next) {
    const data = {
        message: 'test internal and external require'
    };

    const _defaults = require('lodash/object/defaults');
    const _assign = require('lodash/object/assign');
    const _clone = require('lodash/lang/clone');

    response.ok({
        _defaults: _defaults(data, {
            lodash: 'defaults'
        }),
        _assign: _assign(data, {
            lodash: 'assign'
        }),
        _clone: _clone(data)
    });
};

Результат:


$ ab -n 15000 -c 30 -q "http://localhost:8080/not_imported"

Server Hostname:        localhost
Server Port:            8080

Document Path:          /not_imported
Document Length:        233 bytes

Concurrency Level:      30
Time taken for tests:   4.006 seconds
Complete requests:      15000
Failed requests:        0
Total transferred:      6195000 bytes
HTML transferred:       3495000 bytes
Requests per second:    3744.32 [#/sec] (mean)
Time per request:       8.012 [ms] (mean)
Time per request:       0.267 [ms] (mean, across all concurrent requests)
Transfer rate:          1510.16 [Kbytes/sec] received

Percentage of the requests served within a certain time (ms)
  50%      6
  66%      7
  75%      8
  80%      8
  90%     10
  95%     15
  98%     17
  99%     20
 100%    289 (longest request)

Импортирование модулей в начале файла


Код:


const app = require('ifnode')();

const _defaults = require('lodash/object/defaults');
const _assign = require('lodash/object/assign');
const _clone = require('lodash/lang/clone');

const RequireTestingController = app.Controller({
    root: '/',
    map: {
        'GET /already_imported': 'alreadyImportedAction'
    }
});

RequireTestingController.alreadyImportedAction = function(request, response, next) {
    const data = {
        message: 'test internal and external require'
    };

    response.ok({
        _defaults: _defaults(data, {
            lodash: 'defaults'
        }),
        _assign: _assign(data, {
            lodash: 'assign'
        }),
        _clone: _clone(data)
    });
};

Результат:


$ ab -n 15000 -c 30 -q "http://localhost:8080/already_imported"

Server Hostname:        localhost
Server Port:            8080

Document Path:          /already_imported
Document Length:        233 bytes

Concurrency Level:      30
Time taken for tests:   3.241 seconds
Complete requests:      15000
Failed requests:        0
Total transferred:      6195000 bytes
HTML transferred:       3495000 bytes
Requests per second:    4628.64 [#/sec] (mean)
Time per request:       6.481 [ms] (mean)
Time per request:       0.216 [ms] (mean, across all concurrent requests)
Transfer rate:          1866.83 [Kbytes/sec] received

Percentage of the requests served within a certain time (ms)
  50%      5
  66%      6
  75%      6
  80%      7
  90%      8
  95%     14
  98%     17
  99%     20
 100%     38 (longest request)

Анализ результатов


Импортирование модулей в начале файла уменьшило время одного запроса на ~23%(!) (в сравнение с импортированием непосредственно в обработчик), что весьма существенно.


Такая большая разница в результатах кроется в работе функции require. Перед импортированием, require обращается к алгоритму поиска абсолютного пути к запрашиваемому компоненту (алгоритм описан в документации node.js). Когда путь был найден, то require проверяет был ли закеширован модуль, и если нет — не делает ничего сверхестественного, кроме вызова обычного fs.readFileSync для .js и .json форматов, и недокументированного process.dlopen для загрузки C++ модулей.


Note: пробовал "прогревать" кеш для случая с непосредственным импортированием модулей в обработчик (перед запуском утилиты ab, модули были уже закешированы) — производительность улучшалась на 1-2%.


Выводы


Если вы используете node.js, как сервер (нет разницы какой — TCP/UDP или HTTP(S)), то:


  1. Импортирование всех модулей необходимо делать в начале файла, чтобы избегать лишних синхронных операций связанных с загрузкой модулей (один из главных анти-паттернов использования node.js как асинхронного сервера).
  2. Вы можете не тратить ресурсы на вычисление абсолютного пути запрашиваемого модуля (это и есть основное место для потери производительности).
Поделиться с друзьями
-->

Комментарии (28)


  1. k12th
    15.06.2016 15:04

    Вывод верный, но есть контр-пример: скажем, у нас большой gulpfile.js, некоторые таски запускаются далеко не каждый раз, соответственно, некоторые плагины можно require-ить только в этих тасках.


    1. alexkunin
      15.06.2016 15:17
      +2

      Если вы используете node.js, как сервер
      Вы gulp как сервер запускаете? А что за юз-кейз такой, не поделитесь?


      1. k12th
        15.06.2016 15:19
        -1

        gulp watch


        1. alexkunin
          15.06.2016 15:23
          +1

          И что он сервит по «TCP/UDP или HTTP(S)»?


          1. k12th
            15.06.2016 15:26

            А вы думаете, что если процесс ничего не «сервит» по HTTP, так у него какой-то другой алгоритм в require?:)


            1. alexkunin
              15.06.2016 15:31
              +2

              Я думаю, что ваш «контр-пример: скажем, у нас большой gulpfile.js» не очень подходит к «Выводы: Если вы используете node.js, как сервер (нет разницы какой — TCP/UDP или HTTP(S))». Хотя бы потому, что синхронность долгоиграющих операций для gulp — ожидаемое явление, в то время как для веб-сервера (или сетевого сервера вообще) блокирующие операции — очевидное зло, против которого направлена вся суть NodeJS.


              1. k12th
                15.06.2016 15:32

                Как скажете.


          1. k12th
            15.06.2016 15:31

            Даже если вас беспокоит именно этот момент, то существуют плагины, которые занимаются именно этим — запускают express или какой-то веб-сервер, всякий livereload/browsersync и т.п.:)


            1. alexkunin
              15.06.2016 15:33

              Всего лишь хотел сказать, что ваш пример совершенно не релевантен фактической теме статьи (заголовок пошире, да, но содержимое и выводы довольно четко очерчивают область).


              1. frux
                15.06.2016 19:52

                k12th все правильно сказал. Автор статьи на примере вэб-сервера показывает влияние места вызова require на время работы скрипта. Это не значит, что это относится только к вэб-серверам и любые другие примеры в комментариях к статье не релевантны.


                1. alexkunin
                  15.06.2016 20:11

                  Мне показалось, автор сделал вывод «Если вы разводите ондатр, то не кормите их кукурузой», а первый комментарий был «Все верно, но вот контр-пример: у страусов от кукурузы пучит». В рамках животноводства вообще — да, все нормально. В рамках же разведения ондатр, страусы — не релевантны. Тема статьи — ондатры, а не животноводство в целом.

                  Gulp и веб-сервер — слишком разные задачи: gulp мониторит изменения файловой системы и синхронно запускает достаточно долгую одиночную задачу (одной действие раз в несколько секунд в худшем случае), а веб-сервер получает N независимых запросов в секунду и отвечает на каждый как можно скорее. Оверхед на запрос в первом случае пренебрежительно мал, во втором — огромен.


                  1. frux
                    15.06.2016 21:29

                    Следуя Вашим аналогиям
                    Название статьи: «Вред кукурузы».
                    Содержимое статьи: «Я покормил свою ондатру кукурузой и ей поплохело. Кукуруза – плохо.».
                    k12th написал комментарий: «Кукуруза не всегда вредна. Страусам бывает полезно есть кукурузу.».
                    И тут приходите вы и говорите «При чем тут страусы? Автор кормил ондатру!».

                    Если статья называется «Вред кукурузы», то она, очевидно, про вред кукурузы.


                    1. alexkunin
                      15.06.2016 21:42

                      Ключевой момент: не «Кукуруза – плохо», а «Кукуруза для ондатр – плохо». Исключительно об этом я и говорю.

                      И выходит «Кукуруза для ондатр – плохо» — «Вы не правы, кукуруза не всегда вредна. Страусам бывает полезно есть кукурузу.» — и тут я «Речь об ондатрах, и поэтому 'вы не правы' сказать нельзя.»

                      Вообще не понимаю, почему такая простая логическая выкладка вызывает такие когнитивные проблемы — в разделе для программистов (!), у которых логика должна от зубов отскакивать.


                      1. frux
                        15.06.2016 22:47
                        +2

                        Название статьи «Правильное использование require в node.js». Никаких ондатр тут нет и в помине. Ондатр автор упомянул лишь в качестве примера. Не понимаю, почему это вызывает у вас такие затруднения.


                        1. alexkunin
                          15.06.2016 22:57
                          +3

                          Удивительно, но мне придется еще раз процитировать статью и первый комментарий: "Выводы Если вы используете node.js, как сервер (нет разницы какой — TCP/UDP или HTTP(S)), то:" — "Вывод верный, но есть контр-пример".

                          Комментарий был к выводам? Да. Комментарий был про ондатр? Нет, про страусов.


    1. ilfroloff
      16.06.2016 10:53

      Статья больше в контексте сервера (как сказали комментарием ниже) — это наиболее частый сценарий при использовании node.js.


      Если же использовать node.js, как, например, bash-скрипт, то тогда нет большой необходимости использовать require в начале файла, потому что при исполнении скрипта синхронные операции могут быть вполне ожидаемы и даже необходимы.
      Но опять же — это ситуативно, и может запутать, если на вашем проекте серверный код написан с вынесением require вверх файла, а в node.js-скриптах уже по месту востребования. Лично я бы везде придерживался одного стиля — стараться использовать require/exports только вверху.


  1. Imira_crai
    15.06.2016 15:48
    +5

    Такая большая разница в результатах кроется в работе функции require.


    Мне кажется разница во времени здесь не столько из-за особенностей работы require, сколько из-за того, что во втором случае он будет вызван один раз для каждого модуля в момент инициализации скрипта, а в первом случае каждый раз при запросе GET /not_imported.


    1. alexkunin
      15.06.2016 20:18

      Судя по исходникам, require хоть и кеширует сами загруженные модули, но путь к ним резолвит при каждом вызове.

      Т.е. да, просто глупо вставлять такую операцию в плотный цикл (или — как в случае сервера — в обработку запроса).


  1. raveclassic
    15.06.2016 21:10

    Справедливости ради, можно было упомянуть в минусах commonjs невозможность статического анализа и, как следствие, отсутствие tree-shaking и прочего.


    1. ilfroloff
      16.06.2016 11:06

      Это вопрос не в рамках статьи, но если говорить, про tree-shaking, то CommonJS предполагает разбиение на маленькие модули, которые будут непосредственно импортироваться (тот же lodash). Например, хелперы будут выглядеть, как множество отдельный файлов с одним методом. Фактически tree-shaking делается "руками", поэтому нельзя сказать, что это минус. Скорее специфика CommonJS.


      1. raveclassic
        16.06.2016 11:56

        Ну почему, Вы в предисловии статьи приводите заключение в виде плюсов и минусов commonjs, причем явно упоминая о том, что в es6-модулях все импорты должны быть на верхнем уровне. Такое ограничение сделано как раз для возможности статического анализа, потому как require внутри функции или, например, по условию проанализировать не получится. Так что можно было бы в минусах упомянуть каких преимуществ лишается разработчик без статического анализа.

        Про «множество отдельных файлов с одним методом» все же не соглашусь. Если в модуле 200 функций, нужно завести 200 файлов? Достаточно непрактично. Таким образом, учитывая, что, как я упомянул выше, require внутри функции не анализируется, то, так как по схожей причине невозможно понять, вызывается ли функция, ничего не остается, как включать и функцию и подключаемый внутри нее модуль. Хотя это легко избегается с помощью es6-модулей.


  1. protzi
    16.06.2016 11:06

    а если мне надо в начале загрузки пройтись по файлам и зареквайрить их в объект, потом этот объект експортировать.
    Как это сделать с помощью import?


    1. ilfroloff
      16.06.2016 11:11

      Если правильно понял, то что-то такое:


      import a from 'a';
      import b from 'b';
      import c from 'c';
      
      export default { a, b, c }

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


      1. protzi
        16.06.2016 11:27

        Например есть файлики с роутами типо user.js, main.js, api.js… не знаю… любые роуты которые потом используються через middlewares
        app.use('/users', userRouter);

        для того чтобы не плодить кучу import a from 'a', import b from 'b'; на старте синхронно читаем файлики и помещяем все в один обьект чтоб потом зареквайрить 1 раз, а если понадобиться новый роут то просто создаеться файлик и описывается что нужно.
        export const routes = {}
        fs.readdirSync(__dirname)
        .filter(file => /* фильтруем только что нужно */)
        .forEach(file => {
        // реквайрим
        const r = require(file);
        routes[path.parse(file).name.toLowerCase()] = r.router;
        });


        я не знаю, правильно это или нет, но с import в forEach так сделать нельзя.


        1. raveclassic
          16.06.2016 12:02

          Тут вопрос не в практичности, а в идеологии.
          Используя динамический require, Вы рискуете нарваться на runtime-ошибку (отсутствие файла, поврежденный контент и т.д.), когда, подключая все нужные файлы заранее на верхнем уровне, можно было бы избежать ненужных проверок на существование, целостность и т.д.
          Опять же, теряется статический анализ.


    1. k12th
      16.06.2016 11:38

      Есть пропозиция export * from 'something'. Когда будет в стандарте — неизвестно, но для бабеля плагин есть.


      1. raveclassic
        16.06.2016 12:18

        Это не совсем то, такой экспорт «пробрасывает» все экспорты из подключаемого модуля. Полезен, если нужно объединить несколько модулей в одном, например в index.js. Есть одно важное уточнение — экспорты пробрасываются только именные, default нет.


        1. k12th
          16.06.2016 12:24

          Да это я к примеру, там и так, и эдак можно: export {foo, baz as bar} from './something'.


          Держать руку на пульсе.