Dependency Injection в Node.js на чистом JavaScript

Всем доброго времени суток! Меня зовут Александр, я — фулстек программист в стеке технологий NodeJS / PHP и React / Vue. В веб-разработку я пришел чуть больше 10 лет назад, пока занимался написанием кода для стартапа в сфере геймдева, и с тех пор участвую в создании разнообразных веб-проектов в качестве фрилансера.

Одним из последних таких проектов было NodeJS приложение, запускаемое на сервере Raspberry Pi, для управления показом рекламных видеослайдов и прочего подобного контента на андроид-приставках, подключаемых к демонстрационным экранам в кинотеатрах. Его код я начинал писать поверх уже имеющихся у компании наработок еще времен io.js, которые внезапно было решено разморозить. Исходники к тому времени давно устарели, многое в них было реализовано без использования сторонних библиотек, а одной из самых частых проблем, что приводила к регрессивным багам, была весьма запутанная самопальная система внедрения зависимостей. Едва ли не каждый четвертый пропущенный баг был так или иначе связан с ней. В итоге проблему решили с помощью увеличения степени покрытия тестами и рефакторинга в сторону полной замены системы вставки зависимостей.

Собственно, о том как реализовать шаблон проектирования Dependency Injection на чистом JavaScript под NodeJS, и пойдет речь в данной статье. Причем, под «чистым JS» я подразумеваю, что:

  1. Во-первых, будет использоваться именно JavaScript, а не, например, TypeScript, на котором и без того существует множество библиотек и фреймворков. Например, таких как NestJS, который предоставляет достойную реализацию DI из коробки.
  2. Во-вторых, у нас, как разработчиков, не будет возможности провести транспиляцию кода при помощи Babel. Другими словами, речь пойдет о такой экзотике, как внедрение в проект DI без привычного использования декораторов (они же — аннотации в других ЯП).

Рассказывая о способе реализации DI без использования декораторов, мне придется немного погрузиться в теорию, поэтому все то, о чем я буду писать, возможно, будет полезно новичкам. Но, в первую очередь, статья все же рассчитана на программистов, которым приходится иметь дело с поддержкой legacy-кода, написанного еще в те времена, когда соответствующих фреймворков и библиотек еще не существовало.

Немного теории

Dependency Injection — это одна из техник более широкого принципа Inversion of Control, который заключается в инвертировании потока управления выполнения программы путем создания обобщенных / абстрактных функций. Это немного перефразированное определение из Википедии, но все равно звучит довольно расплывчато, поэтому тут будет понятнее на примере. Предположим, где-то в коде приложения есть две связанные функции, одна возвращает true, если переданный в параметре объект пользователя не имеет имени, а вторая — фильтрует список пользователей и возвращает такие из них, что не имеют имени:

// Возвращает true если пользователь не указал имя и фамилию.
function isUserUnnamed(user) {
 return !user.firstName && !user.lastName;
}
 
// Возвращает подмножество пользователей без имени и фамилии.
function filterUnnamedUsers(users) {
  const filteredUsers = [];
  for (const user of users) {
    if (isUserUnnamed(user)) {
      filteredUsers.push(user);
    }
  }
  return filteredUsers;
}

Это демонстрация «прямого» порядка выполнения: вторая функция использует первую, и это жестко зафиксировано в ее реализации. Проблема в том, что, скорее всего, однажды возникнет необходимость фильтрации пользователей по какому-нибудь другому критерию, например, потребуется отфильтровать только тех пользователей, имена которых содержат определенную подстроку, или входящих в определенную возрастную группу, принадлежащих заданному полу и так далее — вариантов масса. Для этого придется написать множество функций, в совокупности нарушающих принцип DRY. Но поскольку использование метода копипаста с минимальными исправлениями по началу не так уж и накладно, то непосредственно написание такого кода произойдет довольно быстро.

