Многие разработчики Node.js для связывания модулей используют исключительно создание жёсткой зависимости с помощью require(), однако существуют и другие подходы, со своими плюсами и минусами. О них я в этой статье и расскажу. Будут рассмотрены четыре подхода:
Модули и модульная архитектура — это основа Node.js. Модули обеспечивают инкапсуляцию (скрывая подробности реализации и открывая только интерфейс с помощью module.exports), повторное использование кода, логическое разбиение кода на файлы. Практически все приложения Node.js состоят из множества модулей, которые должны каким-то образом взаимодействовать. Если неправильно связывать модули или вообще пустить взаимодействие модулей на самотёк, то можно очень быстро обнаружить, что приложение начинает «разваливаться»: изменения кода в одном месте приводят к поломке в другом, а модульное тестирование становится попросту невозможным. В идеале модули должны обладать высокой связностью, но низким зацеплением (coupling).
Жёсткая зависимость одного модуля от другого возникает при использовании require(). Это эффективный, простой и распространённый подход. Например, мы хотим просто подключить модуль, отвечающий за взаимодействие с базой данных:
Подход хорош для небольших приложений или прототипов, а также для подключения модулей, не хранящих состояние: фабрик, конструкторов и наборов функций.
Основная идея внедрения зависимостей — передача модулю зависимости из внешнего компонента. Таким образом, устраняется жёсткая зависимость в модуле и появляется возможность его повторного использования в разных контекстах (например, с разными экземплярами БД).
Внедрение зависимости можно осуществить с помощью передачи зависимости в аргументе конструктора или с помощью установки свойств модуля, но на практике лучше пользоваться первым методом. Применим внедрение зависимости на практике, создав экземпляр БД с помощью фабрики и передав его нашему модулю:
Внешний модуль:
Теперь мы можем не только повторно использовать наш модуль, но и легко написать модульный тест для него: достаточно создать мок-объект экземпляра БД и передать его модулю.
Внедрение зависимостей увеличивает сложность и размер приложения, но взамен даёт возможность повторного использования и облегчает тестирование. Разработчику следует решить, что для него важнее в конкретном случае — простота жёсткой зависимости или более широкие возможности внедрения зависимости.
Идея заключается в наличии реестра зависимостей, который выступает в качестве посредника при загрузке зависимости любым модулем. Вместо жесткого связывания зависимости запрашиваются модулем у локатора служб. Очевидно, что у модулей появляется новая зависимость — сам локатор служб. Примером локатора служб является система модулей Node.js: модули запрашивают зависимость с помощью require(). В следующем примере мы создадим локатор служб, зарегистрируем в нём экземпляры БД и нашего модуля.
Внешний модуль:
Наш модуль:
Следует отметить, что локатор служб хранит фабрики служб вместо экземпляров, и в этом есть смысл. Мы получили преимущества «ленивой» инициализации, к тому же теперь мы можем не заботиться о порядке инициализации модулей — все модули будут инициализироваться тогда, когда это будет нужно. Плюс мы получили возможность хранить в локаторе служб параметры (см. «someParameter»).
В целом, локатор служб похож на внедрение зависимости, в чём-то он легче (нет порядка инициализации), в чём-то — сложнее (меньше возможности повторного использования кода).
У локатора служб есть недостаток, из-за которого он редко применяется на практике — зависимость модулей от самого локатора. Контейнеры внедряемых зависимостей (DI-контейнеры) лишены этого недостатка. По сути, это тот же локатор служб с дополнительной функцией, которая определяет зависимости модуля до создания его экземпляра. Определить зависимости модуля можно с помощью парсинга и извлечения аргументов из конструктора модуля (в JavaScript можно привести ссылку на функцию к строке с помощью toString()). Этот способ подойдёт, если разработка идёт чисто под сервер. Если же пишется клиентский код, то зачастую он минифицируется и извлекать имена аргументов будет бессмысленно. В таком случае список зависимостей можно передать массивом строк (в Angular.js, основанном на использовании DI-контейнеров, используется именно такой подход). Реализуем DI-контейнер, используя парсинг аргументов конструктора:
По сравнению с локатором служб добавился метод inject, который определяет зависимости модуля перед созданием его экземпляра. Код внешнего модуля почти не изменился:
Наш модуль выглядит точно так же, как и при простом внедрении зависимости:
Теперь наш модуль можно вызывать как с помощью DI-контейнера, так и передав ему необходимые экземпляры зависимостей напрямую, использовав простое внедрение зависимости.
Этот подход сложнее в понимании и содержит чуть больше кода, однако он стоит потраченного на него времени из-за своей мощи и элегантности. В небольших проектах такой подход может быть излишним, но его следует рассмотреть, если проектируется крупное приложение.
Были рассмотрены основные подходы к связыванию модулей в Node.js. Как это обычно бывает, «серебряной пули» не существует, но разработчику следует знать о возможных альтернативах и выбирать наиболее подходящее решение для каждого конкретного случая.
Статья основана на главе из вышедшей в 2017 году книги Шаблоны проектирования Node.js. К сожалению, многие вещи в книге уже устарели, поэтому я не могу на 100% порекомендовать её к прочтению, однако некоторые вещи актуальны и сегодня.
- Жёсткие зависимости (require())
- Внедрение зависимостей (Dependency Injection)
- Локаторы служб (Service Locator)
- Контейнеры внедряемых зависимостей (DI Container)
Немного о модулях
Модули и модульная архитектура — это основа Node.js. Модули обеспечивают инкапсуляцию (скрывая подробности реализации и открывая только интерфейс с помощью module.exports), повторное использование кода, логическое разбиение кода на файлы. Практически все приложения Node.js состоят из множества модулей, которые должны каким-то образом взаимодействовать. Если неправильно связывать модули или вообще пустить взаимодействие модулей на самотёк, то можно очень быстро обнаружить, что приложение начинает «разваливаться»: изменения кода в одном месте приводят к поломке в другом, а модульное тестирование становится попросту невозможным. В идеале модули должны обладать высокой связностью, но низким зацеплением (coupling).
Жёсткие зависимости
Жёсткая зависимость одного модуля от другого возникает при использовании require(). Это эффективный, простой и распространённый подход. Например, мы хотим просто подключить модуль, отвечающий за взаимодействие с базой данных:
// ourModule.js
const db = require('db');
// Работа с базой данных...
Плюсы:
- Простота
- Наглядная организация модулей
- Лёгкая отладка
Минусы:
- Трудность для повторного использования модуля (например, если мы хотим использовать наш модуль повторно, но с другим экземпляром БД)
- Трудность для модульного тестирования (придётся создавать фиктивный экземпляр БД и как-то передавать его модулю)
Резюме:
Подход хорош для небольших приложений или прототипов, а также для подключения модулей, не хранящих состояние: фабрик, конструкторов и наборов функций.
Внедрение зависимостей (Dependency Injection)
Основная идея внедрения зависимостей — передача модулю зависимости из внешнего компонента. Таким образом, устраняется жёсткая зависимость в модуле и появляется возможность его повторного использования в разных контекстах (например, с разными экземплярами БД).
Внедрение зависимости можно осуществить с помощью передачи зависимости в аргументе конструктора или с помощью установки свойств модуля, но на практике лучше пользоваться первым методом. Применим внедрение зависимости на практике, создав экземпляр БД с помощью фабрики и передав его нашему модулю:
// ourModule.js
module.exports = (db) => {
// Инициализация модуля с переданным экземпляром базы данных...
};
Внешний модуль:
const dbFactory = require('db');
const OurModule = require('./ourModule.js');
const dbInstance = dbFactory.createInstance('instance1');
const ourModule = OurModule(dbInstance);
Теперь мы можем не только повторно использовать наш модуль, но и легко написать модульный тест для него: достаточно создать мок-объект экземпляра БД и передать его модулю.
Плюсы:
- Лёгкость написания модульных тестов
- Увеличение «многоразовости» модулей
- Снижение зацепления, увеличение связности
- Перекладывание ответственности за создание зависимостей на более высокий уровень — часто это улучшает удобочитаемость программы, так как важные зависимости собраны в одном месте, а не размазаны по модулям
Минусы:
- Необходимость более тщательного проектирования зависимостей: например, должен соблюдаться определённый порядок инициализации модулей
- Сложность управления зависимостями, особенно когда их много
- Ухудшение понятности кода модуля: писать код модуля, когда зависимость приходит извне, труднее, поскольку мы не можем напрямую посмотреть на эту зависимость.
Резюме:
Внедрение зависимостей увеличивает сложность и размер приложения, но взамен даёт возможность повторного использования и облегчает тестирование. Разработчику следует решить, что для него важнее в конкретном случае — простота жёсткой зависимости или более широкие возможности внедрения зависимости.
Локаторы служб (Service Locator)
Идея заключается в наличии реестра зависимостей, который выступает в качестве посредника при загрузке зависимости любым модулем. Вместо жесткого связывания зависимости запрашиваются модулем у локатора служб. Очевидно, что у модулей появляется новая зависимость — сам локатор служб. Примером локатора служб является система модулей Node.js: модули запрашивают зависимость с помощью require(). В следующем примере мы создадим локатор служб, зарегистрируем в нём экземпляры БД и нашего модуля.
// serviceLocator.js
const dependencies = {};
const factories = {};
const serviceLocator = {};
serviceLocator.register = (name, instance) => { //[2]
dependencies[name] = instance;
};
serviceLocator.factory = (name, factory) => { //[1]
factories[name] = factory;
};
serviceLocator.get = (name) => { //[3]
if(!dependencies[name]) {
const factory = factories[name];
dependencies[name] = factory && factory(serviceLocator);
if(!dependencies[name]) {
throw new Error('Cannot find module: ' + name);
}
}
return dependencies[name];
};
Внешний модуль:
const serviceLocator = require('./serviceLocator.js')();
serviceLocator.register('someParameter', 'someValue');
serviceLocator.factory('db', require('db'));
serviceLocator.factory('ourModule', require('ourModule'));
const ourModule = serviceLocator.get('ourModule');
Наш модуль:
// ourModule.js
module.exports = (serviceLocator) => {
const db = serviceLocator.get('db');
const someValue = serviceLocator.get('someParameter');
const ourModule = {};
// Инициализация модуля, работа с БД...
return ourModule;
};
Следует отметить, что локатор служб хранит фабрики служб вместо экземпляров, и в этом есть смысл. Мы получили преимущества «ленивой» инициализации, к тому же теперь мы можем не заботиться о порядке инициализации модулей — все модули будут инициализироваться тогда, когда это будет нужно. Плюс мы получили возможность хранить в локаторе служб параметры (см. «someParameter»).
Плюсы:
- Лёгкость написания модульных тестов
- Повторное использование модуля легче, чем при жёсткой зависимости
- Снижение зацепления, увеличение связности по сравнению с жёсткой зависимостью
- Перекладывание ответственности за создание зависимостей на более высокий уровень
- Нет необходимости соблюдать порядок инициализации модулей
Минусы:
- Повторное использование модуля сложнее, чем при внедрении зависимости (из-за дополнительной зависимости локатора служб)
- Удобочитаемость: ещё сложнее понять, что делает зависимость, требуемая у локатора служб
- Увеличение зацепления по сравнению с внедрением зависимости
Резюме
В целом, локатор служб похож на внедрение зависимости, в чём-то он легче (нет порядка инициализации), в чём-то — сложнее (меньше возможности повторного использования кода).
Контейнеры внедряемых зависимостей (DI Container)
У локатора служб есть недостаток, из-за которого он редко применяется на практике — зависимость модулей от самого локатора. Контейнеры внедряемых зависимостей (DI-контейнеры) лишены этого недостатка. По сути, это тот же локатор служб с дополнительной функцией, которая определяет зависимости модуля до создания его экземпляра. Определить зависимости модуля можно с помощью парсинга и извлечения аргументов из конструктора модуля (в JavaScript можно привести ссылку на функцию к строке с помощью toString()). Этот способ подойдёт, если разработка идёт чисто под сервер. Если же пишется клиентский код, то зачастую он минифицируется и извлекать имена аргументов будет бессмысленно. В таком случае список зависимостей можно передать массивом строк (в Angular.js, основанном на использовании DI-контейнеров, используется именно такой подход). Реализуем DI-контейнер, используя парсинг аргументов конструктора:
const fnArgs = require('parse-fn-args');
module.exports = function() {
const dependencies = {};
const factories = {};
const diContainer = {};
diContainer.factory = (name, factory) => {
factories[name] = factory;
};
diContainer.register = (name, dep) => {
dependencies[name] = dep;
};
diContainer.get = (name) => {
if(!dependencies[name]) {
const factory = factories[name];
dependencies[name] = factory && diContainer.inject(factory);
if(!dependencies[name]) {
throw new Error('Cannot find module: ' + name);
}
}
diContainer.inject = (factory) => {
const args = fnArgs(factory)
.map(dependency => diContainer.get(dependency));
return factory.apply(null, args);
}
return dependencies[name];
};
По сравнению с локатором служб добавился метод inject, который определяет зависимости модуля перед созданием его экземпляра. Код внешнего модуля почти не изменился:
const diContainer = require('./diContainer.js')();
diContainer.register('someParameter', 'someValue');
diContainer.factory('db', require('db'));
diContainer.factory('ourModule', require('ourModule'));
const ourModule = diContainer.get('ourModule');
Наш модуль выглядит точно так же, как и при простом внедрении зависимости:
// ourModule.js
module.exports = (db) => {
// Инициализация модуля с переданным экземпляром базы данных...
};
Теперь наш модуль можно вызывать как с помощью DI-контейнера, так и передав ему необходимые экземпляры зависимостей напрямую, использовав простое внедрение зависимости.
Плюсы:
- Лёгкость написания модульных тестов
- Лёгкость повторного использования модулей
- Снижение зацепления, увеличение связности модулей (особенно по сравнению с локатором служб)
- Перекладывание ответственности за создание зависимостей на более высокий уровень
- Нет необходимости следить за порядком инициализации модулей
Самый большой минус:
- Существенное усложнение логики связывания модулей
Резюме
Этот подход сложнее в понимании и содержит чуть больше кода, однако он стоит потраченного на него времени из-за своей мощи и элегантности. В небольших проектах такой подход может быть излишним, но его следует рассмотреть, если проектируется крупное приложение.
Заключение
Были рассмотрены основные подходы к связыванию модулей в Node.js. Как это обычно бывает, «серебряной пули» не существует, но разработчику следует знать о возможных альтернативах и выбирать наиболее подходящее решение для каждого конкретного случая.
Статья основана на главе из вышедшей в 2017 году книги Шаблоны проектирования Node.js. К сожалению, многие вещи в книге уже устарели, поэтому я не могу на 100% порекомендовать её к прочтению, однако некоторые вещи актуальны и сегодня.