С выходом 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-блоков в заголовке файла, ну и, конечно, позволяет ускорить инициализацию системы.
Неочевидные трудности:
- Данный подход требует написания собственного способа генерации кода для расчета покрытия тестами.
- Требуется наличие отдельной точки входа, которая подключает загрузчик.
Уже сейчас использовать такой загрузчик можно в коде тестов, gulp/grunt файлов и т.п.
Комментарии (22)
Klimashkin
06.05.2016 21:05+5Здорово что так можно, но это самый настоящий антипаттерн
1. Скрывать от программиста объявление импортов (или require) плохо. Возникает ощущение «магии» и члены команды, особенно новые могут перестать понимать что именно происходит и откуда ноги растут — что вынуждает их держать в голове еще больше правил о том что и как у вас в проекте работает. Это ведет к ошибкам в рантайме. Код должен быть как можно более explicit, говорить сам за себя.
2. with крайне не рекомендуется и запрещен в 'strict mode', т.е. достаточно давно. Одна из причин — сложность или невозможность некоторых JIT оптимизировать такие функции. Возможно в v8 это пофиксили, но не уверен что они тратили на это силы, так как with не рекомендован и собирался быть переведенным в deprecated
3. Уходит возможность навигации в проекте по модулям в IDE, возможно во всех IDE
Все эти причины также относятся например к алиасам webpack'arumkin
06.05.2016 21:26Во многом согласен, но как я написал во втором абзаце, цель исследование возможностей Node.js.
- Это решается соглашениями об именовании и выносится в документацию. Легко устраняется именованием переменных в определенном стиле.
- Это не фиксили и об этом я написал в конце.
- Соглашусь, но IDE для JS в большинстве своем не понимают все что касается рантайма и поэтому далеко не всегда находят нужный код даже в достаточно простом проекте. Мой опыт в основном касается WebStorm'а, который достаточно хорош и все же не всегда помогает. При этом я предлагаю достаточно явное решение – использовать scope.js, для которого можно добавить нужный плагин в IDE.
Данный код – это пример проектирования по соглашению, т.е. когда какое-то решение принимается разработчиками с учетом его минусов и плюсов. И это хорошо подходит для прикладных программ, для которых более критична скорость и удобство разработки. Это как денормализация БД.
Statyan
07.05.2016 09:02Есть вариант и без скрытия импортов: www.npmjs.com/package/packagerify
Из бонусов: автокомплит в Идее (ну и ВебШторме, разумеется) и возможность пробежаться в цикле по модулям в пакете (удобно при создании разных загрузчиков)
ChALkeRx
06.05.2016 23:16+3> ну и, конечно, ускоряет загрузку.
А скорость работы вы тестировали?
> v8 не оптимизирует код внутри with
Уже неверно. TurboFan оптимизирует функции с with. А до TurboFan — не «внутри with», а «содержащий with».
И да, мне не очень нравится идея — так сложнее отслеживать зависимости модулей, хуже ясно что чего использует, плохо видна структура проекта.
Да, это можно исправить «соглашениями об именовании», но эти соглашения об именовании в таком случае должны в себя включать перечисление используемых модулей в шапке файла. То есть как раз то, от чего вы и избавились.rumkin
07.05.2016 11:45А скорость работы вы тестировали?
В проекте, который переписал время инициализации сократилось с секунд до долей секунды. Но это зависит от структуры проекта.
TurboFan оптимизирует функции с with.
Пофиксил
И да, мне не очень нравится идея — так сложнее отслеживать зависимости модулей, хуже ясно что чего использует, плохо видна структура проекта.
Практика показала, что особых трудностей это не вызывает. Если
UserService
всегда будет находиться в файлеservices/user-service.js
, то это сильно упрощает работу. Использование простых имен, безусловно, может повлечь за собой путаницу.
А по поводу поиска зависимостей, тут есть обратная сторона: в результате такого подхода зависимость смещается с уровня модуля на уровень метода, что мне кажется более правильным.
faiwer
07.05.2016 08:51+1А как у вас обстоят дела с производительностью? Я как-то решил поковыряться с
vm.runInThisContext
и прочими методами, пытаясь, без серьёзных препроцессоров, вродеbabel
, обеспечить работу будущегоexport-import
. Ага, временно написав свой собственный :) Побаловавшись сvm
, я добился некой минимальной работоспособности, но первые же замеры скорости убили всю малину. Разница в 2 порядка. Нет нет, не в 2 раза, а в 2 порядка. Порыскав в сети что с этим можно сделать, я ничего не нашёл. Идею забросил, всё равно спецификация пока не готова.
Посмотрел ваш код на
github
и вижу всё тоже самое: ручное считывания содержимого файла иvm.runInThisContext
. А что изменилось вnodeJS 6
в этом плане? Разве это не завелось бы с тем же успехом и наnodeJS 4
, скажем?
Сама же идея такого вот DI мне кажется ну очень сомнительной. Но больше интересует — производили ли вы замеры по производительности?
rumkin
07.05.2016 11:25Node.js использует runInThisContext, так что ваша проблема где-то в другом месте.
В шестой версии появились Proxy из коробки, раньше вам бы пришлось вызывать такой код с флагом--harmony_proxies
.
Тесты производительности не показали существенной разницы, но я тестировал достаточно простой код. Наверное, стоит сделать репозиторий с бенчмарком.faiwer
07.05.2016 17:10Node.js использует runInThisContext
Я полагаю, что nodeJS имеет некий кеш уже ранее распарсенных файлов, и не парсит их повторно, если размер или mtime не отличается. Не проверял правда. А вот как заставить оный заработать в случае такого вот хака мне не понятно. Я искал методы чтобы самому заняться кешированием AST или чего-нибудь такого, но ничего не нашёл.
rumkin
07.05.2016 17:54Каждый файл парсится и исполняется только один раз. В моей реализации тоже есть кеш.
faiwer
07.05.2016 19:08А мы точно имеем ввиду один и тот же кеш? Я имею ввиду не runtime, а, как бы это назвать, ну пусть будет "обще-системный". Т.е. 1-й запуск приложения, положим, 2sec, а 2-ой 50ms.
faiwer
07.05.2016 19:20Вопрос снимаю. Провёл пару простых замеров. Разницы в производительности нет. Видимо я тогда наткнулся на что-то иное.
fr33zy
07.05.2016 12:12+1Когда я писал сервисы с использованием Node.JS, через некоторое время осознал, что DI уже встроен в Node.JS. Посудите сами:
1. Нужно на момент запуска приложения решить, какую реализацию выбрать? Не проблема — делаем proxy js'ник, который вернет нужную реализацию, например, на основе конфига.
2. Нужно покрыть модуль юнит-тестами? Не проблема — мокаем с помощью proxyquire.
require по своей природе является Service Locator.
greabock
08.05.2016 06:51+1Исследование провёл хорошее, изложил всё грамотно и последовательно. А вот сама идея хоть и понятна но попахивает…
Fesor
08.05.2016 10:40+1Согласен. Намного интереснее было бы организовать ленивую инициализацию модулей явно. Мол
require
бы вернул проксю, и модуль реально загрузился бы только в момент когда он реально нужен.
Так у нас все зависимости будут декларироваться явно, и при этом мы получаем тот же профит.
Fesor
Подождите… правильно ли я понимаю что вся соль в отказе от явных импортов модулей в пользу… глобального к ним доступа? Так это же не DI.
Честный DI — это когда мы можем его выкинуть (например заменить на фабрики) и это не повлечет за собой переписывание нашего кода. И никакого отношения к модулям это не имеет. То есть применение DI в контексте javascript должно быть ограничено управлением зависимостями объектов, а не управление зависимостями на уровне модулей.
rumkin
Ну почему же. В данном случае используется DI в глобальной области видимости. Реализация механизма DI зависит от ваших потребностей и структуры проекта, я привел пример DI с использованием стандартного механизма require.
Это удастся только в том случае, если интерфейс загрузчика и DI идентичны. Приведите пример.
Fesor
Повторюсь. Код ничего не должен знать о существования DI. Когда у нас есть "загрузчик" — это называется service locator а не DI. Именно по этой причине DI работает на уровне объектов, а не модулей. По сути в случае DI у нас все зависимости должны приходить сверху, что происходит и в случае с модулями. Мы можем открыть файлик, и сверху увидеть все зависимости.
SerafimArts
Судя по тому, что я вижу, это больше похоже на некий совершенно обыкновенный автолоадинг, как в пыхе (ну и с натяжкой в джаве), нежели на сервис локатор, т.к. "модули" грузятся по мере необходимости по определённому правилу подгрузки, а не определены заранее с определённым поведением этой подгрузки (если я правильно и точно понял что всё же такое сервис локатор).
rumkin
Исправил.