Правда, это утверждение будет справедливо ровно до того момента, пока на Джире не появится таск реализовать сортировку не по одному критерию, а, например, по заданной пользователем комбинации. В таком случае не получится воспользоваться большей частью уже имеющегося кода для написания этой функции, и придется создавать ее с нуля. На практике это значит потратить дополнительное время на разработку, отладку и написание тестов достаточно большой функции, тем самым, скорее всего, компенсируя все то, что было сэкономлено копи-пастом. Но, главное, через какое-то время может появиться еще одна задача, нуждающаяся в очень похожей функции на уже реализованную, разве что немного отличающейся по списку критериев (скажем, для поиска пользователей на соседней странице или в другом виджете).

Таким образом, мы приходим к идее о необходимости более общего решения с вынесением части функционала из основного алгоритма:

// Возвращает подмножество пользователей по критерию.
function filterUsers(users, predicate) {
  const filteredUsers = [];
  for (const user of users) {
    if (predicate(user)) {
      filteredUsers.push(user);
    }
  }
  return filteredUsers;
}
 
// Комбинирует предикаты в конъюнкцию.
function and(...predicates) {
  return user => predicates.every(p => p(user));
}

Чтобы потом использовать, например, так:

filterUsers(users, isUserUnnamed);
// или так
filterUsers(users, and(isUserUnnamed, isUserAdult));

Такой порядок выполнения называется «инвертированным» и в нем, по по большому счету, и заключается принцип IoC.

Шаблон Dependency Injection реализует тот же принцип, но на уровне компонентов. Основная его идея заключается в передаче зависимостей в конструкторе объекта при его создании. Например:

// Redis имплементация кэш-сервиса
class RedisCache {
  // Возвращает значение из кэша. Если там его не находит, то
  // вызывает колбэк producer для генерации нового значения.
  async use(key, ttl, producer) {
    // ...
  }
}
 
// Сервис магазина
class Store {
  constructor(cache, config) {
    // Сохраняем переданные зависимости
    this.cache = cache;
    this.config = config;
  }
 
  // Возвращает кэшированный список продуктов из базы.
  async getProducts(page = 1) {
    const { cacheTtl } = this.config.store;
    return this.cache.use('store.products', cacheTtl, () => {
      return this.fetchProductsFromDb(page);
    });
  }
}

Но использование такого подхода вызывает ряд трудностей.

Во-первых, для создания экземпляра каждого класса придется заполнять параметры конструкторов каждый раз вручную, а это совершенно лишний человеческий фактор, чреватый ошибками в процессе дальнейших изменений. Поэтому для этой цели стоит реализовать некий фабричный метод. В терминах DI конструкция, использующая такой фабричный метод для конструирования объектов с автоматической вставкой зависимостей, обычно называется Инжектором.

Во-вторых, если все реализовать как в предложенном примере выше, то агрегирующие объекты должны будут знать о зависимостях агрегируемых внутри них сервисов. И в случае изменений в зависимостях последних потребуется внесение правок во всю цепочку от потомка к родителю самого верхнего уровня, что потребует много внимания, отнимет время и увеличит вероятность ошибки. Поэтому удобно иметь некий Контекст (или Контейнер), доступный для поддеревьев данного объекта в дереве агрегации, и в котором были бы зарегистрированы инстансы сервисов. По сути, сервис локатор.

В-третьих, было бы удобно, если бы в Контексте можно было сохранять не только экземпляры классов, но и произвольные значения. Это позволит, например, положить туда настройки приложения в виде JSON объекта, а потом использовать в виде зависимостей в классах.

В-четвертых, в контексте помимо синглтонов иногда бывает необходимо создавать разного рода временные объекты, например:

  • Объекты со временем жизни равным времени выполнения запроса. Подходит для данных вроде информации о текущем пользователе.
  • Объекты, создающиеся по новой при каждом обращении.
  • Или создающиеся на определенный промежуток времени, а по его истечению пересоздающиеся снова.

Кроме того, в-пятых, было неплохо, чтобы фабричный метод умел создавать объекты конкретных классов на основании информации о базовом.

И, в-шестых, может оказаться не лишним научить рисолвер подставлять нужную имплементацию сервиса в зависимости от некоторых определенных пользователем условий.

