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

    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 22

      +2
      Написать кучу мягко говоря неочевидного кода вместо того, чтоб просто спокойно воспользоваться factory или factory method — это, разумеется, особый вид специальной олимпиады.

      Но за старания — респект :-)
        +1

        Factory Method != Inversion of Control


        Поэтому нельзя "просто спокойно воспользоваться factory или factory method", чтобы получить инверсию управления. Приходится "написать кучу мягко говоря неочевидного кода". А в JS'е, с его отсутствием интерфейсов и так себе рефлексией, эта неочевидность только возрастает.


        Я летом прошлого года что-то подобное DI-контейнеру пытался изобразить — teqfw/di, было интересно. Но потом фокус сдвинулся.


        Добавил статью в закладки, если вернусь к теме, попробую погрузиться в эту версию мягко говоря неочевидного кода.


        IoC — это массивные проекты с огромной кодовой базой и независимыми группами разрабов. В других проектах их полезность не так заметна.

          0
          Давайте я попрошу вас покликать по вашим же ссылкам, ок?
          IoC реализуется в том числе и с помощью фабрики. Factory method — это частный случай фабрики.
          Не всякая реализация factory method будет обеспечивать IoC, но вы можете реализовать factory или factory method так, чтоб обеспечить IoC.

          Так понятнее?
            0

            "кирпич" != "дом", хотя дом может состоять из кирпичей. Так понятнее?

              +2
              Вот изначальный аргумент — он как раз в том, что построить дом из кирпичей будет проще, чем реализовывать абстракцию «дом» из вороха неочевидных вещей.

              Если вы считаете, что рефлексия для IoC просто нужна, и без неё совсем ой — вы строите дом из клея и песка. Даже несмотря на то, что он у вас определенно получится, если взять достаточно того и другого.
                –2

                "Если вы считаете, что рефлексия для IoC просто нужна, и без неё совсем ой ..." (с)


                Для DI нужна. Статья так и называется "Внедри это полностью. DI-in-JS" Попробуйте реализовать DI-контейнер без рефлексии и без декларативного описания зависимостей через конфиги. Поэтому и ворох неочевидных вещей.


                Но лично вы считаете, что для JS'а хватает и фабрик.

                  +1

                  Выскажусь в защиту мнения JustDont, потому что видел реализацию DI, например http://www.slimframework.com/docs/v4/concepts/di.html, которая представляет собой контейнер, который предлагает метод для получения зависимостей. Рефлексия — это одна частная имплементация DI.
                  Насколько я понимаю DI, часть приложения будет вынуждено знать о контейнере, но главное не наделять этим знанием все подряд без разбора.

            0
            По поводу очевидности частично согласен. За респект спасибо))
            А вот по поводу паттерна factory непонятно. Там кода получиться не меньше, вы же должны писать кучу классов под каждый случай ConcreteCreator.create() -> ConcreteProduct. О каком DI тут речь непонятно. Либо же вы расширяете этот паттерн и в фабричный метод передаете конфиги с описанием имен и классов зависимостей.
            Хотелось бы увидеть реализацию, просто я пока не понимаю что вы имели в виду.
            +2
            Я в качестве IoC с недавнего времени стал использовать awilix
              +3
              Затенение — плохая практика.
              class Foo {
                constructor(dbConnection: DbConnection = "DbConnection")
              }
              

              т.е. ты ожидаешь объект, но инициализируешь его как строку. И делаешь это не потому что Foo это необходимо, но для того чтобы сделать его «Injectable». А Foo не должен знать что Injectable он или нет.

              2 Как будет работать этот подход после того по коду пройдет `uglify`?

              3. Что если мне надо внедрить не объект, а строку (или объект который является результатом выполенеия некой функции. Например DSN который будет разным для разных окружений?

              Я тоже интересовался этой темой и написал свой велосипед
              www.npmjs.com/package/rsdi

              // your classes 
              class CookieStorage {}
              class AuthStorage {
                constructor(storage: CookieStorage) {}
              }
              
              // configure DI
              import DIContainer, { object, get, factory, IDIContainer } from "rsdi";
               
              const config = {
                  "ENV": "PRODUCTION",               // define raw value
                  "AuthStorage": object(AuthStorage).construct(
                     get("Storage")                         // refer to another dependency       
                  ),
                  "Storage": object(CookieStorage),         // constructor without arguments       
                  "BrowserHistory": factory(configureHistory), // factory (will be called only once)  
              };
              const container = new DIContainer();
              container.addDefinitions(config);
               
              function configureHistory(container: IDIContainer): History {
                  const history = createBrowserHistory();
                  const env = container.get("ENV");
                  if (env === "production") {
                      // do what you need
                  }
                  return history;
              }
               
              // in your code
               
              const env = container.get<string>("ENV"); // PRODUCTION
              const authStorage = container.get<AuthStorage>("AuthStorage");  // object of AuthStorage
              const history = container.get<History>("BrowserHistory");  // History singleton will be returned
              
                +1
                Так я вроде и написал что затенение это плохо)))
                container.get — это чистой воды Service Locator, который кстати многие считают плохой практикой и анти-паттерном.
                  0
                  container.get — это чистой воды Service Locator,

                  нет. Это Dependency Injection Container. И разница есть
                  habr.com/ru/post/465395
                    0
                    Развивая тему — да, ты можешь пихать `container` во все классы и таким образом превратить его в ServiceLocator. Но мы ведь не будем так делать.

                    class AuthStorage {
                      constructor(storage: CookieStorage) {
                        this.storage = storage;
                      }
                    }
                    vs
                    class AuthStorage {
                      constructor(container: IDIContainer) {
                        this.storage = container.get<AuthStorage>("AuthStorage");
                      }
                    }
                    
                +2
                Inversion of Control – это принцип проектирования, который противопоставляется традиционному подходу тем, что если в традиционном подходе «пользовательский» код вызывает зависимости, то в случае инверсии контроля – зависимости вызывают и управляют пользовательским кодом.

                То есть, проще говоря, это то что отличает фреймворк от библиотеки с набором функций.

                А то что вы пытаетесь преподнести как IoC называется композиция.
                  0
                  Пример из https://en.wikipedia.org/wiki/Inversion_of_control

                  public class ServerFacade {
                      public <K, V> V respondToRequest(K request, DAO dao) {
                          return dao.getData(request);
                      }
                  }


                  Из статьи
                  class Component {
                  
                      constructor(name = 'Component') {
                  
                          inject(this, name);
                      }
                  


                  Зависимость, управляет пользовательским кодом.
                  0

                  А я вот одно время кайфовал от inversify

                    0
                    А что изменилось?
                    По моим впечатлением, оно уже долгое время является самой мощной и удобной реализацией в мире JS/TS.
                      0

                      Перешел на другие проекты, которые написаны на других языках.

                    +3
                    Завязка на .toString() для выполнения DI не очень хорошая мысль. Подобный подход провернули еще в первом ангуляре при иджекте сервисов по именам параметров, но это очень хорошо выстрелило в ногу с минификацией, т.к. замена имен параметров невозможна при таком подходе, а если принимать во внимание отдельный транспилятор как babel или ts, то ситуация становится еще хуже.

                    Также возникают вопросы с правильной типизацией всего, т.к. перебивание типа у this вызывает слишком большие вопросы как в ts, так и во flow.
                      0
                      но это очень хорошо выстрелило в ногу с минификацией, т.к. замена имен параметров невозможна при таком подходе

                      Если мне не изменяет память, то это тривиально настраивалось. У минификатора есть набор флагов. Полагаю то, что от этого подхода отказались было завязано всё таки на какие-то ещё причины.

                      +2
                      На мой взгляд, последний — 3 вариант наиболее подходит под определения DI & IoC, тем что механизм внедрения наиболее скрыт от клиентского кода.

                      Имхо, наследование — наименее "скрытый" механизм. По сути весь проект вынужден наследоваться от одного базового класса, который не имеет отношения к предметной области. И да, не забываем явно вызывать родительский конструктор из потомков.

                        0

                        del

                        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.