Анализ подходов к связыванию модулей в Node.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% порекомендовать её к прочтению, однако некоторые вещи актуальны и сегодня.
    Поделиться публикацией

    Комментарии 0

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

    Самое читаемое