Решением всех этих задач занимаются различные DI-фреймворки. Правда, слово «различные», применительно к чистому JavaScript, это всё же преувеличение, поскольку преимущественное большинство из более-менее «зрелых» библиотек на деле требуют либо транспиляции c помощью Babel, либо из TypeScript. Но, к счастью, есть исключения, и об одном из них пойдет речь дальше.

Библиотека awilix

На данный момент это, наверное, самый популярный DI-фреймворк, написанный для чистого JavaScript и поддерживаемый NodeJS без необходимости транспиляции. Помимо того, что в ней реализованы все перечисленные в предыдущей части функции, библиотека обладает хорошей, понятной документацией. Поэтому я не стану копировать сюда примеры, а просто перечислю некоторые ее особенности.

Приложение на awilix обычно имеет один глобальный контейнер, который создается функцией awilix.createContainer(). На контейнере регистрируются сервисы со временем жизни трех типов:

  • TRANSIENT
  • SINGLETON
  • SCOPED

Объекты первого типа создаются по новой при каждом обращении, второго — живут пока жив контейнер, а последнего — живут, пока жив scope (scope может создаваться, например, на время жизни запроса).

Ресолвинг сервисов реализован в двух режимах, которые можно выбрать на свое усмотрение:

— Классическом:

class Store {
 constructor(cache, config) {
   this.cache = cache;
   this.config = config;
 }
}

— И прокси:

class Store {
 constructor({ cache, config }) {
   this.cache = cache;
   this.config = config;
 }
}

В классическом варианте конструктор преобразуется в строку (банально при помощи Store.toString()), а затем парсится список параметров, откуда выдергиваются имена зависимостей. Этот вариант не очень надежный, поскольку в процессе минимизации или транспайлинга параметры конструктора будут переименованы. Поэтому его можно использовать только если вы точно уверены, что обфускации кода никогда не потребуется.

Второй основан на передаче единственного аргумента в конструктор в виде прокси объекта. Под «прокси» я имею в виду экземпляр класса Proxy, а не обычный клон объекта. Если вы пропустили это добавление в ECMAScript, то на самом деле Proxy — весьма полезный класс, позволяющий реализовать что-то наподобие magic методов в PHP или InvocationHandler в Java. Например, с его помощью можно реализовать в сервисе логгера такое:

class Log {
  // ...
 
  debug(...args) {
    return this.log('debug', ...args);
  }
 
  scoped(scope) {
    return new Proxy(this, {
      get(target, prop) {
        if (!LEVELS.includes(prop)) {
          return target[prop];
        }
        return (...args) => {
          const scopeName = isFunction(scope) ? scope() : scope;
          return target[prop](`${scopeName}::`, ...args);
        };
      },
    });
  }
}

А затем использовать в зависящих сервисах:

class Mailer {
  constructor({ config, log }) {
    this.log = log.scoped('mailer');
    // ...
  }
 
  async send(letter, id) {
    // ...
    this.log.debug(`Sent email to ${letter.to}`);
    // ...
  }
}

Теперь отладочное сообщение о том что письмо послано появится в логах с префиксом, указанным в параметре метода log.scoped() в конструкторе класса Mailer.

Таким же образом реализован прокси объект, передающийся в конструктор сервиса библиотекой Awilix при его создании. По сути, это просто скрытый сервис локатор, функционально абсолютно такой же, какой реализован в инжекторе любой другой библиотеки DI на практически любом языке.

И, на этом месте, я думаю, кому-то уже пришел в голову вопрос: «Разве это не делает паттерны SL и DI по сути одним и тем же?». Нет. Сейчас объясню почему.

Различие между Dependency Injection и Service Locator

На самом деле, разница между этими двумя шаблонами с чисто эмпирической точки зрения очень простая. Она сводится к единственному слову — тестируемость. Например, можно написать юнит-тест для сервиса DI, подменив mock-зависимости:

