Внедрение зависимостей (dependency injection) через свойства-функции в JavaScript
- пятница, 20 ноября 2020 г. в 00:40:34
Известный, но не очень популярный способ внедрения зависимостей. Попытка реализовать этот способ в популярных DI npm пакетах. Еще один свой 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. Я условно их разделил по своему видению:
Мало где уделяют внимание на независимость компонент от DI. Но на мой взгляд у любой библиотеки появляется дополнительное преимущество, если ее компоненты могут работать с разными DI реализациями, а не тянут конкретную вместе с собой.
Во многих DI реализациях используются декораторы. Можно в библиотеке код компоненты оставить чистым, а декорировать в отдельном файле. Для клиента появляется вариант, либо импортировать чистый компонент, либо вместе с конкретным DI декоратором.
В целом, чем выше цифра, тем больше абстракций, больше гибкость, больше времени на разработку, ниже скорость исполнения кода. Поэтому говорить что, что-то лучше, или что-то хуже, неправильно. Есть разные инструменты для разных нужд. Выбор инструмента соответствующего задаче — настоящее “кунг-фу” )
Сделал небольшой парсер, разбирающий попадание сочетания “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 |
Codesandbox код реализации моего примера
Наверное самый сложный, но и мощный пакет, возможно немного субъективно, потому что пример с ним делал самым первым. После него многие другие казались упрощенными версиями )).Наверное сложно придумать кейс, который бы не рассматривался авторами библиотеки. Монстр)
Чувствуется, что библиотека мощная, много разных возможностей. К сожалению, пока не смог разобраться, как я могу в App создать два разных экземпляра Date, с разными аргументами конструктора. Быть может здесь есть опытные его пользователи, которые подскажут?
Позволяет оставить код таким какой он есть, подменять содержимое файлов. В большей степени только для тестов. Сложно назвать DI, но для определенных задач может быть очень подходящим.
Не получилось реализовать, возникает ошибка “Symbol(Symbol.toPrimitive)”, как я понял, из-за того что в основе библиотеки Proxy, а у меня один из сервисов наследник от нативного Date класса. Не увидел в примерах использования интерфейсов.
Судя по документации и примерам создана именно с основной целью целью иметь возможность разбивать классы на более мелкие. Является частью фреймворка Aurelia.
Необычная ОО реализация. Множественное наследование. Не пытался что-то делать.
Я не фанат Microsoft, но объективно написать реализацию в их библиотеке у меня получилось быстрее всех остальных. Все умеет, специально выделили что инъекция свойства не реализована и никогда не будет реализована.
По задумке очень похожа на proxyquire.
Использовалась в Angular 4. Обширные возможности, конкретно мой пример реализовать не получилось, непонятно как в useFactory передать аргумент.
Мой пример сделать не получилось. Вроде подходит метод .instanceFactory, но как туда передать аргумент не понятно.
Не пытался реализовать. Варианты с ES6 классами пока не реализованы автором.
Много возможностей. Есть специальный код для использования вместе с React. Чрезвычайно маленькая документация. Чтобы разобраться как что-либо сделать приходится смотреть тесты пакета. Не без костылей, но реализовал свой пример.
Специфичный для vue без ES6 классов инструмент. Не рассматривал. В этом фреймворке есть и возможность ипспользовать ES6 classes, и есть функционал provide inject через который можно использовать DI. Библиотека кажется устаревшей.
Конфигурация зависимостей определяется отдельным YAML/JS/JSON файлом. Для сервера. Основана на концепции фреймворка на php symfony Мой пример сделать не получилось, думал через костыли и передачу класса в setParameter, но и там ограничение, невозможно использовать конструктор класса как параметр.
Реализовал, но костылями, которые аннулируют все DI преимущества.
Сначала показался новой версией inversify, облегченной и удобной. Возможно это авторешение циклических зависимостей, но кажется неочевидным: чтобы прописать зависимости у компонента, уже на входе надо иметь ссылку на инстанс контейнера, определяющего эти зависимости. По моему мнению с таким подходом связность увеличивается, а не уменьшается.
Мой велосипед, подробнее ниже.
Я понимаю что мой пример получился замороченный, и есть много проектов без таких требований, но если 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. Напишите в комментариях.