Анализ подходов к связыванию модулей в Node.js

ProgrammingNode.JS
Многие разработчики Node.js для связывания модулей используют исключительно создание жёсткой зависимости с помощью require(), однако существуют и другие подходы, со своими плюсами и минусами. О них я в этой статье и расскажу. Будут рассмотрены четыре подхода:

  • Жёсткие зависимости (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% порекомендовать её к прочтению, однако некоторые вещи актуальны и сегодня.
Tags:node.jsразработкаархитектура приложений
Hubs: Programming Node.JS
+8
2.3k 47
Leave a comment

Popular right now

Автор/преподаватель курса по Node.js
from 90,000 to 120,000 ₽ThinkneticaRemote job
Node.js developer (infrastructure)
from 100,000 to 300,000 ₽ЯндексМосква
Node.js developer (infrastructure)
from 100,000 to 300,000 ₽ЯндексСанкт-Петербург
Middle+ / Senior Node.js Developer
from 3,000 to 4,500 $CodemotionRemote job
Node.js разработчик
from 120,000 to 160,000 ₽EquiteRemote job

Top of the last 24 hours