describe('ContactService.sendMail()', () => {
  let contactService;
  let sendMail = jest.fn();
 
  beforeEach(() => {
    contactService = new ContactService({
      mailerService: { sendMail },
    });
  });
 
  it('should call MailerService.sendMail()', () => {
    contactService.sendMail(contactFixture);
    expect(sendMail).toBeCalledTimes(1);
    expect(sendMail).toBeCalledWith(mailFixture);
  });
});

Такой тест прозрачен по смыслу даже для стороннего читателя и его легко написать благодаря тому, что зависимости передаются в конструктор класса явным образом в виде обычного Plain JavaScript объекта. Это значит, что их легко подменить не совершая лишних телодвижений и не прибегая к использованию бубна и шаманским пляскам.

С другой стороны, можно пользоваться DI фреймворком, но без понимания сути превратить его в SL библиотеку. Например, вот так:

const container = require('./container');
 
class ContactService {
  constructor() {
    this.mailerService = container.cradle.mailerService;
  }
}

Тут не передаются зависимости в конструкторе, а присваиваются из глобального объекта DI контейнера непосредственно. Такой класс будет сложнее тестировать. Чтобы передать поддельную зависимость mailerService нужно будет добавлять ее в контейнер перед каждым тестом, а после завершения оттуда удалять. Таким образом, очень быстро код тестов станет захламлен постоянными регистрациями и удалениями сервиса из контейнера.

Но стоит признать, что это правило работает не всегда. Тут все сильно зависит от специфики тестов, их количества в тест-сьюте и разнообразия. При достаточной простоте и изолированности тестов подстановка через контейнер может оказаться ничуть не сложнее, чем передача зависимостей вручную.

Лучше всего недостатки такого подхода мы могли бы ощутить, если бы JavaScript был статически типизированным языком. Дело в том, что подобная реализация класса позволяет создавать его объекты с неявным требованием зависимостей, проверка которых отсутствует на этапе компиляции. А значит, если в имплементации такого класса добавляется зависимость, то автоматически отследить все места в коде, которые нуждаются в правках, становится невозможно. Но в JavaScript это повседневная практика, поэтому все на что остается полагаться и чем компенсировать — это уровень покрытия тестами.

О недостатках Dependency Injection

Шаблону DI присущ ряд недостатков. В широком смысле их, конечно, больше, но в случае JavaScript я бы выделил только два основных:

  1. Поскольку реализация DI подразумевает динамическое связывание, у IDE не получается автоматически отслеживать информацию о типах JavaScript. Это лишает сервисы подсказок IntelliSense в среде разработки.
  2. Нет поддержки со стороны библиотек. О решении некоторых таких проблем я расскажу отдельно.

Решение проблем с совместимостью

Иногда случается так, что некоторое вычисляемое поле модели ORM зависит от конфигурации приложения. Особенно часто это происходит с путями к файлам, когда базовый адрес задается где-нибудь в настройках приложения. Эту проблему можно обойти путем хранения таких данных в константах, которые будут указывать на пути монтирования docker-томов или на ссылки на директории в файловой системе.

Также бывает необходимо выполнить некоторое действие, непосредственно связанное с моделью, но зависимое от сервисов. Тут тоже нет серебряной пули, но зачастую такое действие и не должно быть частью модели ORM согласно принципу единой ответственности. Например, для загрузки аватара пользователя имеет смысл создать отдельный класс AvatarUploader вместо какого-нибудь метода uploadAvatar() у модели User.

В конце концов, фреймворк ExpressJS не имеет поддержки библиотеками DI из коробки. Но этот вопрос решается тривиально, например, путем привычного для ExpressJS расширения объекта Request дополнительным свойством cradle, ссылающимся на прокси объект Awilix.

Заключение

В заключении хочу сказать, что на данный момент вряд ли существует лучший способ организации сервисов в тестируемом объектно-ориентированном приложении, чем DI. Однако следует иметь в виду, что некорректное его применение может как бессмысленно усложнить код, так и усилить его связанность. Я искренне надеюсь, что эта статья будет вам полезна и поможет в проектировании архитектуры приложений на JavaScript.

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

