Всем привет! Сегодня я попробую поэкспериментировать с Dependency Injection на чистом JavaScript. Тех кто не в курсе, что это за дичь и как ее готовить, приглашаю ознакомиться. Ну а у тех кто в курсе будет повод написать важный и полезный комментарий. Итак, погнали…
Dependency Injection
DI — архитектурный паттерн, который призван уменьшить связанность сущностей системы — компонентов, модулей, классов. Чем меньше связанность (не путать со связностью), тем проще изменение этих самых сущностей, добавление новых и их тестирование. В общем плюс на плюсе, но посмотрим так ли это на самом деле.
Без DI:
class Engine {...};
class ElectroEngine {...};
class Transmission {...};
class Chassis {...};
class TestChassis {...};
class Car {
constructor() {
this.engine = new Engine();
this.transmission = new Transmission();
this.chassis = new Chassis();
}
}
class ElectroCar {
constructor() {
this.engine = new ElectroEngine();
this.transmission = new Transmission();
this.chassis = new Chassis();
}
}
class TestCar {
constructor() {
this.engine = new Engine();
this.transmission = new Transmission();
this.chassis = new TestChassis ();
}
}
const car = new Car();
const electroCar = new ElectroCar();
const testCar = new TestCar();
С DI:
class Engine{...};
class ElectroEngine {...};
class TestEngine {...};
class Transmission {...};
class TestTransmission {...};
class Chassis {...};
class SportChassis {...};
class TestChassis {...};
class Car {
constructor(engine, transmission, chassis) {
this.engine = engine;
this.transmission = transmission;
this.chassis = chassis;
}
}
const petrolCar = new Car(new Engine(), new Transmission(), new Chassis());
const sportCar = new Car(new Engine(), new Transmission(), new SportChassis());
const electroCar = new Car(new ElectroEngine(), new Transmission(), new Chassis());
const testCar = new Car(new TestEngine(), new TestTransmission(), new TestChassis());
В первом примере без DI наш класс Car привязан к конкретным классам, и поэтому чтобы создать, например, electroCar приходиться делать отдельный класс ElectroCar. В этом варианте имеет место "жесткая" зависимость от реализации т.е. зависимость от инстанса конкретного класса.
Во втором же случае — с DI, довольно просто создать новые типы Car. Можно просто передавать в конструктор разные типы зависимостей. Но! Реализующие одинаковый интерфейс — набор полей и методов. Можно сказать, что в этом варианте "мягкая" зависимость от абстракции — интерфейса.
Видимо DI и правда может упростить жизнь разработчика. Но именно в таком "ручном" внедрении существует очевидный минус — внешнему коду нужно самостоятельно создавать все зависимости класса, вместо того, чтобы он сам позаботился об этом. А если у зависимостей в свою очередь тоже есть зависимости, а у тех еще? Может получиться совсем не так уж красиво, как кажется. Например:
class Engine{
constructor(candles, pistons, oil) {….}
};
class Chassis{
constructor(doors, hood, trunk) {….}
};
const petrolCar = new Car(
new Engine(new Candles(), new Pistons(), new Oil() ),
new Transmission(…..),
new Chassis(new Doors, new Hood(), new Trunk())
);
Выглядит все это довольно удручающе. Гораздо проще вызвать конструктор без всяких аргументов, а он уж пусть сам разберется какие зависимости ему нужны. Но как было сказано выше, это может вызывать проблемы при изменении, расширении и тестировании.
Inversion of Control
Тут на помощь "ручному" DI-ю приходит другой паттерн — Inversion of Control (IoC). Суть которого в том, что разработчик часть своих полномочий отдает на откуп внешней программой сущности — функции, библиотеке или фреймворку. Касательно DI, IoC заключается в том, что мы просто указываем зависимости при описании класса. А созданием инстансов этих зависимостей управляет какой-то внешний код, при инициализации инстанса основного класса. Например:
class Engine{...};
class Transmission{...};
class Chassis{…}
class Car {
constructor(engine: Engine, transmission: Transmission, chassis: Chassis) {}
}
const car = new Car();
car.engine instanceof Engine; //*true*
То есть для создания инстанса нужен просто вызов конструктора — new Car(). Все как и хотелось — легко расширяемый и тестируемый код, a также нет ручного создания зависимостей.
DI-in-JS
А теперь вернемся в суровую реальность JS. И здесь нет ни синтаксиса указания типа, ни DI из коробки. И это ли не повод поизобретать "велосипед".
Итак, синтаксиса типов нет, но есть классы, которые по сути функции, которые если точнее функциональные объекты. А объекты можно передавать как аргументы или указывать в качестве значения параметров по умолчанию. Например.
constructor(engine = Engine, transmission = Transmission, chassis = Chassis)
Это довольно таки похоже на :
constructor(engine: Engine, transmission: Transmission, chassis: Chassis)
Но само по себе это ничего не дает, это просто некая условная привязка параметров к типам. Согласно принципу IoC нам нужна некая «внешняя» сущность, которая реализует процесс инициализации и внедрения указанных зависимостей. Предположим такая сущность есть, но каким образом ей получить или ей передать информацию о зависимостях?
Самое время вспомнить такое понятие, как Reflection. Если коротко, то рефлексия — это способность кода анализировать свою структуру и в зависимости от этого менять свое поведение во время исполнения.
Посмотрим, какие метаданные функций доступны в JS:
function reflectionMetaInfo(a) { console.log(a); }
reflectionMetaInfo.name ; // reflectionMetaInfo;
reflectionMetaInfo.length ; //1
reflectionMetaInfo.toString(); //function reflectionMeta(a) { console.log(a);}
arguments; //Arguments [%value%/]
Очевидно, что больше всего информация дает метод toString(). Он фактически возвращает исходный код функции. Проблема в том, что это просто строка текста, поэтому нужен некий строковый парсер. Но все тело класса (функции) парсить нет необходимости, важно только выделить сигнатуру конструктора и разобрать параметры. Примерно, как то так.
const constructorSignature = classFunc
.toString()
.replace(/s|['"]/g, '')
.replace(/.*(constructor((?:w+=w+?,?)+)).*/g, '$1')
.match(/((.*))/)[1]
.split(',')
.map(item => item.split('='));
constructorSignature // [ [dep1Name, dep1Value], [dep2Name, dep2Value] …. ]
Наверняка, можно написать более короткий вариант парсинга, но это уже нюансы. Главное, что теперь есть имена полей, в которые должны помещаться зависимости. А так же есть имена классов зависимостей. Осталось написать IoC — сущность которая будет заниматься созданием инстансов и внедрением зависимостей в поля этих инстансов.
Попытка номер раз:
function Injectable(classFunc, options) {
const
depsRegistry = Injectable.depsRegistry || (Injectable.depsRegistry = {}),
className = classFunc.name,
factories = options && options.factories;
if (factories) {
Object.keys(factories).forEach(factoryName => {
depsRegistry[factoryName] = factories[factoryName];
})
}
const depDescriptions = classFunc.toString()
.replace(/s/g, '')
.match(/constructor((.*)[^(]){/)[1]
.replace(/"|'/g, '')
.split(',')
.map(item => item.split('='));
const injectableClassFunc = function(...args) {
const instance = new classFunc(...args);
depDescriptions.forEach(depDescription => {
const
depFieldName = depDescription[0],
depDesc = depDescription[1];
if (instance[depFieldName]) return;
try {
instance[depFieldName] = new depsRegistry[depDesc]();
} catch (err) {
instance[depFieldName] = depDesc;
}
});
return instance;
}
return depsRegistry[classFunc.name] = injectableClassFunc;
}
class CustomComponent {
constructor(name = "Custom Component") {
this.name = name;
}
sayName() {
alert(this.name);
}
}
const Button = Injectable(
class Button extends CustomComponent {
constructor(name = 'Button') {
super(name);
}
}
)
const Popup = Injectable(
class Popup extends CustomComponent {
constructor(
confirmButton = 'confirmButtonFactory',
closeButton = Button,
name = 'NoticePopup'
) {
super(name);
}
},
{
factories: {
confirmButtonFactory: function() { return new Button('Confirm Button') }
}
}
);
const Panel = Injectable(
class Panel extends CustomComponent {
constructor(
closeButton = 'closeButtonFactory',
popup = Popup,
name = 'Head Panel'
) {
super(name);
}
},
{
factories: {
closeButtonFactory: function() { return new Button('Close Button') }
}
}
);
const customPanel = new Panel();
Для примера я использовал классы неких условных элементов интерфейса, на этом не стоит акцентировать внимание, важно лишь отношение между классами. Итак, что же получилось. А получился декоратор — функция Injectable, которая выполняет роль IoC. Алгоритм ее работы такой:
- Получить исходный класс;
- Получить сигнатуру конструктора и выделить зависимости;
- Сохранить информацию об исходном классе и зависимостях для повторного использования;
- Создать фабричную функцию для создания инстанса исходного класса;
- Создать все выделенные зависимости и поместить их в поля инстанса исходного класса;
Так как в качестве зависимостей могут выступать любые значения, то применяется конструкцию try-catch для отлова ошибок при попытке создать инстанс зависимости.
Теперь разберем очевидные минусы такой реализации:
- Используется приём называемый затенение переменной. В данном случае это затенение имени исходного класса именем константы. Всегда есть вероятность, что код будет написан так, что исходный класс выйдет из тени, и что-то сломается.
- Есть очевидная проблема с фабриками в качестве зависимости. Если выделять такие фабрики из сигнатуры конструктора, то это усложнит парсер, понадобятся проверки корректного вызова фабрики и все это чревато ошибками. Поэтому фабрики-зависимости передаются через отдельный параметр option.factories, а в конструкторе указываем имя фабрики.
Попробуем решить выше описанные проблемы.
Попытка номер два:
function inject(context, ...deps) {
const
depsRegistry = inject.depsRegistry || (inject.depsRegistry = {}),
className = context.constructor.name;
let depsNames = depsRegistry[className];
if (!depsNames) {
depsNames
= depsRegistry[className]
= context.constructor
.toString()
.replace(/s|['"]/g, '')
.replace(/.*(inject((?:w+,?)+)).*/g, '$1')
.replace(/inject((.*))/, '$1')
.split(',');
depsNames.shift();
}
deps.forEach((dep, index) => {
const depName = depsNames[index];
try {
context[depName] = new dep();
} catch (err) {
context[depName] = dep;
}
});
return context;
}
class Component {
constructor(name = 'Component') {
inject(this, name);
}
showName() {
alert(this.name);
}
}
class Button extends Component {
constructor(name = 'Component') {
super();
inject(this, name);
}
disable() {
alert(`button ${this.name} is disabled`);
}
enable() {
alert(`button ${this.name} is enabled`);
}
}
class PopupComponent extends Component {
show() {
alert(`show ${this.name} popup`);
}
hide() {
alert(`hide ${this.name} popup`);
}
}
class TopPopup extends PopupComponent {
constructor(
popupButton = Button,
name = 'Top Popup'
) {
super();
inject(this, popupButton, name);
this.popupButton.name = 'TopPopup Button';
}
}
class BottomPopup extends PopupComponent {
constructor(
popupButton = function() { return new Button('BottomPopup Button') },
name = 'Bottom Popup'
) {
super();
inject(this, popupButton, name);
}
}
class Panel extends Component {
constructor(
name = 'Panel',
popup1 = TopPopup,
popup2 = BottomPopup,
buttonClose = function() { return new Button('Close Button') }
) {
super();
inject(this, name, popup1, popup2, buttonClose);
}
}
const panel = new Panel('Panel 1');
Итак в данном варианты имеется переход от декорирования к делегированию. Т. е. в конструкторе класса, есть явный вызов функции inject, которой делегируются полномочия по созданию зависимостей.
Алгоритм работы inject такой:
- получить контекст инстанса (this)
- получить конструктор класса — context.constructor.
- получить имена полей для внедрения зависимостей.
- если это первый экземпляр класса, то сохранить описание зависимостей в реестр — inject.depsRegistry
- Создать инстансы всех зависимостей и записать в поля контекста — context
В этой реализации один большой и очевидный минус — нарушается правило внедрения зависимостей. В конструкторе вызывается внешняя функция — Inject , которая по сути тоже является зависимостью. Что ж, надо попробовать устранить и этот недостаток.
Попытка номер три:
class Injectable {
constructor(...dependensies) {
const
depsRegistry = Injectable.depsRegistry || (Injectable.depsRegistry = {}),
className = this.constructor.name;
let depNames = depsRegistry[className];
if (!depNames) {
depNames = this.constructor
.toString()
.replace(/s|['"]/g, '')
.replace(/.*(super((?:w+,?)+)).*/g, '$1')
.replace(/super((.*))/, '$1')
.split(',');
}
dependensies.forEach((dependense, index) => {
const depName = depNames[index];
try {
this[depName] = new dependense();
} catch (err) {
this[depName] = dependense;
}
})
}
}
class Component extends Injectable {
showName() {
alert(this.name);
}
}
class Button extends Component {
constructor(name = 'button') {
super(name);
}
disable() {
alert(`button ${this.name} is disabled`);
}
enable() {
alert(`button ${this.name} is enabled`);
}
}
class PopupComponent extends Component {
show() {
alert(`show ${this.name} popup`);
}
hide() {
alert(`hide ${this.name} popup`);
}
}
class TopPopup extends PopupComponent {
constructor(
popupButton = Button,
name = 'Top Popup'
) {
super(popupButton, name);
this.popupButton.name = 'TopPopup Button';
}
}
class BottomPopup extends PopupComponent {
constructor(
popupButton = function() { return new Button('BottomPopup Button') },
name = 'Bottom Popup'
) {
super(popupButton, name);
}
}
class Panel extends Component {
constructor(
name = 'Panel',
popup1 = TopPopup,
popup2 = BottomPopup,
buttonClose = function() { return new Button('Close Button') }
) {
super(name, popup1, popup2, buttonClose);
}
}
const panel = new Panel('Panel 1');
В данном варианте используется наследование и внедрением зависимостей занимается базовый класс Injectable. При это вызов super в классах потомках не является нарушением принципа внедрения зависимостей, так как является частью спецификации языка.
Теперь более наглядно сравним используемые подходы во всех трех вариантах:
На мой взгляд, последний — 3 вариант наиболее подходит под определения DI & IoC, тем что механизм внедрения наиболее скрыт от клиентского кода.
Что ж, на этом все. Надеюсь было интересно и познавательно. Всем пока!
Автор: Иван