С выходом Node.js 6.0 мы из коробки получили готовый набор компонентов для организации честного ленивого загрузчика. В данном случае я имею в виду lazyload, который пытается найти и загрузить нужный модуль только в момент запроса его по имени и находится в глобальной области видимости для текущего модуля, при этом не вмешиваясь в работу сторонних модулей. Написано по мотивам статей Node.JS Избавься от require() навсегда и Загрузчик модулей для node js с поддержкой локальных модулей и загрузки модулей по требованию.


Данная статья носит больше исследовательский характер, а ее целью является показать особенности работы Node.js, показать реальную пользу от нововведений ES 2015 и по новому взглянуть на уже имеющиеся возможности JS. Замечу, что этот подход опробован в продакшене, но все же имеет несколько ловушек и требует вдумчивого применения, в конце статьи я опишу это подробнее. Данный DI может легко использоваться в прикладных программах.


Сразу приведу ссылку на репозиторий с рабочим кодом.


И так, давайте опишем основные требования к нашей системе:


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

Работать это будет приблизительно так:


// script.js
speachModule.sayHello();

// deps/speach-module.js
exports.sayHello = function() {
    console.log('Hello');
};

Псевдо-глобальная область видимости


Что такое псевдо-глобальная область видимости? Это область видимости переменных доступных из любого файла, но только внутри текущего модуля. Т.е. она не доступна модулям из node_modules, или лежащим выше корня модуля. Но как этого добиться? Для этого нам понадобится изучить систему загрузки модулей Node.js.


Создайте файл exception.js:


throw 'test error';

А затем исполните его:


node exception.js

Посмотрите на метку позиции ошибки в трейсе, там явно не то что вы ожидали увидеть.


Дело в том, что система загрузки модулей самого Node.js при подключении модуля его содержимое оборачивается в функцию:


NativeModule.wrap = function(script) {
    return NativeModule.wrapper[0] + script + NativeModule.wrapper[1];
};

NativeModule.wrapper = [
    '(function (exports, require, module, __filename, __dirname) { ',
    '\n});'
];

Как видите exports, require, dirname, filename не являются магическими переменными, как в других средах. А код модуля просто-напросто оборачивается в функцию, которая потом выполняется с нужными аргументами.


Мы можем сделать собственный загрузчик действующий по тому же принципу, подменить им дефолтный и затем управлять переменными модуля и добавлять свои при необходимости. Отлично, но нам нужно перехватывать обращение к несуществующим переменным. Для этого мы будем использовать with, который будет выступать посредником между глобальной и текущей областями видимости, а чтобы каждый модуль получил правильный scope, мы будем использовать метод scopeLookup, который будет искать файл scope.js в корне модуля и возвращать его для всех файлов внутри проекта, а для остальных передавать global.


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

Вот так может выглядеть обертка теперь:


var wrapper = [
    '(function (exports, require, module, __filename, __dirname, scopeLookup) { with (scopeLookup(__dirname)) {',
    '\n}});'
];

Полный код загрузчика в репозитории с примером.


Как я уже писал выше, сам scope хранится в файле scope.js. Это нужно для того, чтобы сделать более очевидным процесс внесения и отслеживания изменений в нашей области видимости.


Подгрузка модулей по требованию


Хорошо. Теперь у нас есть файл scope.js, в котором объект export содержит значения псевдо-глобальной области видимости. Дело за малым: заменим объект exports на экземпляр Proxy, который мы обучим загружать нужные модули на лету:


const fs = require('fs');
const path = require('path');
const decamelize = require('decamelize');

// Собственно сам scope
const scope = {};

module.exports = new Proxy(scope, {
    has(target, prop) {
        if (prop in target) {
            return true;
        }

        if (typeof prop !== 'string') {
            return;
        }

        var filename = decamelize(prop, '-')  + '.js';
        var filepath = path.resolve(__dirname, 'deps', filepath);
        return fs.existsSync(filepath);
    },
    get(target, prop) {
        if (prop in target) {
            return target[prop];
        }

        if (typeof prop !== 'string') {
            return;
        }

        var filename = decamelize(prop, '-')  + '.js';
        var filepath = path.resolve(__dirname, 'deps', filename);
        if (fs.existsSync(filepath)) {
            return scope[prop] = require(filepath);
        }

        return null;
    }
});

Вот, собственно и все. В итоге мы получили самый настоящий lazyload на Node.js, который незаметен для других модулей, позволяет избежать огромных require-блоков в заголовке файла, ну и, конечно, позволяет ускорить инициализацию системы.