👍ПодобаєтьсяСподобалось13
До обраногоВ обраному11
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Коменчу давній пост — недавно стикалася з 2-ма проблемами самописного DI на чистому JavaScript:

1) В юніт-тестах класу передавалися всі потрібні залежності, а в реальному коді одна з залежностей не передавалася. Юніт-тести проходили і були зеленими, а при запуску код падав, бо однієї з залежностей не отримував. Яке рішення? Або клас завжди має в конструкторі перевіряти, що він всі потрібні залежності отримав, або це мав піймати інтеграційний тест?

constructor({ config, log }) {
    if (!config) throw new Error(....)
    if (!log) throw new Error(....)
    this.log = log;
}

2) Перейменували функцію/метод у класа — наприклад у класа mailerService зробили описку і замість sendMail метод називається sendMain (тут дуже утровано) а в юніт-тестах все помокано правильно і ContactService отримує фейковий mailerService з назвою метода sendMail. Знов — тести проходять зеленим, а реальний код падає, бо метода sendMail в mailerService по факту нема.

contactService = new ContactService({
      mailerService: { sendMail },
});

З DI на TypeScript таких проблем не було, бо там інтерфейси та required параметри

Зачем все эти костыли с for / if / []?
Вы пытались в 2022 году изобрести функцию .filter() ?

Это просто каждому понятный пример, не требующий погружения в конкретную предметную область. Кроме того была и побочная цель — заронить идею, что многие привычные функции, которыми мы не задумываясь пользуемся в повседневности, на самом деле могут реализовывать тот или иной принцип или паттерн. Поэтому, это хороший знак, что читая статью, в голове что-то щелкнуло, и вы узнали функцию. В этом и была цель использования именно ее — дать читающему ощутить насколько вездесущ IoC, при том что обычно мы можем не обращать на него внимания.

в DI на чистом javascript есть одна, но очень большая проблема. перестает работать интелисенс, тк тот же VSCode не может понять какие методы есть у dependency переданной в конструктор

Да, это так. Но есть решение с помощью JSDoc:

/** @type {import(’./services/Log’)} */
this.log = container.cradle.log;
Или при передаче в конструктор:
/** @param {{ express: import(’express’).Express }} express */
constructor({ express }) {}
Кроме того в VSCode есть возможность ввести строгие проверки типов в стиле TS с помощью директивы @ts-check, например, в начале файла:
// @ts-check
И обратная ей директива @ts-nocheck.

Коментар порушує правила спільноти і видалений модераторами.

Толковый материал, спасибо !

Дякую за статтю! Підкажіть, будь ласка, чому в прикладі з Log/Mailer не скористатись звичайним наслідуванням для створення Scoped Log? Наприклад якось так:


class Log {
  scopeName;
  // ...
 
  debug(...args) {
    return this.scopeName 
      ? this.log('debug', `${this.scopeName}::`, ...args) 
      : this.log('debug', ...args);
  }
)

class MailerLog extends Log {
  scopeName = 'mailer';
}

class Mailer {
  constructor({ config, log }) {
    this.log = log;
    // ...
  }
 
  async send(letter, id) {
    // ...
    this.log.debug(`Sent email to ${letter.to}`);
    // ...
  }
}

Яка вигода саме від динамічного створення за допомогою об’єкта Proxy?

Именно в такой реализации придется своими руками наследовать класс логгера всякий раз, когда понадобится скоуп.

Можно ее улучшить и вынести наследование в метод scoped. Но тогда всякий раз когда вызывается метод scoped() будет создаваться новый инстанс логгера, что приведет к ошибке, когда он попытается открыть уже открытый другим логгером файл.

Поэтому это должен быть не просто потомок, а имплементация паттерна прокси. Тогда всё будет работать как задумывалось. Но паттерн прокси наследует интерфейс, а не реализацию (в случае JS, где интерфейсов нет, это просто анонимный класс-обертка), поэтому нужно будет имплементировать все методы вывода лога таким образом, чтобы они вызывали нужные методы оборачиваемого класса. В итоге, строк кода это займет несколько больше.

Класний матеріал, дякую

Підписатись на коментарі