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

Внедрение зависимостей (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. Я условно их разделил по своему видению:

  • Дать возможность писать тесты, не изменяя исходный код. Тестирование.
  • Уменьшить связность кода, оставляя в реализации компонента ключи/токены для обращения к другим компонентам. Удобство поддержки, повторное использование, тестирование.
  • Уменьшить связность кода, добавляя интерфейсы доступа к другим компонентам. Удобство поддержки, повторное использование, тестирование, безопасность, автокомплит/навигация 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 } ) {}

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

  • Выполнить какие-то операции инициализации.
  • Определить обязательные для работы компонента входные аргументы.
  • Первый пункт в 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. Напишите в комментариях.

    Рубрики: Новости
    ×
    Обсудить проект
    Заполните форму и мы с Вами свяжемся
    ×