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

в 23:19, , рубрики: di, javascript, Разработка веб-сайтов

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

Всем привет! Сегодня я попробую поэкспериментировать с 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 в классах потомках не является нарушением принципа внедрения зависимостей, так как является частью спецификации языка.

Теперь более наглядно сравним используемые подходы во всех трех вариантах:

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

На мой взгляд, последний — 3 вариант наиболее подходит под определения DI & IoC, тем что механизм внедрения наиболее скрыт от клиентского кода.
Что ж, на этом все. Надеюсь было интересно и познавательно. Всем пока!

Автор: Иван

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js