Неочевидные трудности:


  1. Данный подход требует написания собственного способа генерации кода для расчета покрытия тестами.
  2. Требуется наличие отдельной точки входа, которая подключает загрузчик.

Уже сейчас использовать такой загрузчик можно в коде тестов, gulp/grunt файлов и т.п.

Поделиться с друзьями
-->

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


  1. Fesor
    06.05.2016 20:16
    +8

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


    Честный DI — это когда мы можем его выкинуть (например заменить на фабрики) и это не повлечет за собой переписывание нашего кода. И никакого отношения к модулям это не имеет. То есть применение DI в контексте javascript должно быть ограничено управлением зависимостями объектов, а не управление зависимостями на уровне модулей.


    1. rumkin
      06.05.2016 20:55

      Ну почему же. В данном случае используется DI в глобальной области видимости. Реализация механизма DI зависит от ваших потребностей и структуры проекта, я привел пример DI с использованием стандартного механизма require.


      Честный DI — это когда мы можем его выкинуть (например заменить на фабрики) и это не повлечет за собой переписывание нашего кода.

      Это удастся только в том случае, если интерфейс загрузчика и DI идентичны. Приведите пример.


      1. Fesor
        06.05.2016 21:18
        +5

        если интерфейс загрузчика и DI идентичны

        Повторюсь. Код ничего не должен знать о существования DI. Когда у нас есть "загрузчик" — это называется service locator а не DI. Именно по этой причине DI работает на уровне объектов, а не модулей. По сути в случае DI у нас все зависимости должны приходить сверху, что происходит и в случае с модулями. Мы можем открыть файлик, и сверху увидеть все зависимости.


        1. SerafimArts
          07.05.2016 02:51
          +2

          Судя по тому, что я вижу, это больше похоже на некий совершенно обыкновенный автолоадинг, как в пыхе (ну и с натяжкой в джаве), нежели на сервис локатор, т.к. "модули" грузятся по мере необходимости по определённому правилу подгрузки, а не определены заранее с определённым поведением этой подгрузки (если я правильно и точно понял что всё же такое сервис локатор).


          1. rumkin
            07.05.2016 12:11

            Исправил.


  1. Klimashkin
    06.05.2016 21:05
    +5

    Здорово что так можно, но это самый настоящий антипаттерн

    1. Скрывать от программиста объявление импортов (или require) плохо. Возникает ощущение «магии» и члены команды, особенно новые могут перестать понимать что именно происходит и откуда ноги растут — что вынуждает их держать в голове еще больше правил о том что и как у вас в проекте работает. Это ведет к ошибкам в рантайме. Код должен быть как можно более explicit, говорить сам за себя.
    2. with крайне не рекомендуется и запрещен в 'strict mode', т.е. достаточно давно. Одна из причин — сложность или невозможность некоторых JIT оптимизировать такие функции. Возможно в v8 это пофиксили, но не уверен что они тратили на это силы, так как with не рекомендован и собирался быть переведенным в deprecated
    3. Уходит возможность навигации в проекте по модулям в IDE, возможно во всех IDE

    Все эти причины также относятся например к алиасам webpack'a


    1. rumkin
      06.05.2016 21:26

      Во многом согласен, но как я написал во втором абзаце, цель исследование возможностей Node.js.


      1. Это решается соглашениями об именовании и выносится в документацию. Легко устраняется именованием переменных в определенном стиле.
      2. Это не фиксили и об этом я написал в конце.
      3. Соглашусь, но IDE для JS в большинстве своем не понимают все что касается рантайма и поэтому далеко не всегда находят нужный код даже в достаточно простом проекте. Мой опыт в основном касается WebStorm'а, который достаточно хорош и все же не всегда помогает. При этом я предлагаю достаточно явное решение – использовать scope.js, для которого можно добавить нужный плагин в IDE.

      Данный код – это пример проектирования по соглашению, т.е. когда какое-то решение принимается разработчиками с учетом его минусов и плюсов. И это хорошо подходит для прикладных программ, для которых более критична скорость и удобство разработки. Это как денормализация БД.


    1. Statyan
      07.05.2016 09:02

      Есть вариант и без скрытия импортов: www.npmjs.com/package/packagerify
      Из бонусов: автокомплит в Идее (ну и ВебШторме, разумеется) и возможность пробежаться в цикле по модулям в пакете (удобно при создании разных загрузчиков)


  1. ChALkeRx
    06.05.2016 23:16
    +3

    > ну и, конечно, ускоряет загрузку.

    А скорость работы вы тестировали?

    > v8 не оптимизирует код внутри with

    Уже неверно. TurboFan оптимизирует функции с with. А до TurboFan — не «внутри with», а «содержащий with».

    И да, мне не очень нравится идея — так сложнее отслеживать зависимости модулей, хуже ясно что чего использует, плохо видна структура проекта.

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


    1. rumkin
      07.05.2016 11:45

      А скорость работы вы тестировали?

      В проекте, который переписал время инициализации сократилось с секунд до долей секунды. Но это зависит от структуры проекта.


      TurboFan оптимизирует функции с with.

      Пофиксил


      И да, мне не очень нравится идея — так сложнее отслеживать зависимости модулей, хуже ясно что чего использует, плохо видна структура проекта.

      Практика показала, что особых трудностей это не вызывает. Если UserService всегда будет находиться в файле services/user-service.js, то это сильно упрощает работу. Использование простых имен, безусловно, может повлечь за собой путаницу.


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


  1. faiwer
    07.05.2016 08:51
    +1

    А как у вас обстоят дела с производительностью? Я как-то решил поковыряться с vm.runInThisContext и прочими методами, пытаясь, без серьёзных препроцессоров, вроде babel, обеспечить работу будущего export-import. Ага, временно написав свой собственный :) Побаловавшись с vm, я добился некой минимальной работоспособности, но первые же замеры скорости убили всю малину. Разница в 2 порядка. Нет нет, не в 2 раза, а в 2 порядка. Порыскав в сети что с этим можно сделать, я ничего не нашёл. Идею забросил, всё равно спецификация пока не готова.


    Посмотрел ваш код на github и вижу всё тоже самое: ручное считывания содержимого файла и vm.runInThisContext. А что изменилось в nodeJS 6 в этом плане? Разве это не завелось бы с тем же успехом и на nodeJS 4, скажем?


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


    1. rumkin
      07.05.2016 11:25

      Node.js использует runInThisContext, так что ваша проблема где-то в другом месте.
      В шестой версии появились Proxy из коробки, раньше вам бы пришлось вызывать такой код с флагом --harmony_proxies.
      Тесты производительности не показали существенной разницы, но я тестировал достаточно простой код. Наверное, стоит сделать репозиторий с бенчмарком.


      1. faiwer
        07.05.2016 17:10

        Node.js использует runInThisContext

        Я полагаю, что nodeJS имеет некий кеш уже ранее распарсенных файлов, и не парсит их повторно, если размер или mtime не отличается. Не проверял правда. А вот как заставить оный заработать в случае такого вот хака мне не понятно. Я искал методы чтобы самому заняться кешированием AST или чего-нибудь такого, но ничего не нашёл.


        1. rumkin
          07.05.2016 17:54

          Каждый файл парсится и исполняется только один раз. В моей реализации тоже есть кеш.


          1. faiwer
            07.05.2016 19:08

            А мы точно имеем ввиду один и тот же кеш? Я имею ввиду не runtime, а, как бы это назвать, ну пусть будет "обще-системный". Т.е. 1-й запуск приложения, положим, 2sec, а 2-ой 50ms.


          1. faiwer
            07.05.2016 19:20

            Вопрос снимаю. Провёл пару простых замеров. Разницы в производительности нет. Видимо я тогда наткнулся на что-то иное.


  1. Holix
    07.05.2016 09:02

    А не пробовали использовать vm.runInNewContext() вместо with? Интересно, прокси будет продолжать работать?


    1. rumkin
      07.05.2016 11:27

      Не пробовал. Вообще Proxy должен продолжить свою работу, если нет, то, по-моему, это баг.


  1. AxisPod
    07.05.2016 09:56
    +4

    Это не DI, это ленивая инициализация.


  1. fr33zy
    07.05.2016 12:12
    +1

    Когда я писал сервисы с использованием Node.JS, через некоторое время осознал, что DI уже встроен в Node.JS. Посудите сами:

    1. Нужно на момент запуска приложения решить, какую реализацию выбрать? Не проблема — делаем proxy js'ник, который вернет нужную реализацию, например, на основе конфига.
    2. Нужно покрыть модуль юнит-тестами? Не проблема — мокаем с помощью proxyquire.

    require по своей природе является Service Locator.


  1. greabock
    08.05.2016 06:51
    +1

    Исследование провёл хорошее, изложил всё грамотно и последовательно. А вот сама идея хоть и понятна но попахивает…


    1. Fesor
      08.05.2016 10:40
      +1

      Согласен. Намного интереснее было бы организовать ленивую инициализацию модулей явно. Мол require бы вернул проксю, и модуль реально загрузился бы только в момент когда он реально нужен.


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