В процессе обсуждения статьи "Почему я «мучаюсь» с JS" у меня сложилось понимание, что связка export
/ import
в JS является базой для указания зависимостей между элементами кода (классами и функциями). А так как современные приложения вышли за рамки однофайловых и давно уже строятся из блоков, то выстраивание зависимостей между элементами кода имеет весомое значение. Настолько весомое, что в знаменитой аббревиатуре SOLID этому посвящена отдельная буква — D (Dependency inversion — инверсия зависимостей, не путать с Dependency injection — внедрение зависимостей).
Размышляя над тем, как связываются зависимые элементы кода в JS через export
/ import
, я пришёл к выводу, что не все зависимости в коде es6
-модулей SOLID'ных приложений можно описать инструкциями import
. Излагаю свои соображения, чтобы коллеги могли указать, где я ошибаюсь, или подтвердить мои выкладки.
Ограничение: все размышления относятся к nodejs
-приложениям и es6
-модулям.
export-import
В nodejs
-приложениях самым крупным блоком является npm
-пакет, а самым малым - отдельный экспорт es6
-модуля:
export { name1, name2, …, nameN };
В рамках одного npm
-пакета зависимости между es6
-модулями этого пакета указываются через импорты, на основании относительной адресации:
import ModuleLoader from './ModuleLoader.mjs';
Для использования в мультипакетном режиме npm
-пакеты экспортируют свой код через инструкцию main дескриптора пакета package.json
:
{
"main": "src/Shared/Container.mjs"
}
Указание зависимости из "соседнего" пакета, экспортируемой через main
:
import Container from '@teqfw/di';
Также возможно указание зависимости из "соседнего" пакета напрямую, без привязки к main
:
import Container from '@teqfw/di/src/Shared/Container.mjs';
Таким образом, "джентльменским соглашением" между разработчиками разных npm
-пакетов является использование экспорта "точки входа" в пакет, указанной в main
. Это даёт понимание пользователям пакета, что из содержимого пакета его разработчик счёл публичным интерфейсом (и будет стараться изменять по минимуму), а что — "внутренностями" пакета. Тем не менее, JS предоставляет возможность использовать экспорт из любого es6
-модуля любого npm
-пакета напрямую.
Механизм export
/ import
является базовым для указания зависимостей между элементами кода в nodejs
-приложениях и привязан к файловой системе, содержащей файлы es6
-модулей. Механизм конкретен и не допускает подмены одного файла другим ни при каких условиях.
Инверсия зависимостей
Принцип инверсии зависимостей предполагает использование абстракций вместо конкретики. В программировании принято абстракции описывать как интерфейсы. Например, в TS:
# file './src/Person.ts'
interface Person {
firstName: string;
lastName: string;
}
Использование абстракции (интерфейса Person
) в декларации функции greeter
как раз и демонстрирует принцип инверсии зависимости:
# file './src/Greeter.ts'
import {Person} from './Person.js';
export function greeter(person: Person) {
return "Hello, " + person.firstName + " " + person.lastName;
}
Но проблема в том, что интерфейсы не существуют даже в ES2021, не говоря о более ранних версиях. При компиляции TS-кода в 'ESNext' для интерфейса ./src/Person.ts
имеем практически пустой файл ./build/Person.js
:
export {};
А импорт из ./build/Greeter.js
пропадает:
export function greeter(person) {
return "Hello, " + person.firstName + " " + person.lastName;
}
Что вполне логично и соответствует "бритве Оккама" — не плоди сущностей сверх необходимого. И хотя в исходном TS-коде мы имели зависимость от абстракции и import
, указывающий на эту абстракцию, в результирующем JS-коде осталась только зависимость, без import
'а.
Внедрение зависимостей
Основная идея внедрения зависимостей - "объект пассивен и не предпринимает вообще никаких шагов для выяснения зависимостей, а предоставляет для этого сеттеры и/или принимает своим конструктором аргументы, посредством которых внедряются зависимости".
Т.е., чтобы JS-код соответствовал принципам внедрения зависимостей, создание зависимостей и их внедрение должно выполняться внешней по отношению к JS-коду сущностью — контейнером. JS-код должен предоставить механизм внедрения зависимостей (сеттеры, конструктор, аргументы функции), а контейнер каким-то образом сам должен сообразить (например, на основе конфигурации), какие зависимости нужны в каком случае, создать нужные объекты и внедрить их, используя сеттеры, конструктор или входные аргументы.
В сочетании с принципом инверсии зависимостей и текущими особенностями JavaScript (отсутствием интерфейсов) я прихожу к выводу, что при декларации классов/функций, зависящих от абстракций, в es6
-модулях не должны использоваться import
'ы, т.к. они привязаны к конкретным имплементациям (файлам — es6
-модулям). Поэтому в приложениях, в которых соблюдается принцип инверсии зависимостей, допустимы es6
-модули в которых полностью отсутствуют импорты, несмотря на то, что в них есть зависимости от других es6
-модулей. Что-то типа:
export class DiCompatible {
constructor(dep1, dep2, dep3) {...}
...
}
В таком случае внедрением зависимостей занимается DI-контейнер - именно он, в соответствии со своими настройками, должен разрешать (resolve'ить) запрошенные зависимости, загружать нужные модули и импортировать соответствующие объекты кода (классы/функции). А наличие в коде es6
-модулей импортов конкретных имплементаций "убивают" принцип инверсии зависимостей.
Комментарии (76)
dynamicult
06.08.2021 21:40+2Просто оставлю это здесь.
flancer Автор
06.08.2021 22:16Спасибо (y)
esm_loaders
очень напоминает spl_autoload_register из PHP. Пусть пока и экспериментальный функционал, но само направление движения мне нравится. С import-map, похоже, frontend-only функционал. На Хабре есть статья - "Контролируем JavaScript импорты с помощью Import maps"
Sabubu
07.08.2021 01:40+3А наличие в коде es6-модулей импортов конкретных имплементаций "убивают" принцип инверсии зависимостей.
Как мне кажется, DI не обязательно должен использовать интерфейсы и абстракции. Ключевой принцип — что зависимости передаются снаружи:
let a = new A(x, y, z); let b = new B(a);
В этом примере мы не используем интерфейсы (и не можем передать в
new B()
что-то, кромеA
), но DI все равно нужно, так как позволяет нам произвольно сконфигурировать объектa
перед передачей вnew B()
. Мы сами выбираем, что указать в x, y, z. Мы можем даже создать несколько объектов B с разной конфигурацией A.В случае же с импортом объект сам находит и создает свои зависимости. И повлиять на их конфигурацию мы не можем. Без DI пример выше выглядит как:
let b = new B(); // B сам создает A и непонятно откуда берет x, y, z
Я вообще не очень понимаю, как можно обойтись без DI и как тут помогут импорты. Вот у вас объект B импортирует и создает объект A. А откуда возьмутся настройки для объекта A (то есть x, y, z)?
Это не значит, что импорты вообще не нужны. Они могут пригодиться для импорта функций-утилит.
flancer Автор
07.08.2021 06:18+2Ключевой принцип — что зависимости передаются снаружи:
Совершенно верно.
Вот у вас объект B импортирует и создает объект A. А откуда возьмутся настройки для объекта A (то есть x, y, z)?
Если продолжить аналогию с B, который импортирует и создаёт A, то A также импортирует и создаёт x, y, z. Но в случае с импортами такая связь получается жёсткой - всегда импортируются одни и те же модули для A, x, y, z.
Если только не применяются механизмы esm_loader и import-maps, на которые дал ссылки коллега @dynamicult чуть выше. В этом случае сам JS-движок выступает своего рода DI-контейнером и способен подменять одни es6-модули другими на уровне импортов.
Но что-то мне кажется, что такой подход (с подменой на уровне импортов) нарушает "правило наименьшего удивления", потому что одного исходного кода es6-модуля с импортами становится недостаточно для понимания логики работы - нужна ещё карта импортов или стандартизация логики работы esm_loader'а (как это сделали в PHP). В общем, esm_loader и import-maps - это заявка на переход от физической адресации es6-модулей к логической (движение в сторону появления namespace'ов в JS).
Они могут пригодиться для импорта функций-утилит.
Совершенно верно, в PHP остались include и require и иногда их даже используют.
aamonster
07.08.2021 10:54+1Зависимости на уровне исходников (кто кого подключает) и зависимости на уровне компонентов (кто кого использует) – разное.
Первое решается import'ами, второе – конструкторами с параметрами, сеттерами, DI-контейнерами и так далее.
И вполне обычная практика для выбора зависимости компонента – подключить (в использующем компонент модуле) несколько модулей и выбрать по каким-то условиям.
flancer Автор
07.08.2021 11:23Правильно. И если на уровне используемого компонента решение о том, какие зависимости подключать, не принимается, то никаких импортов в нём и не требуется. Остаётся голый конструктор с возможностью запихнуть нужные зависимости:
// file './scr/DiCompatible.js export class DiCompatible { constructor(dep1, dep2, dep3) {...} ... }
Т.е., у нас есть es6-модуль с зависимостями и без импортов. Потому что это зависимости уровня выполнения (компонентов), а не зависимости уровня сборки (исходников). Каким образом в него внедряются зависимости (через DI-контейнер или вручную) - за то разработчик
./DiCompatible.js
волноваться не должен.aamonster
07.08.2021 15:43+1И если на уровне используемого компонента решение о том, какие зависимости подключать, не принимается, то никаких импортов в нём и не требуется.
Угу. За возможным исключением типов данных для зависимостей (актуально для ts, но в js-то нет статической типизации). Например, интерфейс IFooer описан в модуле A, несколько его реализаций в модулях C и D, а используется он в E и F – так подключить его придётся во все четыре. А в js модуля A может вообще не быть.
ЗЫ: лично я, конечно, предпочту иметь статическую типизацию, я в своей жизни наелся такого, что кто-то из коллег что-то чуть-чуть поменял, и сломалось в неочевидном месте.
flancer Автор
08.08.2021 02:33В JS есть возможность использовать JSDoc annotations для описания типов ожидаемых параметров. Не совсем то же самое, что стат. типизация, но кое-что похожее. Мне хватает, у меня IDEA.
export class DiCompatible { /** * @param {SimpleClass} dep1 * @param {Namespace.NestedClass} dep2 * @param {InterfaceFromJSDoc} dep3 */ constructor(dep1, dep2, dep3) {...} }
актуально для ts ... так подключить его придётся во все четыре. А в js модуля A может вообще не быть.
Да, но я-то на JS пишу, у меня такой проблемы нет. Нет модуля А - нет проблемы.
rvazh
07.08.2021 05:51+1Всё-таки абстракции - это не только интерфейс ы. Почему класс не может быть абстракцией?
flancer Автор
07.08.2021 06:31Может. Это лишь вопрос восприятия и терминологии. "Интерфейс" - абстрактен по своей сути. Встречая в разговоре слово "интерфейс" мы сразу же понимаем, что речь идёт о некотором уровне абстракции. В случае с классом нам нужно добавлять "абстрактный", чтобы понять то же самое. В Java есть abstract class и interface, там терминология закреплена на уровне языка. В JS такого нет, приходится вводить собственные, внутрипроектные договорённости. Например, класс
IPerson
- полностью абстрактный класс (интерфейс), аAPerson
- класс с частичной абстракцией на уровне методов. Но лучше использовать JSDoc-аннотации для этого или TypeScript (там интерфейсы и абстрактные классы тоже есть на уровне языка).rvazh
07.08.2021 17:31+1Согласен. Однако, я имел ввиду именно обычный класс, не абстрактный. Как по мне, абстракция - это все-таки о дизайне класса/модуля. Обычный класс вполне может быть замечательной абстракцией. В пример могу привести репозиторий, внутри методов которого реализуется кэширование. Когда мы вызываем какой-нибудь метод, нам главное то, что он сохраняет/возвращает сущность. Мы не должны знать о том, что помимо БД, он обращается и в кэш. И совсем не обязательно, чтобы этот репозиторий имплементил интерфейс или абстрактный класс.
PavelZubkov
07.08.2021 05:52+2Возможно вас заинтересует эта статья: https://habr.com/ru/post/541800/. Кратко суть:
- вместо импортов/экспортов используются неймспейсы
- по сути в данном случае, неймспейс - это способ авторегистрации объявляемой сущности в глобальном объекте - контейнере
- с помощью небольшого хелпера, мы можем переопределять сущности в этом контейнере, т.е.
- функции вызываются в контексте этого глобального_объекта-контейнера (если нужно с переопределенными сущностями), т.е. черезthis
.
- для классов создается базовый класс, от которого надо наследоваться. В методах класса обращаемся к сущностям черезthis.$.*
-this.$.log()
- там еще используется именование через подчеркивания, это отчасти другая история не связанная с темой (суть: что бы в html/css/js сущности имели одинаковое имя, для поиска / реплейса и т.д.)
- второй момент с подчеркиванием, там юзается Fully Qualified Names . Опять же кратко: в названиее сущности отражается ее местоположение в файловой системе проекта. FQN имя начинается с доллараflancer Автор
07.08.2021 06:41Мне очень понравилась ваша "краткая суть" (namespaces, FQN, авторегистрация, местоположение в файловой системе) - это как раз то, чего мне не хватало в JS. Но, оказывается, я уже читал эту статью. Я просмотрел её ещё раз, но так и не понял, в какой момент происходит загрузка es6-модулей (преобразование FQN объекта в путь к файлу с исходником для него). Возможно, это из-за того, что там TS, а я его не понимаю на достаточном уровне, а возможно, она просто несколько не о том.
PavelZubkov
07.08.2021 22:46+2Неймспейсы:
В коде, мы объявляем неймспейс, для краткости используем в качестве имени `$`.
Внутри него экспортируем функцию, класс, переменную и т.п.namespace $ { export function $log( this: $, ... params: unknown[] ) { console.log( ... params ) } }
В рантайме, у будет объект
$
, а в нем функция$.$log
То есть тайпскирпт скомпилирует код выше, в такой JS:
var $; (function ($) { function $log(...params) { console.log(...params); } $.$log = $log; })($ || ($ = {}));
Получается, что$
это контейнер(или контекст), хранящий все наши сущности. Для того что бы в него что-то положить, это что-то надо экспортировать из неймспейса.
Заметьте, у функции$log
типизированthis
это значит, что тайпскирт не даст нам вызвать эту функцию так:$log()
, а необходимо вызывать ее в контексте контейнера$.$log()
Также нам нужен механизм для переопределния сущностей. Вот один из варинтов:
namespace $ { export function $ambient(this: $ , overrides: Partial<$>): $ { return Object.setPrototypeOf(overrides, this) } }
В статье по ссылке, более сложное переопределение, покрывающее большее количество кейсов
Как с этим работать: Допустим у нас есть функция что-то выводящая в консоль, использующая внутри функцию лог, которая описана выше.namespace $ { export function $hello(this: $) { this.$log('Hello user!') } }
Мы пишем тест, проверяющий что эта функция выводит именно строку "Hello user!"
namespace $ { export function $test() { const logs:string[] = [] // функция $ambiet получает текущий объект-контейнер в this // мержит в него переданный нами объект // в котором подменена функция $log const ctx = $.$ambient({ $log: (str) => { logs.push(str) }, }) ctx.$hello() // Вызывает $hello в измененом контексте if (logs[0] !== 'Hello user!') throw new Error('Wrong message') } }
Для классов история примерно такая же. Только дополнительно нужен базовый класс реализующий работу с контекстом(контейнером) и от него необходимо наследовать классы.
Авторегистрация:
1) мы просто экспортируем сущность из неймспейса, тайпскрипт положит ее в глобальный объект. В функциях обращаемся к другим сущностям черезthis.$имя
или$.$имя
. В классах обращаемся черезthis.$.$имя
.
2) Нам не нужно знать про "property based injection", "constructor based injection" и т.п. Просто обращаемся к сущностям как описано в пункте 1, у всех зависимостей, автоматически, всегда, будет значение по умолчанию. А где нужно, переопределяем нужные сущности в объекте-контейнере перед вызовом функции или при инстанцировании класса. И конечно, нет необходимости везде безусловно использовать обращения к сущностям через контекст, обычно только там где код образается к базе данных, к local storage, или что-то пишет в консоль / файл и т.п.
FQN:
1) FQN стоит рассматривать как альтернативу импортам/экспортам. Вместо того, что бы импортировать какой-то модуль/функцию/класс, мы просто обращаемся к нему по имени, а имя его состоит из пути до него в файловой системе. Например, у нас по путиapp/user/model
лежит модель юзера. В коде, который ее использует, мы обращаемся к ней "напрямую"$app_user_model
без предварительных импортов. Т.е. когда мы что-то объявляем, в имени этой сущности указывается путь до нее. Директории разделяются знаком подчеркивания, а знак доллара - это маркер, говорящий о том, что это FQN имя.
2) Как можно заметить в этом "di на неймспейсах", у нас все сущности свалены в один объект, нужны какие-то ограничения на имена, что бы небыло случайных переопределий, конфликтов, или чего-то еще, что там может случится. FQN с этой задачей полностью справляется.
3) Хоть это и альтернатива импортам/экспортам, но используется слово "export", т.к. мы ограничены реалиями, в данном случае тайпскриптом. Кажется в PHP есть похожая концепция.
4) Можно сказать еще про личный опыт, в виде погружения в достаточно большую кодовую базу. При использовании FQN это легче, потому что с одного взгляда понятно откуда какая зависимость и где ее искать.
5) Еще чуть-чуть: если имя слишком длинное, то можно использовать локальный алиас, где-нибудь вверху, так же как импорт `const model = $app_user_model`; длинные имена заставляют больше задумывать над именованием и расположением файлов в фс (привет DDD). Есть обратная сторона: для сокращения имени, могут использовать не общепринятые слова, а их более короткие синонимы, не всегда понятно что означает слово.Минусы:
1) При использовании такого подхода на фронте(если мы заботимся о размере бандла) - в каждом файле с неймспейсом нужно писать специальную директиву для тайпскрипта, что бы он знал, что этот файл тоже надо включать в бандл. Иначе можо сбилдить только весь код целиком, что не всегда нужно. Это может стать проблемой например, если используется модульная система типа MAM, где рядом может лежать много проектов использующих общие модули или друг друга в качестве зависимостей, и мы не хотим класть в бандл вообще все. Или когда бандлы собираются постранично. Придется мириться с директивами, либо настраивать вебпак / свой сборщик / тришейкер. В MAM свой сборщик.
2)Еще, что бы перевести какой-то существующий проект на такой подход, нужно досаточно много усилий, с точки зрения переписывания кода. Хотя в теории и перевод проекта на другие подходы, тоже может быть затратен.
3) А также не просто ощутить / понять / объяснить преимущества этого на другими подходами. Т.к. все привыкли к чему-то "Inversify"подобному подходу, который скопирован с подхода в java, где других вариантов не было.(тут могу ошибаться, мб поправит кто-нибудь)
4) NPM-пакеты подключаются сложнее, т.к. они заточены на импорты/экспорты.Итог:
Этот подход в описаном выше виде используется в $mol , думаю его можно реализовать и как-то по другому. Если интересно подробнее разобраться, то наверно лучше в телеге , там больше людей и лучше, чем я смогут помочь, т.к. я сам во всем этом еще разбираюсь.
Еще есть краткое сравнение разных подходовИ вообще, замечаю проблему с объяснением каких то идей из этого фреймворка, который нельзя называть, с тем что практически любая из-них связана с еще несколькими, и что бы объяснить одну, нужно какое-то понимание других и так по кругу. В довесок к этому, сами идеи / концепции мало где используются и не привычные, и первое что хочется - это просто отвергнуть и не тратить время, на какие странные вещи.
flancer Автор
08.08.2021 02:21мы просто экспортируем сущность из неймспейса, тайпскрипт положит ее в глобальный объект.
Да, теперь понятно, как устанавливаются связи.
Иначе можно сбилдить только весь код целиком, что не всегда нужно
Вот у меня как раз другой подход - разбивать код на отдельные es6-модули и затягивать на фронт только те, что используются по факту (на бэке это и так есть). Тут другой минус: много мелких модулей - много обращений к серверу. Можно кэшировать, это смягчает проблему. Или "инсталлировать" - при старте приложения закачивать все модули в кэш браузера (аналог "сбилдить весь код целиком").
Спасибо за подробное объяснение, но для меня у $mol есть
фатальныйзначимый недостаток - он на TS. Я очень плохо перевариваю транспиляторы (включая бандлеры), даже аппетит пропадает. К тому же у вас $mol заточен, насколько я понял, под фронт (на бандлы), а мне же больше видится единое "фронт-бэк" решение, с кодом, разделяемым между сервером и браузером (те же DTO).Я не стремлюсь сделать популярное решение, я стремлюсь сделать решение, удобное прежде всего для меня. А мне удобно иметь код, который я могу использовать без изменений как в браузере, так и на сервере.
Ещё раз спасибо за подробный коммент и ваше время.
PavelZubkov
08.08.2021 09:10а мне же больше видится единое "фронт-бэк" решение, с кодом, разделяемым между сервером и браузером
Полностью разделяю такой подход
К тому же у вас $mol заточен, насколько я понял, под фронт (на бандлы)
Да бандлы есть, но сам он - это просто набор маленьких изоморфных модулей, хорошо состыкованых друг с другом, которые можно использовать и на сервере, и на фронте. Выделеное ядро отсутствует.
Спасибо за ответы.
debagger
07.08.2021 06:56+3Я несколько раз перечитал вашу статью, но так и не смог понять, в чем ваша боль. Могут ли быть модули без импортов? Да, конечно.
В браузере возможно обойтись без импортов вообще, если зависимости берутся из глобальных объектов (если используются только методы из стандартной библиотеки). Но если используются библиотеки/фреймворки, нужен бандлер, а он будет собирать приложение, ориентируясь как раз при помощи импортов.
На Node.js вообще без импортов (или require) вряд ли получится, потому что здесь стандартная библиотека на модулях (в отличии от браузеров).
Если используется TS, то импорты нужны еще и для передачи типов. Так что если даже сама зависимость инжектируется, все еще нужно в модуль пробросить ее тип (иначе зачем TS?), для этого тоже нужен import.
Но, в итоге, система модулей (и import/export) это удобный способ структурирования кода, и зачем с этим бороться я не очень понимаю.
flancer Автор
07.08.2021 07:27Моя "боль" - вот:
А наличие в коде es6-модулей импортов конкретных имплементаций "убивают" принцип инверсии зависимостей.
Но мне её "полечил" коллега @dynamicult своим esm_loader.
Я не борюсь с import'ом, в конце-концов, в JS нет другого механизма для загрузки модулей. Я просто проверяю свои наработки. В моих es6-модулях, не завязанных на nodejs, нет импортов и тем не менее, они подгружаются и связываются друг с другом как на фронте, так и на бэке. Мне комфортно программировать на JS (я дал в начале публикации ссылку на объяснение, почему так) и без import'ов. Вам - на TS и с import'ами.
Ну и опрос, опять же :)
YSpektor
07.08.2021 09:04Трудно понять эту боль, когда давно используешь TypeScript + TypeDI
flancer Автор
07.08.2021 11:41Попробую объяснить. У вас есть интерфейс IPerson:
export default interface IPerson { getFullName(): string; }
и две имплементации:
export default class Fbi implements IPerson { firstName: string; lastName: string; constructor(first: string, last: string) { this.firstName = first; this.lastName = last; } getFullName(): string { return `${this.firstName} ${this.lastName}`; } }
export default class Kgb implements IPerson { firstName: string; lastName: string; constructor(first: string, last: string) { this.firstName = first; this.lastName = last; } getFullName(): string { return `${this.lastName} ${this.firstName}`; } }
Вся разница у них, что первая для
getFullName()
выводит имя как "Alex Gusev", а вторая - как "Gusev Alex".Есть учётная карточка персонала с именем и званием:
export default class Card { person: IPerson; rank: string; constructor(person: IPerson, rank: string) { this.person = person; this.rank = rank; } getName(): string { return `${this.person.getFullName()}: ${this.rank}`; } }
В соответствии с принципом инверсии зависимостей
Card
зависит от абстракции (интерфейсIPerson
), а не от деталей (Fbi
илиKgb
). И решение о том, какую имплементацию подключать в каком случае, принимается не на уровнеCard
.На уровне
Card
нет импорта ниFbi
, ниKgb
.borshak
07.08.2021 13:44+3Окей, но в точке (т.е. в модуле) где создается
Card
и принимается решение о том, какую имплементацию использовать -Fbi
илиKgb
- уже будет импорт трёх данных сущностей. Или мы должны пойти дальше, и импотировать все классы (а также функции, константы, внешние зависимости из npm-пакетов, svg на фронтенде и пр.) в некий клобальный entity-локатор, и оттуда раздавать по требованию?А наличие в коде es6-модулей импортов конкретных имплементаций "убивают" принцип инверсии зависимостей.
Если импорт
Fbi
иKgb
будет внутриCard
, то да. Но если в точке, где создаетсяCard
, то нет. Или вы предлагаете возвести инверсию в абсолют, и принимать решение обо всех деталях реализации на самом верхнет уровне - то есть, в конфигурации вне приложения, которая поключается к нему на лету во время запуска?В таком случае будет нарушен основополагающий принцип инженерной деятельности - инкапсуляция. Не говоря уже о том, что понять как работает приложение, будет невозможно из-за динамичности абсолютно всех деталей.
flancer Автор
08.08.2021 01:57уже будет импорт трёх данных сущностей.
Или двух, если решение "захардкожено". Например, на уровне
KgbCardsFactory
.Или мы должны пойти дальше, и импотировать все классы (а также функции, константы, внешние зависимости из npm-пакетов, svg на фронтенде и пр.) в некий клобальный entity-локатор, и оттуда раздавать по требованию?
Примерно так и работают Dependency Injection контейнеры. Тот же TypeDI. Вы в своей реплике слегка драматизировали, загнав в "некий глобальный entity-локатор" совсем всё, но "кое-что нужное" DI-контейнер импортирует и раздаёт по требованию. Всё верно.
Если импорт Fbi и Kgb будет внутри Card , то да.
Вот за этот случай я и говорю.
Или вы предлагаете возвести инверсию в абсолют
Нет.
Не говоря уже о том, что понять как работает приложение, будет невозможно из-за динамичности абсолютно всех деталей.
В других языках как-то справляются.
borshak
08.08.2021 10:39В других языках как-то справляются.
Вряд ли стоит апеллировать к другим языкам, поскольку в разных экосистемах принято по-разному решать разные проблемы. К примеру, в некоторых языках есть опционалы, а в некоторых - перегружаемые операторы. Но вы почему-то не топите за их включение в JS, вам не нравится именно модульная система в JS.
Но ваш подход - вне сомнения - имеет полное право на жизнь (как и любой другой подход). Единственное, использовать его в проде - вряд ли обдуманное решение, поскольку такой код будет трудно ревьювить; плюс, если с проекта уйдут ключевые разработчики (т.е. вы и те кто знаком с подходом), то новоприбывшим будет очень трудно разобраться как все устроено.
Ну и простите моё любопытство, но вот, если мы к примеру пишем приложение в чисто функциональном стиле (функции высшего порядка, каррирование и декорирование функций, никаких классов) - то как в данном случае распространять зависимости (импортированные в DI контейнер) по приложению? Можно небольшой пример, или хотя бы концептуально на словах? Спасибо.
flancer Автор
08.08.2021 18:53Я не силён в функциональном программировании, но попробую.
DI-контейнер оперирует es6-модулями и
export
'ами внутри этих модулей. Я сделал ветку для демо, каким образом инжектить функции внутрь других функций. Все es6-модули внутри этой демки находятся в пространстве имёнDemo
. Основной файл (./src/main.mjs
) импортирует контейнер и настраивает маппинг имён на файлы. Это единственный файл проекта, в котором используетсяimport
, все остальные es6-модули загружаются через DI.default export
модуля имеет идентификаторVnd_Mod#
(#
в конце). Для такого идентификатора контейнер вернёт саму функцию, которая соответствуетdefault export
'у. Если указать идентификаторVnd_Mod$$
($$
в конце), то контейнер вернёт результат выполненияdefault export
'а, предоставив функции соответствующие зависимости, запрошенные через входной аргументspec
. Так получаются фабричные функции, которые создают другие функции, которые сами могут быть зависимостями:export default function Factory(spec) { /** @type {Function|Demo_Other_Card.fnCard} */ const card = spec['Demo_Other_Card#Builder$']; // ... return function() { /* use 'card()' inside */ }; }
Если в конце стоит один
$
, то контейнер при помощи фабрики создаст функцию при первом запросе соответствующего depId, а затем будет всегда возвращать этот же экземпляр (singleton).Если у нас в es6-модуле экспортируется просто функция, а не фабричная функция, то её depId будет примерно такой '
Demo_Stuff_Lib#getRank
' . Контейнер просто вернёт соответствующий экспорт, как будто был применён статический импорт:// внутрипакетный import {getRank} from './Stuff/Lib.mjs' // из других пакетов import {getRank} from '@flancer64/habr_di_demo/src/Stuff/Lib.mjs'
Ну а дальше всё просто - у нас есть es6-модули с простыми функциями, эти функции мы используем в качестве зависимостей в фабриках для получения составных функций (использующих простые). И так по цепочке, пока не дойдём до начала.
Я ничего не понимаю в функциональном программировании (каррирование монады и всё такое), но это пример того, как в JS (ES6+) можно связывать простые функции в составные при помощи DI-контейнера.
Вполне возможно, что DI в функциональном программировании - это нонсенс. Но вы спросили - я ответил :)
YSpektor
07.08.2021 15:50Да нет, я понимаю что такое DI в обеих расшифровках, и для чего оно нужно. Боль в том, что в JavaScript нет интерфейсов?
flancer Автор
08.08.2021 01:44Скорее, нет устоявшейся системы адресации кода без привязки к файловой системе (package в Java и namespace в PHP).
Но и это не боль. Задача данной статьи - получить отклик от коллег, насколько для них допустима мысль, что зависимости между различными es6-модулями не связаны напрямую с используемыми в них импортами. На данный момент 45% допускают подобный вариант, а 55% считают, что "если в es6-модуле есть зависимости, то должны быть и import'ы".
Просто в моём коде мало импортов, но много зависимостей и это может показаться кому-то странным. Но на самом деле это совершенно нормально. Как тут чуть ниже отметил коллега @funca это код "позднего связывания" - динамического, а не статического. Так что эта статья для них - тех, кому не хватает импортов в моём коде.
funca
07.08.2021 09:13+8В начале статьи вы упомянули, что не стоит путать архитектурный принцип Dependency Inversion из SOLID с техникой программирования Dependency Injection (которая суть частный случай техники Inversion of Control). Но вся статья как будто бы про второе.
Здравый смысл говорит нам о том, что слои более низкого уровня не должны ни чего знать о слоях более высокого. База данных ни чего не знает про бизнес логику, бизнес логика не имеет понятия о UI и т.п. Когда вы заказываете пиццу, то повар который её готовит, не имеет ни малейшего представления ни о вашем существовании, ни о ваших вкусовых пристрастиях (равно как и тысяч других каких же как вы клиентов). Следование этому принципу позволяет повторно использовать низкоуровневые компоненты в абсолютно разных задачах. Но с другой стороны, в таком случае, высокоуровневые компоненты вынуждены подстраиваться под все условия низкоуровневых, что делает их практически незаменимыми. А ведь часто хочется пиццу, приготовленную на свой вкус. Чтобы этого избежать, можно сделать все наоборот: высокоуровневая система абстрактно описывает свои требования (в виде договора на своих условиях, регламента, контракта, критериев, интерфейса и т.п) и предлагает низкоуровневой системе его реализовать. В таком случае заменить низкоуровневую систему не составит проблем, ведь контракт был на стороне высокоуровневой и он остался прежним. Так появляется та самая инверсия в направлении зависимостей (Dependency Inversion). На практике используются в основном прямые зависимости внутри, а инвертированные лишь на границах систем, иначе дополнительные издержки, связанные с разработкой и поддержкой такой бюрократии себя не окупают.
Внедрение зависимостей (Dependency Injection) решает другую проблему, а именно разделение ответственности за жизненный цикл объекта (создание, хранение, удаление) и его использование. Обычно клиент хочет только использовать, а остальные такие заморочки ему ни к чему. Все техники сводится к разным вариантам позднего связывания, когда клиент получает свои зависимости только в рантайме, а управляет жизненным циклом кто-то другой.
Основная идея, во круг которой строилась спецификация для ESM модулей, была напротив возможность выражать связи в коде статически - чтобы все зависимости можно было проследить чисто по тексту, без запуска программы. Благодаря этому, например, довольно правдоподобно работает автокомплит в ide, линтеры могут сообщать об ошибках непосредственно при редактировании, а сборщики - исключать из бандлов неиспользуемый код. Позднее связывание тоже поддерживается (функция import), но в целом ее использование идёт в разрез основной идее.
Думаю отказываться от импорта, означает лишать себя многих полезных вещей, связанных со статическим анализом кода. Однако пренебрегать поздним связыванием тоже не стоит. Хотя, управление жизненным циклом объектов это вряд-ли та задача, которую стоит решать на уровне ESM. Решение инженерных задач всегда содержит в себе компромиссы. PHP и JS идут немного разными путями потому, что их создатели по-разному ставят приоритеты. Но в целом, пробовать таскать разные идеи туда-сюда, может быть даже если и не полезно, то крайне увлекательно
flancer Автор
07.08.2021 09:59+1Но вся статья как будто бы про второе.
Да, потому что, второе очень часто работает в контексте первого. Высокоуровневые компоненты декларируют контракты (это инверсия), а что-то должно этим контрактам сопоставлять реальные объекты (это внедрение). Второе базируется на первом (но может и не базироваться, если в приложении внедрение зависимостей есть, а инверсии нет).
Основная идея, вокруг которой строилась спецификация для ESM модулей, была напротив возможность выражать связи в коде статически
Вот! Поэтому и возникает конфликт статических import'ов с динамическим "поздним связыванием". А у динамического import'а с динамическим "поздним связыванием" такого конфликта нет. И esm_loader & import maps как раз и пытаются добавить динамики (а заодно и порушить автокомплит в IDE и стат. анализаторы кода). В общем, тут пришили, там отвалилось.
PHP и JS идут немного разными путями
Но к очень похожим целям. PHP всегда был "серверным" языком, а на JS очень сильно влияет "браузерность". composer появился позже npm (2012 vs 2010), но "пакеты" в PHP были с 1999-го года (PEAR), так что у PHP community более богатый опыт в "групповой" разработке. В той же Magento 2, написанной на PHP, порядка 3.4 млн. строк кода (32% - комменты) и 590 тыс. классов. И это только сама платформа, без зависимостей и расширений (я в курсе размеров node_modules, но в нём разные проекты, а не один). В общем, сопоставляя опыт одних и других, действительно приходишь к выводу, что:
пробовать таскать разные идеи туда-сюда, может быть даже если и не полезно, то крайне увлекательно
funca
09.08.2021 19:42Да, потому что, второе очень часто работает в контексте первого. Высокоуровневые компоненты декларируют контракты (это инверсия), а что-то должно этим контрактам сопоставлять реальные объекты (это внедрение). Второе базируется на первом (но может и не базироваться, если в приложении внедрение зависимостей есть, а инверсии нет).
Инверсия зависимостей стала популярной благодаря SOLID. Но частота ее упоминаний довольно слабо кореллирует с частотой использования конкретно в программировании. Это точно не самый распространенный программистский прием на практике (впрочем, как и весь солид).
Пример зависимости без инверсии из реальной жизни это, например, прокат автомобилей (ну или любой другой сервис из сферы массового обслуживания). Тысячи клиентов, скрипя зубами, подписывают типовые контракты в том виде, в котором их предлагает провайдер услуги, покорно соглашаясь на все их драконовские условия.
Инверсия выглядит как решение, но если вдуматься, то она требует индивидуального подхода, тщательной проработки юридических, экономических, технических и т.п. вопросов недешевыми специалистами. В общем будет крайне затратна для обоих сторон, что может свести всю экономику такой затеи глубоко в минуса.
Если же вы относитесь к тем немногим счастливчикам, для кого деньги не главное, то инверсия нередко приводит к появлению на стыке каких-то адаптеров - фирм-прокладок, берущих на себя ответственность за <strike>распил бабла</strike> поддержание должного уровня абстракции, согласованность контрактов, обеспечение специфических индивидуальных гарантий и нивелирование рисков.
Druu
13.08.2021 09:53Пример зависимости без инверсии из реальной жизни это, например, прокат автомобилей (ну или любой другой сервис из сферы массового обслуживания).
Вообще-то как раз наоборот - это хороший пример инверсии зависимости)
Т.к. требование к конфигурациии сервиса предъявляет его потребитель, а не: "ну дайте мне что-нибудь, что получилось".
Finesse
07.08.2021 16:55А как же KISS? Если есть необходимость подменять зависимости (в зависимости от настроек или для тестирования), то делаем DI, иначе не оверинженирим.
flancer Автор
08.08.2021 01:58Я очень уважаю KISS. Эта статья как раз про первый случай.
Finesse
08.08.2021 03:43Я скорее про опрос, потому что он звучит ультимативно
flancer Автор
08.08.2021 08:19Вы меня озадачили :) Что ультимативного в вопросе "Допустимы ли es6-модули с зависимостями, но без импортов?" Даже если предположить варианты ответов "да" и "нет"?
Да, допустимы
Нет, не допустимы
Какой третий вариант ответа здесь мог бы быть? "Иногда допустимы" или "иногда не допустимы"? Мне кажется, они все покрываются первым вариантом - "да, допустимы". Поясните, пожалуйста, в чём вы видите ультимативность.
Finesse
08.08.2021 08:22В том, что вопрос безусловный. То есть либо можно использовать импорты, либо нельзя совсем ни при каких условиях. Из вопроса не понятно, что речь идёт только про использование импортов в контексте DI.
flancer Автор
08.08.2021 08:38Я не вижу этой ультимативности в вопросе: "Допустимы ли es6-модули с зависимостями, но без импортов?"
"Допустимый" как раз и означает "разрешённый, позволительный, приемлемый, терпимый". Тут прямо веет компромиссом.
IMHO, ультимативный вопрос звучал бы так: "Обязаны ли es6-модули с зависимостями иметь импорты?"
Полагаю, что ответ с отрицанием допустимости звучит несколько категорично "если в es6-модуле есть зависимости, то должны быть и import'ы". Но отрицание компромисса не может само быть компромиссом.
Полагаю, что KISS поддерживает как раз первый ответ - "es6-модуль без import'ов вполне себе рабочее решение"
borshak
08.08.2021 09:49Но ваш опрос и правда звучит ультимативно. Возможный третий вариант — «Импорты допустимы при оправданной необходимости.» Что такое оправданная необходимость? Как видно уже из нескольких ваших статей и комментариев к ним, сообщество тяготеет к одному трактованию, вы — к кардинально отличному. Но при [оправданной] необходимости импорты все же используете.
flancer Автор
08.08.2021 13:53Я не топлю за отказ от импортов. В JS это невозможно в принципе (если у нас больше одного файла в приложении). Я показываю вариант, когда можно (и нужно!) обходиться без статических импортов. Просто некоторые в комментах к статье, ссылку на которую я дал в начале, назвали подобный подход глупостью. Нет, это не глупость.
Что такое оправданная необходимость?
Тут просто - когда в JS-приложении связываются более одного es6-модуля или используются библиотеки nodejs, то без import'а не обойтись. А использовать статический или динамический import - то на выбор разработчиков. Я стараюсь использовать динамический, где это возможно.
Как видно уже из нескольких ваших статей и комментариев к ним, сообщество тяготеет к одному трактованию, вы — к кардинально отличному.
Возможно, я просто криво выражаю свои мысли.
flancer Автор
08.08.2021 08:57Я недавно встретил такую расшифровку правила KISS - "Keep It Stupidly Simple". Она слегка меняет акценты в правиле, не меняя его сути. Не stupid делай simple, а делай stupidly simple. Я считаю, что убрать import'ы для es6-модулей, связываемых через DI-контейнер, как раз и есть случай "keep it stupidly simple". По крайней мере, некоторые коллеги глупостью это уже называли ;)
Zoolander
08.08.2021 09:09+1может быть, вы и правы, и ваш подход жизнеспособен.
Но отказываясь от индустриальных стандартов, вы добровольно отключаете себе ряд возможностей. Включая то, как люди, привыкшие к индустриальным стандартам, будут относиться к вашему стилю.
Devoter
08.08.2021 12:23+2Мне кажется - проблема в том, что понятие модуля в разных языках разное. В es модулем является файл, но один файл далеко не всегда (скорее, почти никогда) представляется реализацией целого слоя приложения. Если, скажем, разделить приложение на несколько базовых уровней вроде транспортного, бизнес-логики и хранилища, то связи между ними как раз удобно и правильно реализовывать в виде абстракций и инверсии зависимостей. Но в рамках одного слоя, если требуется работа с конкретной базой данных, скажем, то инверсия уже не требуется - используем прямые импорты. При этом бизнес-логика будет работать с любыми видами хранилища через обобщенный интерфейс.
Это не какое-то строгое правило, и подход применим и к более мелким частям приложения, можно ограничить их пространствами имен или макро-модулями (называйте как хотите), вопрос лишь в потребности. Если есть необходимость использовать различные реализации - используем инверсию, и, как правило, инъекцию. Если же такое потребности нет, то зачем усложнять жизнь себе и тем, кто будет читать код? Собственно, мой вопрос состоит в том - почему вы хотите отделить один прием от другого, если они не противоречат друг-другу? Или я неверно вас понял?
flancer Автор
08.08.2021 13:41Я не противопоставляю. Я показываю вариант, когда es6-модуль без импортов, но с зависимостями не является такой уж глупостью. Вы правы, что оба варианта имеют право на существование в рамках одного приложения.
funca
09.08.2021 20:02Разница в трактовке модулей хорошо видна на примере первого Angular.js (где, если вы помните, этот термин был использован в реализации собственного DI контейнера). Несколько лет тому назад на stackoverflow был популярен вопрос в чем разница между модулями Angular.js и ESM модулями и как их скрестить. Во второй версии они переименовали свои модули в компоненты и все на этом успокоились.
Bronx
13.08.2021 09:24я прихожу к выводу, что при декларации классов/функций, зависящих от абстракций, в es6-модулях не должны использоваться import'ы, т.к. они привязаны к конкретным имплементациям (файлам — es6-модулям). Поэтому в приложениях, в которых соблюдается принцип инверсии зависимостей, допустимы es6-модули в которых полностью отсутствуют импорты,
Я так и не понял, к какому конкретно выводу вы пришли. Процитированное выше позволяет два толкования:
- модули без импортов — обязательны, т.е. все модули должны получать абсолютно все зависимости исключительно через DI. Используешь внутри
JSON.stringify()
? Это зависимость, получи её через DI, вдруг кто-то замокать захочет; - модули без импортов — просто допустимы, т.е. если модуль хочет абстракций — использует DI, если ему нужна конкретная имплементация — использует
import
илиglobal
, получилось без них — молодец.
Однако со вторым никто вроде и не спорит, никто никого не обязывает использовать импорты под пистолетом. Если это ваш главный вывод, то статья и опрос ни о чём.
flancer Автор
13.08.2021 09:51Вот видите, какой вы молодец! Для вас очевидно, что импорты в es6-модулях не обязательны. А вот по результатам опроса 60% считают наоборот.
Bronx
13.08.2021 10:39Так что насчёт "не должны использоваться import'ы"? Вообще никогда не должны, или когда-то можно?
Ну, скажем, возьмём какой-нибудь первый попавшийся пакет вроде
uuid
. Этот пакет зависит от ГСЧ, который в браузере берётся изglobal.crypto
, а в ноде — черезimport "crypto"
. Как по-вашему, должен ли был автор делать ГСЧ абстрактной зависимостью, чтобы пользователи сами искали и подсовывали нужный ГСЧ через DI?flancer Автор
13.08.2021 10:45при декларации классов/функций, зависящих от абстракций, в es6-модулях не должны использоваться import'ы
Я ответил на ваш вопрос?
Bronx
13.08.2021 10:58ГСЧ, используемый для генерации UUIDов — это абстракция?
flancer Автор
13.08.2021 11:03А что там в коде? По коду же обычно видно - абстракция или конкретика.
Bronx
13.08.2021 11:15В модуле
uuid
нету своего кода ГСЧ, берётся внешний — значит это вроде абстракция, нужно передавать через конструктор? В другом модуле нет своего кодаJSON.parse()
, берётся внешний — значит это тоже абстракция, тоже передавать через конструктор?flancer Автор
13.08.2021 11:19значит это вроде абстракция
Вроде или абстракция? Не всякая внешняя зависимость - абстракция. Некоторые вполне себе конкретные имплементации. Вы сталкивались когда-нибудь с интерфейсами и абстрактными классами?
Bronx
13.08.2021 11:36Вроде или абстракция?
Я же не знаю, что вы лично называете абстракцией. Термин "абстракция" — это абстракция :) Он зависит от того, где проведена граница, при этом каждый проводит границу сам, и границы, проведенные разными людьми, могут не совпасть. Например, я могу назвать абстракцией любой компонент, исходный код которого контролирую не я. Тогда абсолютно любая функция, определённая вне модуля — это абстракция, со своим абстрактным интерфейсом вызова.
Не всякая внешняя зависимость — абстракция. Некоторые вполне себе конкретные имплементации.
Я и пытаюсь добиться от вас ответа, где провести границу, и насколько далеко вы хотите зайти в отказе от импортов и использования глобальных API. Вы пока что предпочитаете от ответа уходить, отвечая вопросами на вопросы.
flancer Автор
13.08.2021 12:23Мне кажется, вы упустили, что статья называется "Инверсия зависимостей и 'import' в JS". Если вы определили в качестве зависимости некий интерфейс и не предполагаете, что за имплементация к вам попадёт (в этом и есть суть "Инверсии зависимостей"), то у вас, в общем случае, в принципе нет понимания, что за модуль этот интерфейс будет имплементировать и как он попадёт в ваш код. Всё, что от вас требуется - предоставить механизм внедрения (конструктор или сеттер).
и использования глобальных API
А это-то тут причём? Это ваша собственная придумка с JSON.parse() и она отношения к инверсии зависимостей не имеет.
Я и пытаюсь добиться от вас ответа, где провести границу,
Где удобно лично вам в соответствии с особенностями вашего проекта.
и насколько далеко вы хотите зайти в отказе от импортов
В своем коде я отказался от импортов своих собственных es6-модулей на 99%. Импортируется только DI-контейнер, всё остальное тянется через него. Разумеется, импорты в nodejs-части кода остаются (nodejs API и прочие npm-пакеты), в браузерной части кода импортов нет (за исключением DI-контейнера, опять же), т.к. мои модули тянутся через DI, а внешние библиотеки подключаются в globals в HTML:
<script type="application/javascript" src="./src/vue/vue.global.prod.js"></script>
и доступны в DI-контейнере через соответствующий класс-обёртку.
Но это совсем не значит, что вы тоже должны так делать. Если вы не понимаете суть принципа инверсии зависимостей, то отказ от import'ов нанесёт вам больше вреда, чем вы получите пользы. Более того, используя мой подход, вы не сможете создавать "классические" web-приложения и писать на TypeScript. Он подходит только для PWA и ESNext. У моего подхода достаточно специфическая область применимости и, чтобы его использовать, нужно осознавать границы этой области.
Bronx
13.08.2021 14:24export default class Fl64_Habr_Vue_Front_App { constructor(spec) { this.#config = spec['TeqFw_Web_Front_Model_Config$']; this.#container = spec['TeqFw_Di_Shared_Container$']; ... } }
Откуда пользователь узнает строки вроде "Fl64_Habr_Vue_Front_App$" или "TeqFw_Web_Front_Model_Config$", не заглядывая в имплементацию, в справочник по синтаксису, и без подсказки IDE?
Что будет, если класс переименовать/переместить? Бегать менять строки везде?
Как я понимаю, суффиксы вроде "#", "$" и "$$" указывают на способ получения зависимости из контейнера — т.е. это фактически неявный интерфейс доступа к контейнеру, закодированный в строках. По сути скрытый ServiceLocator.
По ссылке на прошлый пост прочитал про этот
spec
:Прокси-объект, через который DI-контейнер определяет, какую зависимость запрашивает конструктор. Контейнер по идентификатору находит исходник для зависимости и импортирует модуль
Ну так я и думал — это 100% ServiceLocator, с прокидыванием локатора в каждый конструктор, только вместо явного
require("./путь/к/компоненте")
илиcontainer.get("путь_к_компоненте")
у вас неявный, через геттер. Т.е. каждый класс теперь имеет одну-единственную зависимость — от вашего прокси, — и сигнатура конструктора никак не раскрывает истинные зависимости. А то, что вызов конструктора может запустить цепочку долгих загрузок недостающих модулей — это вообще полный мрак.Совершенно непонятно, почему нельзя использовать нормальный человеческий DI без этих странных телодвижений:
constructor(config, ...) { this.#config = config; ... }
Тоже никаких импортов, зато полная языковая поддержка при рефакторинге, и никаких скрытых локаторов. Даже с TypeScript импорты не нужны, если прописать типы в ambient declarations.
flancer Автор
13.08.2021 15:24Откуда пользователь узнает строки вроде "Fl64_Habr_Vue_Front_App$" или "TeqFw_Web_Front_Model_Config$"
Вы абсолютно правы, пользователь должен знать, где находится код, который он собирается использовать в качестве зависимостей. И от способа наименования кода эта ситуация не меняется:
new com.sun.net.ssl.internal.ssl.Provider(); // java new My_Long_NestedComponent_ClassName(); // PHP 5- new \My\Long\NestedComponent\ClassName(); // PHP 5+ import ClassName from 'package/src/My/Long/NestedComponent/ClassName.js'; // js
Что будет, если класс переименовать/переместить? Бегать менять строки везде?
Именно так. То, что за вас это делает IDE не значит, что не нужно "менять строки везде". К тому же при замене подобных длинных имён замечательно работает Find/Replace. У меня нет проблем с рефакторингом, если вы об этом.
Как я понимаю, суффиксы вроде "#", "$" и "$$" указывают на способ получения зависимости из контейнера — т.е. это фактически неявный интерфейс доступа к контейнеру, закодированный в строках. По сути скрытый ServiceLocator.
Не совсем так. Вам никто не запрещает создавать объекты вручную:
import App from '@flancer64/habr_teqfw_vue/src/Front/App.mjs'; const config ={}; const app = new App({ ['TeqFw_Web_Front_Model_Config$']: config, ... }) ;
ну и где тут сервис-локатор? К тому же лично я не вижу ничего плохого в самом сервис-локаторе. Просто не употребляйте его там, где не нужно.
Т.е. каждый класс теперь имеет одну-единственную зависимость — от вашего прокси
Неверно. Выше я привёл код, который позволяет создавать объект вручную без всяких прокси.
и сигнатура конструктора никак не раскрывает истинные зависимости.
Кто понимает правила, тот видит зависимости. DI-контейнер, например, справлется.
А то, что вызов конструктора может запустить цепочку долгих загрузок недостающих модулей — это вообще полный мрак.
Так работает любой DI-контейнер.
Совершенно непонятно, почему нельзя использовать нормальный человеческий DI
Потому что в JS нет "нормальной человеческой рефлексии".
Даже с TypeScript импорты не нужны, если прописать типы в ambient declarations.
Я не пишу на TypeScript, т.к. я не использую транспиляторы и бандлеры.
Bronx
14.08.2021 07:59Пользователь должен знать, где находится код, который он собирается использовать в качестве зависимостей.
Нет, класс
Widget
, являющийся пользователем сервисаLogger
, совершенно не обязан знать, где находится код класса, реализующего этот сервис. Это должен знать DI-контейнер и только он.Widget
должен знать только публичный интерфейс логгера.У меня нет проблем с рефакторингом, если вы об этом.
А с минификацией?
Вам никто не запрещает создавать объекты вручную… ну и где тут сервис-локатор?
То, что сервис-локатор имеет интерфейс хэшмапа не отменяет его присутствия. Вызов конструктора всё равно способен оказаться неожиданно долгим, с нетривиальнымы результатами.
Так работает любой DI-контейнер.
Вовсе нет, так работает только сервис-локатор (и SL не является DI, потому что он ничего никуда активно не "инжектирует", он пассивно выполняет запрос, в нем нет IoC).
Есть несколько причин почему SL считается мягко говоря спорной практикой:
- у всех появляется зависимость от SL. Как минимум, ваш SL накладывает ограничение на параметры конструктора, требуя передавать
spec
и толькоspec
; объекты которые не следуют паттерну (например, из чужих пакетов или встроенные), не могут пользоваться вашим механизмом. В DI такой проблемы нет — контейнер принципиально может сконструировать любой объект и скормить ему любой другой, no strings attached. - из-за SL конструктор внезапно получает дополнительную скрытую ответственность. В коде конструктора мы видим простую легковесную инициализацию членов, и ожидаем, что конструктор дёшев для вызова, что он может бросать только ошибки валидации. И в тестах с ручными хэшмапами оно так и есть — можно беззаботно создавать миллионы объектов в секунду.
В продакшине же вдруг внезапно оказывается, что там ещё появляется асинхронное общение с сетью, и конструктор может бросать ошибки сети, ошибки сервера, ошибки сериализации и чёрта лысого. И вызов тривиального конструктора может вылиться в целое приключение. Счастливой отладки.
В DI здорового человека вся чёрная и долгая работа — в контейнере — он загружает модули, обрабатывает ошибки. И только после того как всё необходимое загружено и готово — начинает строить граф зависимостей, вызывая конструкторы и фабрики в нужном порядке, и инжектируя в них нужные зависимости, уже готовые к использованию. Конструкторы же никогда в жизни не бросают ошибки, которые они не должны бросать, нарушая контракт (да-да, список исключений — это тоже часть контракта).
Потому что в JS нет "нормальной человеческой рефлексии".
А без неё совсем никак? В любом DI-контейнере есть стадия регистрации, где вы можете связать одни типы с другими, указать их жизненный цикл и проч. Рефлексия там не обязательна, хотя может помогать. Без неё будет чуть больше ручной работы, но всё это хотя бы будет локализовано в одном месте, а не навязано и распылено по всему коду. Как бонус, ваш composition root будет единственным источником истины. Разные композиции можно сохранять в файлах и выборочно загружать для разных условий.
Я не пишу на TypeScript
Ну вы в предыдущей статье спрашивали про возможность написать конструктор без import'ов. Так как любой код JS — это валидный код TS, то если это можно сделать в JS, то можно и в TS. Только информацию о типа нужно откуда-то взять, если не хочется получить
any
на всё.- у всех появляется зависимость от SL. Как минимум, ваш SL накладывает ограничение на параметры конструктора, требуя передавать
flancer Автор
14.08.2021 09:11Нет, класс
Widget
, являющийся пользователем сервисаLogger
, совершенно не обязан знать...ОК, я не так выразился. Я имел в виду местоположение класса в пространстве имён всех возможных классов проекта, что и продемонстрировал примером (в трёх из 4 вариантов используется как раз "логическая" адресация), но вы поняли по-своему (4-й вариант, но в JS нет namespace'ов, поэтому так). Пользователь должен знать, что собственно он собирается использовать в качестве зависимости - у меня это называется "идентификатор зависимости" и выглядит примерно так:
TeqFw_Core_Shared_Logger$
. Вы совершенно правы, что потребитель зависимости не знает, где находится имплементация, поиск соответствующего файла делает DI-контейнер в соответствии со своими настройками.А с минификацией?
А какое отношение имеет минификация к вашему начальному вопросу, на который я отвечал?
Что будет, если класс переименовать/переместить? Бегать менять строки везде?
У меня нет проблем с рефакторингом. И у меня нет минификации. Меня в первую очередь заботит, удобен ли код для отладки в браузере, и лишь затем - сколько места он там занимает и как долго он туда закачивается. Когда меня начнёт заботить минификация, я сделаю её на уровне раздачи статики web-сервером приложения при переводе его в production mode. Инструменты для этого есть - тот же uglify-js. Я использую кэширование на уровне Service Worker'ов, поэтому понадобится только однократная закачка нового кода на клиента (аналог инсталляции в десктопных приложениях). Но мне нравится, что вы расширяете охват оценки решения и выходите за рамки статьи.
То, что сервис-локатор имеет интерфейс хэшмапа не отменяет его присутствия.
Вы не поняли. При ручной сборке нет никакого DI-контейнера и сервис-локатора. Я оформляю свой код так, что он 100% совместим с обычным ручным импортом, созданием зависимостей и их внедрением. Да, в моих приложениях есть DI-контейнер (некоторые разделяют понятия DI-контейнер и сервис-локатор, но по сути любой DI-контейнер можно использовать как сервис-локатор, если заинжектить его в качестве зависимости), но 99% моего кода можно использовать без DI-контейнера. Правда вы замучаетесь вручную создавать и инжектить зависимости. Этим я ещё раз подчёркиваю, что у моих es6-модулей нет настоящей зависимости от моего контейнера. Да, вам будет неудобно использовать мои модули без моего контейнера, но с точки зрения архитектуры у меня всё ОК. Вы можете написать свой собственный "true" DI-контейнер, заложить в него граф зависимостей и он точно так же сможет использовать мои es6-модули без их переделки. Это должно быть понятно по коду модулей и я не буду больше повторять эту мысль.
Как минимум, ваш SL накладывает ограничение на параметры конструктора
Да, моё "вытягивание зависимостей" (если вам больше нравится точность формулировок) основано на определённом формате аргумента конструктора. Вы совершенно правы. Не любой объект может быть сконструирован моим контейнером.
из-за SL конструктор внезапно получает дополнительную скрытую ответственность.
Точно так. Но если вы о ней знаете, то она уже не скрытая.
В коде конструктора мы видим ..., и ожидаем ...
Да, мой подход не совпадает с вашим видением и ожиданием.
И вызов тривиального конструктора может вылиться в целое приключение.
Может. Вопрос, что вы получаете за эту плату.
В DI здорового человека вся чёрная и долгая работа — в контейнере — он загружает модули, обрабатывает ошибки. И только после того как всё необходимое загружено и готово — начинает строить граф зависимостей,
Получается, что для того, чтобы нарисовать в SPA логин-форму DI-контейнер "здорового человека" должен распарсить зависимости на всё приложение? А как быть с правами, которые выясняются уже после авторизации? Ведь по ним может оказаться, что у пользователя нет доступа к части функционала. Или DI-контейнер должен создаваться на каждый route в SPA?
Для backend'а такой DI подойдёт, а на фронте с ним будут проблемы.
А без неё совсем никак?
А без неё вот так - приходится вводить spec и отталкиваться от названий атрибутов spec'а.
В любом DI-контейнере есть стадия регистрации, где вы можете связать одни типы с другими, указать их жизненный цикл и проч.
Вы можете взять "любой DI-контейнер" и он сможет работать с моими es6-модулями. Нужно будет только "связать одни типы с другими, указать их жизненный цикл и проч." Вы же сами говорите, что "true" DI-контейнеры могут работать с любыми классами, значит смогут и с моими.
Свой контейнер я сделал под себя, мне с ним удобно вот так. И во многом потому, что мне не нравится делать "чуть больше ручной работы".
а не навязано и распылено по всему коду.
У меня как раз другая цель - размазать функционал по плагинам (npm-пакетам) и сделать их переиспользуемыми.
Ну вы в предыдущей статье спрашивали про возможность написать конструктор без import'ов.
Да, это были "размышления вслух". Я на днях опять попробовал TS и там всё ещё есть транспиляция. Поэтому я пока что останусь на JS.
Bronx
14.08.2021 09:30А какое отношение имеет минификация к вашему начальному вопросу, на который я отвечал?
Минификаторы могут менять имена компонентов. Если ваш локатор парсит строки для поиска компонент по имени класса, то это может поломать его — как и при рефакторинге. Поиск/замена тут не помогут.
Получается, что для того, чтобы нарисовать в SPA логин-форму DI-контейнер "здорового человека" должен распарсить зависимости на всё приложение?
Распарсить зависимости на приложение он должен, чтобы запустить собственно приложение. Форма же не в воздухе висит? А когда придёт очередь формы — он распарсит зависимости для этой формы.
flancer Автор
14.08.2021 09:50Минификаторы могут менять имена компонентов.
Очень сильно подозреваю, что могут и не менять - в зависимости от настроек. Вряд ли они меняют строковые константы и имена экспортов - а это всё, что нужно.
А когда придёт очередь ...
ну, значит я неправильно понял ваше
И только после того как всё необходимое загружено и готово — начинает строить граф зависимостей, вызывая конструкторы и фабрики в нужном порядке, и инжектируя в них нужные зависимости, уже готовые к использованию.
flancer Автор
13.08.2021 11:26А-а, походу я понял, куда вы клоните. Типа, если в es6-модуле есть хотя бы одна абстрактная зависимость, то импорты в этом модуле не должны использоваться вовсе? Разумеется, нет - импорты конкретных зависимостей остаются. Отсутствуют только импорты для абстрактных зависимостей, тех, что в TS оформляются в виде интерфейсов.
Alexandroppolus
13.08.2021 10:59+1Ну, скажем, возьмём какой-нибудь первый попавшийся пакет вроде
uuid
. Этот пакет зависит от ГСЧ, который в браузере берётся изglobal.crypto
, а в ноде — черезimport "crypto"
. Как по-вашему, должен ли был автор делать ГСЧ абстрактной зависимостью, чтобы пользователи сами искали и подсовывали нужный ГСЧ через DI?import { v4 } from 'uuid'; const id = v4({ rng: myFunc });
myFunc уже решает, откуда взять рандомайзер. То есть автор uuid сделал это абстрактной зависимостью (хотя и предусмотрел некое дефолтное разрешение этой зависимости). Собственно, вот он DI в чистом виде.
Bronx
13.08.2021 11:07Меня интересует, до какого уровня абстракции автор предлагает дойти. Нужно ли просовывать через DI вообще всё, что хоть как-то используется модулем —
JSON.parse()/stringify()
,atob()
,URI
и т.п., или где-то можно остановиться?Alexandroppolus
13.08.2021 11:22Хороший вопрос, кстати. Выскажу своё мнение: тут не обойтись без чутья и чувства меры. Стандартные, встроенные в язык штуки вроде бы тащить в DI не надо. А вот всякие модули среды исполнения вполне могут (но не обязательно) понадобиться там, где их нет, при переиспользовании клиентского кода на сервере или наоборот, или в каких иных ситуациях. Или, к примеру, fetch - зашили его напрямую, а потом понадобилось в запросы что-то добавлять. Короче, зависит от ситуации.
- модули без импортов — обязательны, т.е. все модули должны получать абсолютно все зависимости исключительно через DI. Используешь внутри
Alexandroppolus
Рассмотренные здесь модули без импортов вполне соответствуют DI, и так кодить правильно, имхо. Насчёт SOLID - его 4 и 5 принципы к JS вообще никак не относятся: нет интерфейсов, говорить не о чем.
flancer Автор
JS - "живой" язык, начиная с 2015 года обновления идут каждый год. Возможно, эта красота закончится через год или раньше, но пока так. Думаю, что некоторую имплементацию интерфейсов в него реально встроить в ближайшие год-два, если спрос на интерфейсы будет, разумеется.
Хотя даже на нынешнем уровне развития языка возможно использование интерфейсов в виде классов с пустыми методами:
По Ctrl+I с курсором на 15-й строке IDEA выдает подсказку, какие методы нужно добавить для класса
Person
, имплементирующегоIPerson
:Я бы не стал списывать со счетов 4-й и 5-й принципы SOLID даже для JS :)
Спасибо, что высказались за возможность существования es6-модулей с зависимостями, но без импортов.
Druu
4 и 5 принципы solid не имеют ни какого отношения к интерфейсам
flancer Автор
Карма меньше -30 накладывает ограничение на количество комментариев в сутки, а не на количество букв в комментарии. Могли бы и раскрыть свою мысль, если она у вас вдруг возникла.
Druu
Так а что тут раскрывать? Просто в формулировке Interface segregation и Dependency inversion самого Мартина ни чего нет про интерфейсы (сразу примечание: конечно же, "interface" в "interface segregation" это не те интерфейсы, которые в условной джаве, т.е. это не языковой механизм). Оно прекрасно работает как в языке с интерфейсами, так и в языке без них - это принципы объектно-ориентированного программирования.
flancer Автор
"Мне кажется, я начал понимать, что ты имела в виду!" (c)