Внедри это полностью. DI-in-JS

от автора

Всем привет! Сегодня я попробую поэкспериментировать с 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. Алгоритм ее работы такой:

  1. Получить исходный класс;
  2. Получить сигнатуру конструктора и выделить зависимости;
  3. Сохранить информацию об исходном классе и зависимостях для повторного использования;
  4. Создать фабричную функцию для создания инстанса исходного класса;
  5. Создать все выделенные зависимости и поместить их в поля инстанса исходного класса;

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

Теперь разберем очевидные минусы такой реализации:

  1. Используется приём называемый затенение переменной. В данном случае это затенение имени исходного класса именем константы. Всегда есть вероятность, что код будет написан так, что исходный класс выйдет из тени, и что-то сломается.
  2. Есть очевидная проблема с фабриками в качестве зависимости. Если выделять такие фабрики из сигнатуры конструктора, то это усложнит парсер, понадобятся проверки корректного вызова фабрики и все это чревато ошибками. Поэтому фабрики-зависимости передаются через отдельный параметр 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 такой:

  1. получить контекст инстанса (this)
  2. получить конструктор класса — context.constructor.
  3. получить имена полей для внедрения зависимостей.
  4. если это первый экземпляр класса, то сохранить описание зависимостей в реестр — inject.depsRegistry
  5. Создать инстансы всех зависимостей и записать в поля контекста — 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *