Инверсия зависимостей и 'import' в JS

Kate

Administrator
Команда форума
В процессе обсуждения статьи "Почему я «мучаюсь» с 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-модулей импортов конкретных имплементаций "убивают" принцип инверсии зависимостей.

 
Сверху