Полноценный DI на node.js
С выходом Node.js 6.0 мы из коробки получили готовый набор компонентов для организации честного DI. В данном случае я имею в виду DI, который пытается найти и загрузить нужный модуль только в момент запроса его по имени и находится в глобальной области видимости для текущего модуля, при этом не вмешиваясь в работу сторонних модулей. Написано по мотивам статей Node.JS Избавься от require () навсегда и Загрузчик модулей для node js с поддержкой локальных модулей и загрузки модулей по требованию.
Данная статья носит больше исследовательский характер, а ее целью является показать особенности работы Node.js, показать реальную пользу от нововведений ES 2015 и по новому взглянуть на уже имеющиеся возможности JS. Замечу, что этот подход опробован в продакшене, но все же имеет несколько ловушек и требует вдумчивого применения, в конце статьи я опишу это подробнее. Данный DI может легко использоваться в прикладных программах.
Сразу приведу ссылку на репозиторий с рабочим кодом.
И так, давайте опишем основные требования к нашей системе:
- DI не должен исследовать файловую систему перед началом работы.
- DI не должен подключаться вручную в каждом файле.
- 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 не являются магическими переменными, как в других средах. А код модуля просто-напросто оборачивается в функцию, которая потом выполняется с нужными аргументами.
Мы можем сделать собственный загрузчик действующий по тому же принципу, подменить им дефолтный и затем управлять переменными модуля и добавлять свои при необходимости. Отлично, но для DI нам нужно перехватывать обращение к несуществующим переменным. Для этого мы будем использовать 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;
}
});
Вот, собственно и все. В итоге мы получили самый настоящий DI на Node.js, который незаметен для других модулей, позволяет избежать огромных require-блоков в заголовке файла, ну и, конечно, ускоряет загрузку.
Неочевидные трудности:
- Данный подход требует написания собственного способа генерации кода для расчета покрытия тестами.
- Требуется наличие отдельной точки входа, которая подключает загрузчик DI.
- v8 не оптимизирует код внутри with, но реальное снижение скорости нужно измерять в конкретном случае.
Уже сейчас использовать DI можно в коде тестов, gulp/grunt файлов и т.п.