Внедрение зависимостей (dependency injection) через свойства-функции в JavaScript



    Известный, но не очень популярный способ внедрения зависимостей. Попытка реализовать этот способ в популярных DI npm пакетах. Еще один свой DI.


    Несколько слов об OOP и DI


    Тему противопоставления ООП другим парадигмам хотел бы оставить в стороне. На мой взгляд в одном приложении вполне могут сочетаться разные парадигмы. Считаю ES классы большим шагом в сторону привлекательности js для использования ООП.


    Небольшая история из личного опыта. В 2006 году был гораздо более популярен, чем сейчас — язык PERL. Он гибкий. Я в том году написал свою OO реализацию, и небольшое приложение, язык PERL это позволяет, пара мануалов 1, 2, и безграничные возможности.

    Потом вернулся к этой поделке через 2 месяца, понял что потратил приличное количество времени чтобы погрузиться в свою же ОО реализацию, да и она оказалась не такой уж хорошей, как я думал изначально. Еще через полгода снова пришлось вернуться, и понял что потратил еще больше времени чтобы понять свой же код. Плюс пришлось потратить время на поддержку этой ОО реализации. Для себя сделал вывод, что больше так делать не буду.


    Программируя на JavaScript, я чувствовал, что будет такая же проблема. Вроде ООП хочется, но посмотришь вокруг сколько вариантов как это сделать с прототипной моделью, и все не стандарты.


    TypeScript тогда не было, но и когда появился с первого раза у меня ничего не получилось, все на каком-то ровном месте пляски с бубном были (это конечно субъективно). Тогда не срослось.


    У меня был внутренний настрой, чем меньше JS в проекте, тем лучше. Я использовал JQuery UI Widget Factory. Не идеально, но можно расширять и какой-никакой стандарт, и в целом достаточно быстро получалось. Сейчас ES6 classes после множества локальных реализаций классов на ES5 просто прорыв и возможность использовать ООП. И по появлению ES6 классов можно подумать и о новых реализациях DI.


    Внедрение зависимостей (dependency injection) считаю важным инструментом парадигмы ООП. Все легко, когда мы хотим отнаследоваться от одного класса, и немножко изменить поведение под свой проект. Но если мы добавляем сложную библиотеку из нескольких классов, и в ней есть DI, то получаем гибкое приложение.


    DI может избавить библиотеку от монструозности. Например, библиотека — календарик. Вариаций, как может быть нарисован календарь бесконечное количество (один/несколько месяцев, формат даты, времени, язык, стандарты...). Предусмотреть все возможные варианты как аргументы/параметры автору библиотеки просто невозможно. А если и захочет, то простенький календарик может превратиться в “монстрокалендарь”, который будут бояться использовать из-за его размеров. Но если будет возможность конечному клиенту легко чуточек допилить под себя или подключить плагин — календарик становится прекрасным! Вполне себе аргументы для использования DI.


    В написании тестов DI может быть полностью самодостаточным инструментом — помощником.


    По теме статьи


    Один из вариантов реализации внедрения зависимостей — через свойство, причем в свойстве и передается зависимость. Javascript позволяет определять функции как переменные. А прототипная модель позволяет легко менять контексты у этих функций. В итоге можно реализовать внедрение зависимостей через функции, которые возвращают необходимые зависимости.


    Проще всего пояснить примером.


    Допустим есть класс App приложения,


    класс Storage — какое то хранилище (один экземпляр на все приложение singleton/service),


    и класс Date, для работы с датой (под каждую дату понадобится отдельный экземпляр).


    Функции-свойству которая каждый след. вызов будет создавать новый объект (transient) добавим префикс “new”.


    Функции-свойству всегда отдающую один и тот же объект (singleton) добавим префикс “one”.



    class App {
    
        /** @type { function( int ):IDate } */
        newDate;
    
        /** @type { function(): IStorage } */
        oneStorage;
    
        construct( newDate, oneStorage ) {
            this.newDate = newDate;
            this.oneStorage = oneStorage;
        }
    
        main() {
            const oDate1 = this.newDate( 0 );
            const oDate2 = this.newDate( 1000 );
            const oStorage = this.oneStorage();
            oStorage.insert( oDate1.format() + ' - ' + oDate2.format() );
        }
    }
    

    Мне нравится такой подход, тем что он максимально универсален. Так можно внедрять все что угодно и по умолчанию отложено (lazy). Когда добавляется много вариантов из конкретных реализаций, как внедрять (например в inversify: to, toSelf, toConstantValue, toDynamicValue, toConstructor, toFactory, toFunction, toAutoFactory, toProvider, toService), вся концепция DI становится сложной на ровном месте. Поэтому если внедрять везде одинаково, то можно писать быстрее.


    Часто конкретная реализация DI накладывает определенные требования на код самих компонент или сами компоненты становятся зависимы от системы внедрения зависимостей. Я попробовал этот пример оформить в популярных DI реализациях, найти максимально универсальный формат компоненты, заодно оформить их в некую сравнительную табличку. Ниже опишу мои впечатления от различных DI реализаций.



    Разные трактовки назначения dependency injection


    Прежде, чем привести табличку, хочу обратить внимание на то, что все библиотеки очень разные. И дополнительная разница появляется от разных трактовок назначения dependency injection. Я условно их разделил по своему видению:


    1. Дать возможность писать тесты, не изменяя исходный код. Тестирование.
    2. Уменьшить связность кода, оставляя в реализации компонента ключи/токены для обращения к другим компонентам. Удобство поддержки, повторное использование, тестирование.
    3. Уменьшить связность кода, добавляя интерфейсы доступа к другим компонентам. Удобство поддержки, повторное использование, тестирование, безопасность, автокомплит/навигация IDE.

    Мало где уделяют внимание на независимость компонент от DI. Но на мой взгляд у любой библиотеки появляется дополнительное преимущество, если ее компоненты могут работать с разными DI реализациями, а не тянут конкретную вместе с собой.


    Во многих DI реализациях используются декораторы. Можно в библиотеке код компоненты оставить чистым, а декорировать в отдельном файле. Для клиента появляется вариант, либо импортировать чистый компонент, либо вместе с конкретным DI декоратором.


    В целом, чем выше цифра, тем больше абстракций, больше гибкость, больше времени на разработку, ниже скорость исполнения кода. Поэтому говорить что, что-то лучше, или что-то хуже, неправильно. Есть разные инструменты для разных нужд. Выбор инструмента соответствующего задаче — настоящее “кунг-фу” )



    Популярные dependency injection вспомогательные библиотеки javascript/typescript


    Сделал небольшой парсер, разбирающий попадание сочетания “di” в npm. Пакетов по этой теме ~1400. Все рассмотреть невозможно. Рассмотрел в порядке уменьшения количества npm dependents.



    repo npm dependents npm weekly downloads github stars возраст, лет последняя правка, мес назад lang ES classes interfaces inject property bundle size, KB open github issues github forks
    inversify/Inversifyjs
    1798
    408k 6.6k 6 1 TS + + + 63.3 204 458
    typestack/typedi 353 62k 1.9k 5 3 TS + + + 30.3 17 98
    thlorenz/proxyquire 344 426k 2.6k 8 8 ES5 ? ? ? ? 9 116
    jeffijoe/awilix 244 42k 1.7k 5 1 TS + - - 31.7 2 92
    aurelia/dependency-injection
    153
    13k 156 6 2 TS + - ? ? 2 68
    stampit-org/stampit 170
    22k 3k 8 1 ES5 ? ? ? ? 6 107
    microsoft/tsyringe
    149
    80k 1.5k 3 1 TS + + - 30.4 27 69
    boblauer/mock-require
    136 160k 441 6 1 ES5 ? ? ? ? 4 29
    mgechev/injection-js 105 236k 928 4 1 TS + -? ? 41.7 0 48
    young-steveo/bottlejs
    101
    16k 1.2k 6 1 ES5 + D.TS -? - - 13.3 2 63
    jaredhanson/electrolyte
    33 1k 569 7 1 ES5 - - - ? 25 65
    zhang740/power-di
    10
    0.2k 65 4 1 TS + + + 45.0 2 69
    jpex-js/vue-inject
    9 0.8k 174 4 12 ES5 - - ? ? 3 14
    zazoomauro/node-dependency-injection
    5
    1k 123 4 2 ES6 + D.TS + -? + 291.0 3 17
    justmoon/constitute 4 8k 132 5 60 ES6 + -? - 56.2 4 6
    owja/ioc 1
    2k 158 1 3 TS + + + 11.3 4 5
    kraut-dps/di-box
    1 0k 0 0 1 ES6 + D.TS + + + 11.1 0 0


    Gitcompare ссылка


    Codesandbox код реализации моего примера



    https://github.com/inversify/InversifyJS


    Наверное самый сложный, но и мощный пакет, возможно немного субъективно, потому что пример с ним делал самым первым. После него многие другие казались упрощенными версиями )).Наверное сложно придумать кейс, который бы не рассматривался авторами библиотеки. Монстр)



    https://github.com/typestack/typedi


    Чувствуется, что библиотека мощная, много разных возможностей. К сожалению, пока не смог разобраться, как я могу в App создать два разных экземпляра Date, с разными аргументами конструктора. Быть может здесь есть опытные его пользователи, которые подскажут?



    https://github.com/thlorenz/proxyquire


    Позволяет оставить код таким какой он есть, подменять содержимое файлов. В большей степени только для тестов. Сложно назвать DI, но для определенных задач может быть очень подходящим.



    https://github.com/jeffijoe/awilix


    Не получилось реализовать, возникает ошибка “Symbol(Symbol.toPrimitive)”, как я понял, из-за того что в основе библиотеки Proxy, а у меня один из сервисов наследник от нативного Date класса. Не увидел в примерах использования интерфейсов.



    https://github.com/aurelia/dependency-injection


    Судя по документации и примерам создана именно с основной целью целью иметь возможность разбивать классы на более мелкие. Является частью фреймворка Aurelia.



    https://github.com/stampit-org/stampit


    Необычная ОО реализация. Множественное наследование. Не пытался что-то делать.



    https://github.com/microsoft/tsyringe


    Я не фанат Microsoft, но объективно написать реализацию в их библиотеке у меня получилось быстрее всех остальных. Все умеет, специально выделили что инъекция свойства не реализована и никогда не будет реализована.



    https://github.com/boblauer/mock-require


    По задумке очень похожа на proxyquire.



    https://github.com/mgechev/injection-js


    Использовалась в Angular 4. Обширные возможности, конкретно мой пример реализовать не получилось, непонятно как в useFactory передать аргумент.



    https://github.com/young-steveo/bottlejs


    Мой пример сделать не получилось. Вроде подходит метод .instanceFactory, но как туда передать аргумент не понятно.



    https://github.com/jaredhanson/electrolyte


    Не пытался реализовать. Варианты с ES6 классами пока не реализованы автором.



    https://github.com/zhang740/power-di


    Много возможностей. Есть специальный код для использования вместе с React. Чрезвычайно маленькая документация. Чтобы разобраться как что-либо сделать приходится смотреть тесты пакета. Не без костылей, но реализовал свой пример.



    https://github.com/jpex-js/vue-inject


    Специфичный для vue без ES6 классов инструмент. Не рассматривал. В этом фреймворке есть и возможность ипспользовать ES6 classes, и есть функционал provide inject через который можно использовать DI. Библиотека кажется устаревшей.



    https://github.com/zazoomauro/node-dependency-injection


    Конфигурация зависимостей определяется отдельным YAML/JS/JSON файлом. Для сервера. Основана на концепции фреймворка на php symfony Мой пример сделать не получилось, думал через костыли и передачу класса в setParameter, но и там ограничение, невозможно использовать конструктор класса как параметр.



    https://github.com/justmoon/constitute


    Реализовал, но костылями, которые аннулируют все DI преимущества.



    https://github.com/owja/ioc


    Сначала показался новой версией inversify, облегченной и удобной. Возможно это авторешение циклических зависимостей, но кажется неочевидным: чтобы прописать зависимости у компонента, уже на входе надо иметь ссылку на инстанс контейнера, определяющего эти зависимости. По моему мнению с таким подходом связность увеличивается, а не уменьшается.



    https://github.com/kraut-dps/di-box


    Мой велосипед, подробнее ниже.



    Я понимаю что мой пример получился замороченный, и есть много проектов без таких требований, но если DI реализация может больше, это большой плюс на будущее, ведь проекты развиваются.



    Свой велосипед


    Основан на прототипной “магии”, пример совсем без каких либо библиотек:



    class Service {
        work () {
            console.log('work');
        }
    }
    
    class App {
        oneService;
        main () {
            this.oneService().work();
        }
    }
    
    // специальный es6 класс, выполняющий функции DI
    class AppBox {
        Service;
        App;
    
        _oService;
    
        newApp () {
            const oApp = new this.App();
    
            // тут прототипная магия
            oApp.oneService = this.oneService.bind(this);
    
            return oApp;
        }
    
        oneService () {
            if (!this._oService) {
                this._oService = new this.Service();
            }
            return this._oService;
        }
    }
    
    const oBox = new AppBox();
    oBox.Service = Service;
    oBox.App = App;
    const oApp = oBox.newApp();
    oApp.main();
    

    Класс Box можно представить как набор декораторов конструкторов со своим состоянием, хранящим конструкторы и синглтоны, .

    Непосредственно в библиотеке несколько инструментов чтобы создавать синглтоны (.one()), не писать bind(this), контролировать заполненность обязательных свойств. С библиотекой этот же пример выглядит так:



    import {Box} from "di-box";
    
    class Service {
        work() {
            console.log( 'work' );
        }
    }
    
    class App {
        oneService;
        main() {
            this.oneService().work();
        }
    }
    
    class AppBox extends Box {
        App;
        Service;
    
        newService() {
            return new this.Service();
        }
    
        oneService() {
            return this.one( this.newService );
        }
    
        newApp() {
            const oApp = new this.App();
            oApp.oneService = this.oneService;
            return oApp;
        }
    }
    
    const oBox = new AppBox();
    oBox.Service = Service;
    oBox.App = App;
    const oApp = oBox.newApp();
    oApp.main();
    

    Пример в codesandbox



    Контроль обязательных свойств такой:


    const oBox = new AppBox();
    // пропущено oBox.Service = Service;
    oBox.App = App;
    const oApp = oBox.newApp(); // то будет ошибка: свойство Service is undefined
    oApp.main();
    

    Конструкторы...


    При написании компонентов для DI реализаций частенько приходится писать много аргументов в конструктор. И через какое то время, приходит мысль, что передача одного объекта со всеми зависимостями удобнее. Передача по ключу, удобнее чем по порядковому номеру.

    Сравните:



    constructor( arg1, arg2, arg3 ) {}
    
    // и
    
    constructor( { arg1key: arg1, arg2key: arg2, arg3key: arg3 } ) {}
    

    Но можно пойти еще дальше и попробовать отказаться от конструкторов, не во вред функциональности. Какие задачи у конструктора?


    1. Выполнить какие-то операции инициализации.
    2. Определить обязательные для работы компонента входные аргументы.

    Первый пункт в ES таки подразумевает создание отдельного метода инициализации. Если этого не сделать, то достаточно сложно переопределить конструктор в наследнике из-за этой особенности. А DI изначально задуман для того чтобы сделать компонент более гибким.


    Второй пункт можно решить организационным соглашениям. Например все публичные свойства не должны содержать undefined. Можно провести аналогию с абстрактными свойствами и методами из других языков. Как будто все публичные свойства абстрактны.


    Сравните:



    class A {
        _arg1;
        _arg2;
    
        constructor( arg1, arg2 = null ) {
            this._arg1 = arg1;
            this._arg2 = arg2;
        }
    }
    const instance = new A( 1, 2 );
    
    // и
    
    class A {
        arg1; // будет ошибка, если не установлено
        arg2 = null; // ошибки не будет null !== undefined
    }
    const instance = new A();
    instance.arg1 = 1;
    instance.arg2 = 2;
    

    Если компонент создается в dependency injection реализации, то можно дополнительной проверкой это реализовать. Это поведение по умолчанию библиотеки внедрения зависимостей di-box.

    Но для классического подхода или для typescript с удобным синтаксисом типа constructor( public arg1: type, public arg2: type ) это поведение можно убрать опциями при создании Box:


    new AppBox( { bNeedSelfCheck: false, sNeedCheckPrefix: null } );
    

    В примере на codesandbox.


    Итого с di-box получаем возможность писать в ООП стиле, с минимальным, но достаточным дополнительным кодом, реализующим DI. С одной стороны в реализации присутствует прототипная “магия”, но с другой она только на мета уровне, и сами компоненты могут быть чистыми, и ничего не знать об окружении.


    Буду рад обсуждению, возможно упустил из виду какие-то другие библиотеки. Или может вы знаете как лучше реализовать мой пример в какой-то из реализаций DI. Напишите в комментариях.

    Реклама

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

      +2
      Я бы посоветовал полноценно понять, что же такое IoC (Inversion of Control) и IoC контейнеры. И после этого пересмотреть свои взгляды. Да, в списке библиотек у вас смешались в кучу кони, люди. Там есть IoC контейнеры, есть просто свободное видение IoC. Ваше же решение, это просто обилие бойлерплейт кода, всё это можно автоматизировать, но у вас надо делать ручками. IoC контейнеры автоматизируют внедрение зависимостей.

      Ну и собственно в вашем велосипеде я не увидел самого главного — внедрения зависимостей. Чем ваше решение отличается от простой фабрики?

      IoC контейнеры решают множество проблем. Автоматическое внедрение. Во время разработки вы просто меняете сигнатуру конструктора или список публичных параметров (возможно помеченных), и вуаля, ни строчки лишнего кода и там у вас уже будут нужные объекты.

      Я конечно понимаю, вы не называли свою статью «IoC контейнеры для упрощения работы DI», но абсолютно не вижу смысла как-то частично автоматизировать внедрение зависимостей.

      В общем советую полноценно разобраться с IoC контейнерами и возможно более не будете изобретать подобные велосипеды. При этом на хабре уже есть тьма статей про это. Не смотрите в срезе JS, смотрите теорию, она применима куда угодно. Я реализовывал свои решения на C++ и JS и они в общем-то работают без проблем и выполняют свои задачи. Хотя вроде как языки и не очень хорошо подходят для этого.

      P.S. TS преимуществ тут никак не даст, ибо в рантайме есть только JS.
        0
        AxisPod, спасибо за ваш комментарий!

        Я бы посоветовал полноценно понять, что же такое IoC (Inversion of Control) и IoC контейнеры. И после этого пересмотреть свои взгляды.
        IoC нигде в статье не упоминал.

        Да, в списке библиотек у вас смешались в кучу кони, люди. Там есть IoC контейнеры, есть просто свободное видение IoC.
        Это действительно так, я в статье указал как я нашел эти библиотеки «Сделал небольшой парсер, разбирающий попадание сочетания “di” в npm. Пакетов по этой теме ~1400. Все рассмотреть невозможно. Рассмотрел в порядке уменьшения количества npm dependents.». Тут моя ошибка в подзаголовке «Популярные dependency injection вспомогательные библиотеки javascript/typescript», правильнее «Библиотеки npm, содержащие в keywords сочетание di»

        Ваше же решение, это просто обилие бойлерплейт кода, всё это можно автоматизировать, но у вас надо делать ручками. IoC контейнеры автоматизируют внедрение зависимостей.
        Действительно можно, но у меня было скорее желание донести мысль из темы: «Внедрение зависимостей (dependency injection) через свойства-функции в JavaScript», свое решение скорее как видение как эту тему можно красиво реализовать именно в прототипной модели. Но на самом деле есть и дополнительные преимущества, например если это скрипт для браузера, то размер с моим решением ~11КБ (для сравнения inversify 63КБ, колонка bundle size в таблице) будет аргументом в пользу использования, отказавшись от преиуществ IoC.

        Ну и собственно в вашем велосипеде я не увидел самого главного — внедрения зависимостей. Чем ваше решение отличается от простой фабрики?
        Да, по сути фабрика с контекстом, хранящим зависимости. Да, наверное оно выглядит слишком просто. Но я считаю это скорее преимуществом, чем недостатком.

        IoC контейнеры решают множество проблем. Автоматическое внедрение. Во время разработки вы просто меняете сигнатуру конструктора или список публичных параметров (возможно помеченных), и вуаля, ни строчки лишнего кода и там у вас уже будут нужные объекты.
        Да, согласен, у меня не IoC контейнер. И по сравнению с ним, в моем решении надо будет делать дополнительную работу. Я скорее пытался освятить другую проблему. Долгое время в JavaScript и общепризнанной реализации классов не было. И можно найти много готовых и очень популярных библиотек, которые очень сложно допилить под себя. Я предлагаю один из вариантов, с минимальным дополнительным кодом можно писать более гибкий ООП код.

        Я конечно понимаю, вы не называли свою статью «IoC контейнеры для упрощения работы DI», но абсолютно не вижу смысла как-то частично автоматизировать внедрение зависимостей.
        Думаю уже ответил выше.

        В общем советую полноценно разобраться с IoC контейнерами и возможно более не будете изобретать подобные велосипеды. При этом на хабре уже есть тьма статей про это. Не смотрите в срезе JS, смотрите теорию, она применима куда угодно. Я реализовывал свои решения на C++ и JS и они в общем-то работают без проблем и выполняют свои задачи. Хотя вроде как языки и не очень хорошо подходят для этого.
        Подскажите, в своем JS варианте вы использовали какую-то библиотеку? Если да то какую?
        +1
        Подскажите, в своем JS варианте вы использовали какую-то библиотеку? Если да то какую?

        Никаких не использовал, только ES6. Метаинформацию описывал используя декораторы. Внедрить можно было в любой класс, даже незарегистрированный, полям, куда внедрять надо указать внедряемый объект через декоратор inject, для конструкторов привязка шла декоратором на класс. Работает строго для классах.

        P.S. Ответил не туда.
          0
          Никаких не использовал, только ES6. Метаинформацию описывал используя декораторы. Внедрить можно было в любой класс, даже незарегистрированный, полям, куда внедрять надо указать внедряемый объект через декоратор inject, для конструкторов привязка шла декоратором на класс. Работает строго для классах.

          Правильно ли я понимаю, что inject декоратору аргументом нужно передать готовый внедряемый объект? Не токен, не ключ?
          Но тогда где вызывается new? Вне системы dependency injection? А что если этому внедряемому объекту потребуется своя зависимость?
            0
            Правильно ли я понимаю, что inject декоратору аргументом нужно передать готовый внедряемый объект?

            Нет конечно, я использовал аналоги интерфейсов на основе абстрактных классов, для этого был ещё доп. декоратор использован abstract, все методы помечались как абстрактные, правда кидали исключение только в рантайме. Совместить с compile-time позволит TS, если это необходимо. Моя реализация похожа на оные для строго типизированных языков, где сервис регистрируется в контейнере по интерфейсу.
              0
              Нет конечно, я использовал аналоги интерфейсов на основе абстрактных классов, для этого был ещё доп. декоратор использован abstract, все методы помечались как абстрактные, правда кидали исключение только в рантайме. Совместить с compile-time позволит TS, если это необходимо. Моя реализация похожа на оные для строго типизированных языков, где сервис регистрируется в контейнере по интерфейсу.

              Понятно, но если сложить саму реализацию Inject, декораторы abstract, сами абстрактные классы, то в конечном счете для IoC много дополнительного кода.
              Моя альтернатива в том, что мы заведомо принимаем решение, что можем прописать все зависимости вручную, без IoC преимуществ, но взамен имеем простой, расширяемый способ писать в ООП стиле. Аналогами интерфейсов для ES6 в моем случае являются типы () => IService, т.е. функции, которая возвращает определенный тип. Сами интерфейсы в ts стиле можно прописывать или нет, в любом случае они не отразятся на конечном коде. IDE будет помогать выявлять потенциальные ошибки:



              См. пример — codesandbox.io/s/github/kraut-dps/di-box/tree/0.2.2/examples/?file=/example-js.js

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

        Самое читаемое