Dependency Injection в Node.js на чистом JavaScript
Всем доброго времени суток! Меня зовут Александр, я — фулстек программист в стеке технологий NodeJS / PHP и React / Vue. В веб-разработку я пришел чуть больше 10 лет назад, пока занимался написанием кода для стартапа в сфере геймдева, и с тех пор участвую в создании разнообразных веб-проектов в качестве фрилансера.
Одним из последних таких проектов было NodeJS приложение, запускаемое на сервере Raspberry Pi, для управления показом рекламных видеослайдов и прочего подобного контента на андроид-приставках, подключаемых к демонстрационным экранам в кинотеатрах. Его код я начинал писать поверх уже имеющихся у компании наработок еще времен io.js, которые внезапно было решено разморозить. Исходники к тому времени давно устарели, многое в них было реализовано без использования сторонних библиотек, а одной из самых частых проблем, что приводила к регрессивным багам, была весьма запутанная самопальная система внедрения зависимостей. Едва ли не каждый четвертый пропущенный баг был так или иначе связан с ней. В итоге проблему решили с помощью увеличения степени покрытия тестами и рефакторинга в сторону полной замены системы вставки зависимостей.
Собственно, о том как реализовать шаблон проектирования Dependency Injection на чистом JavaScript под NodeJS, и пойдет речь в данной статье. Причем, под «чистым JS» я подразумеваю, что:
- Во-первых, будет использоваться именно JavaScript, а не, например, TypeScript, на котором и без того существует множество библиотек и фреймворков. Например, таких как NestJS, который предоставляет достойную реализацию DI из коробки.
- Во-вторых, у нас, как разработчиков, не будет возможности провести транспиляцию кода при помощи 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 объекта, а потом использовать в виде зависимостей в классах.
В-четвертых, в контексте помимо синглтонов иногда бывает необходимо создавать разного рода временные объекты, например:
- Объекты со временем жизни равным времени выполнения запроса. Подходит для данных вроде информации о текущем пользователе.
- Объекты, создающиеся по новой при каждом обращении.
- Или создающиеся на определенный промежуток времени, а по его истечению пересоздающиеся снова.
Кроме того, в-пятых, было неплохо, чтобы фабричный метод умел создавать объекты конкретных классов на основании информации о базовом.
И, в-шестых, может оказаться не лишним научить рисолвер подставлять нужную имплементацию сервиса в зависимости от некоторых определенных пользователем условий.
Решением всех этих задач занимаются различные
Библиотека awilix
На данный момент это, наверное, самый популярный
Приложение на 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 я бы выделил только два основных:
- Поскольку реализация DI подразумевает динамическое связывание, у IDE не получается автоматически отслеживать информацию о типах JavaScript. Это лишает сервисы подсказок IntelliSense в среде разработки.
- Нет поддержки со стороны библиотек. О решении некоторых таких проблем я расскажу отдельно.
Решение проблем с совместимостью
Иногда случается так, что некоторое вычисляемое поле модели ORM зависит от конфигурации приложения. Особенно часто это происходит с путями к файлам, когда базовый адрес задается где-нибудь в настройках приложения. Эту проблему можно обойти путем хранения таких данных в константах, которые будут указывать на пути монтирования docker-томов или на ссылки на директории в файловой системе.
Также бывает необходимо выполнить некоторое действие, непосредственно связанное с моделью, но зависимое от сервисов. Тут тоже нет серебряной пули, но зачастую такое действие и не должно быть частью модели ORM согласно принципу единой ответственности. Например, для загрузки аватара пользователя имеет смысл создать отдельный класс AvatarUploader вместо какого-нибудь метода uploadAvatar() у модели User.
В конце концов, фреймворк ExpressJS не имеет поддержки библиотеками DI из коробки. Но этот вопрос решается тривиально, например, путем привычного для ExpressJS расширения объекта Request дополнительным свойством cradle, ссылающимся на прокси объект Awilix.
Заключение
В заключении хочу сказать, что на данный момент вряд ли существует лучший способ организации сервисов в тестируемом объектно-ориентированном приложении, чем DI. Однако следует иметь в виду, что некорректное его применение может как бессмысленно усложнить код, так и усилить его связанность. Я искренне надеюсь, что эта статья будет вам полезна и поможет в проектировании архитектуры приложений на JavaScript.
9 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів