Полноценный 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-блоков в заголовке файла, ну и, конечно, ускоряет загрузку.

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


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

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

© Habrahabr.ru