Всем привет! Сегодня я попробую поэкспериментировать с 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, тем что механизм внедрения наиболее скрыт от клиентского кода.
Что ж, на этом все. Надеюсь было интересно и познавательно. Всем пока!
ссылка на оригинал статьи https://habr.com/ru/post/492150/
Добавить комментарий