Этот пост является ещё одной попыткой сформулировать идею, зачем нужно внедрение зависимостей в ванильном JavaScript (именно в ES6+, а не в TS).

Основная сложность в том, что шаблон “внедрение зависимостей” (DI) есть следствие применение на практике “принципа инверсии зависимостей” (DIP). Классическая формулировка этого принципа выглядит так:

  • A. Модули верхних уровней не должны зависеть от модулей нижних уровней. Оба типа модулей должны зависеть от абстракций.

  • B. Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.

Для JS-программиста данная формулировка представляет определённую сложность в силу того, что в JS нет классических абстракций (в виде “интерфейсов” из других ЯП). В JS вообще нет абстракций, тут всё очень конкретно: вот объекты, вот примитивы - комбинируй.

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

Наверное, самое главное, что нужно принимать во внимание, что DIP относится к архитектуре приложения. Резон использовать этот принцип появляется в тот момент, когда разработчики начинают задумываться не о том, как реализовывать бизнес-функции, а о том, как организовать код так, чтобы можно было продолжать реализовывать бизнес-функции с приемлемой скоростью.

Развитие DIP в практической плоскости во “взрослых” языках программирования (с интерфейсами, классами и прочим) привело к появлению такого архитектурного решения, как “инверсия управления” (IoC). Внедрение зависимостей - это один из методов реализации данного архитектурного решения (наряду с “Локатором служб”, шаблоном “Фабрика” и контекстным поиском). И вот на этом уровне всё становится уже несколько более понятным даже в JS.

Прямой код

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

globals

Например, мы можем считать, что данные объекты каким-то образом попадают в globals, и тогда js-код сервиса мог бы выглядеть так (./service.js):

const logger = self.logger;
const config = self.config;

export function service({name, count}) {
    const total = count * config.price;
    logger(`Product '${name}' is sold for ${total}$.`);
    return total;
}

А его вызов из HTML-кода:

<script>
    self.config = {price: 10};
    self.logger = (msg) => console.log(msg);
</script>
<script type="module">
    import {service} from './service.js';

    const total = service({name: 'Beer', count: 6});
</script>

Modules

Либо разместить логгер и конфигурацию во внешних es-модулях:

// ./logger.js
export default function (msg) {
    console.log(msg);
}
// ./config.js
export default {
    price: 15,
};

и подгружать эти модули в коде сервиса:

import config from './config.js';
import logger from './logger.js';

export function service({name, count}) {...}

В общем, разработчику сервиса нужно знать, какая зависимость где и в каком виде находится. Более того, при смене одного метода размещения зависимостей на другой (globals <=> modules) придётся изменять код всех сервисов, замкнутых на эти зависимости.

Инверсия

Инверсия контроля предполагает, что код нашего сервиса предоставляет возможность некоему внешнему управляющему добавить в него необходимые сервису зависимости. Где этот внешний код будет брать эти зависимости (из глобалов или модулей), наш сервис это не волнует:

export default function (logger, config) {
    return function ({name, count}) {
        const total = count * config.price;
        logger(`Product '${name}' is sold for ${total}$.`);
        return total;
    };
}

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

В целом, приложение даже усложнилось (помимо объектов logger, config, service появился ещё какой-то “внешний управляющий” и фабричная функция). Но с точки зрения разработчика именно сервиса положение слегка упростилось - он задекларировал нужные ему зависимости в фабричной функции и ему уже не важно, в каком виде эти зависимости будут имплементированы и как подключены. Всё, что ему нужно знать - это имена зависимостей и их API.

Если использовать “классовый подход”, то фабричная функция замещается конструктором класса:

export default class Service {
    constructor(logger, config) {
        this.exec = function ({name, count}) {
            const total = count * config.price;
            logger(`Product '${name}' is sold for ${total}$.`);
            return total;
        };
    }
}

В таком виде внедрение зависимостей становится более похожа на классические варианты "внедрения зависимостей через конструктор" из других ЯП.

Резюме

Использование инверсии контроля в архитектуре приложения позволяет значимо уменьшить связность кода отдельных модулей (функций, классов), выводя проблемы нахождения и инициализации зависимостей на внешнюю, по отношению к коду, орбиту.

Это не значит, что приложение в целом стало проще - наоборот, оно стало сложнее. Но за счёт упрощения отдельного блока (модуля, функции, класса) появилась возможность строить более сложные приложения. Так, из стандартизованных кирпичей стало проще возводить бОльшие здания, чем из необработанного камня (такая себе аналогия, но тем не менее).

IoC (и DI) есть смысл применять там, где приложение состоит из множества модулей, которые распределены по множеству пакетов. В первую очередь - в nodejs-приложениях. В браузерах, где js-код зачастую внедряется в HTML-код страницы фрагментами, этот подход вряд ли будет оправдан, за исключением SPA/PWA - эти приложения по своей архитектуре уже приближаются к приложениям enterprise-уровня.

P.S. Я специально не описываю в посте деталей "внешнего управляющего", потому что суть IoC (и DI) - это "кирпичи", а не "строитель".

Комментарии (16)


  1. Alexandroppolus
    14.07.2023 18:57
    +3

    Насколько я понимаю, с автокомплитом в вскоде/вебшторме всё будет совсем грустно. Если при импорте автокомплит хоть как-то мог связать концы с концами, то что такое logger и config в последнем примере, он даже близко понятия не имеет. Преимущества TS тут ощущаются наиболее остро.


    1. flancer Автор
      14.07.2023 18:57
      -2

      У TS есть определение logger и config по-умолчанию? Или всё таки кто-то где-то должен будет определить эти объекты и для TS в том числе?


      1. Alexandroppolus
        14.07.2023 18:57
        +2

        На тайпскрипте параметры функции будут (logger: ILogger, config: Config), и внутри функции/класса автокомплит досконально знает, что там должно быть в составе объектов.


        1. flancer Автор
          14.07.2023 18:57
          -2

          Вы лишь подтвердили, что кто-то где-то определил типы ILogger & Config. А то, что автокомплит знает то, а не знает это - всего лишь вопрос программирования автокомплита. В Notepad++ никто не знает ничего.


    1. Pab10
      14.07.2023 18:57
      +1

      Тут вы конечно правы. Но не умерла еще поддержка такой штуки как https://jsdoc.app/ В пайчарме, во всяком случае, работает. В вебшторме тоже должно.


      1. Alexandroppolus
        14.07.2023 18:57
        +2

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


        1. flancer Автор
          14.07.2023 18:57

          У JSDoc'а есть перед TS одно немаловажное преимущество - его не надо транспилировать.


  1. SuperCat911
    14.07.2023 18:57

    Я задаюсь вопросом: а зачем нам вообще нужен этот конструктор при условии, что в js этот механизм уже реализован через ключевое слово export?

    Вот код service.js:

    import config from './config.js';
    import logger from './logger.js';
    
    let service = {config, logger};
    
    export default service;

    Код index.html:

    <script type="module">
        import service from './service.js';
        service.logger("test");
    </script>

    По факту в каждый модуль Вы будете проставлять только одну зависимость от service.js.

    Я что-то упустил?


    1. Pab10
      14.07.2023 18:57
      +1

      В вашем коде связывание просиходит на уровне импортов, не в рантайме. Ну и если у вас есть соответствующая фабрика и соответствующий сервис с DI он становится более ПЕРЕМЕЩАЕМЫМ, не зависит от импортов и их путей вообще. Дело вкуса короче. По идее оно должно делать мир проще, по факту уничтожает навигацию по коду. Надо постоянно держать в голове идею о том, что где-то сверху есть что-то, что вонзило сюда аргументы.


      1. flancer Автор
        14.07.2023 18:57

        Точно так. А для навигации по коду я использую JSDoc'и (как вы указали в комменте выше). В PhpStorm'е работают очень даже неплохо. В них, кстати, и интерфейсы есть, и привязка к имплементации. Можно над конкретикой JS'а надстроить свою собственную абстракщину :)


      1. SuperCat911
        14.07.2023 18:57

        Раскрою мысль.

        Каждый подключаемый модуль пройдет в начале через импорт или его аналог (например, прямое внедрение на страницу через тег script). Это часть жизненного цикла. Ибо откуда он сам по себе возьмется? А это означает, что адрес модуля, его физическое расположение любом случае нужно будет учитывать (даже хотя бы один раз).

        Причем эта концепция полностью отражена в моем простом коде service.js, где все модули грузятся только всего один раз и будут доступны в неймспейсе модуля service.js.

        Если какой-то из модулей будет перемещен (кроме базового, разумеется), то изменения придется внести один раз, что в коде автора, что в моем примере. И в голове ничего не надо держать.

        Нужно больше рантайма? Вот новый код service.js:

        import config from './config.js';
        import logger from './logger.js';
        
        let service = {config, logger};
        window.service = service;
        
        export default service;

        Теперь вообще можно не использовать js-импорт в модулях. Но рано радоваться, потому что для работы линтера как бы не пришлось в коде без js-импортов каждый раз указывать jsdoc-импорт типа service.

        Например, чтобы линтер точно понял с чем мы имеем дело, все равно придется прописать прямую зависимость в каждом модуле что-то вроде этого:

        /** @typedef {import("./service.js").service} service */
        
        /** @type {service} */
        let service = window.service;


        1. flancer Автор
          14.07.2023 18:57

          Каждый подключаемый модуль пройдет в начале через импорт или его аналог (например, прямое внедрение на страницу через тег script). Это часть жизненного цикла. Ибо откуда он сам по себе возьмется? А это означает, что адрес модуля, его физическое расположение любом случае нужно будет учитывать (даже хотя бы один раз).

          Точно. В ES6+ модули загружаются через импорт - статический или динамический. Других вариантов нет.

          Причем эта концепция полностью отражена в моем простом коде service.js, где все модули грузятся только всего один раз и будут доступны в неймспейсе модуля service.js.

          Это и есть прямой контроль - когда разработчик сервиса должен знать, где находятся его зависимости, чтобы подключить их через import.

          Если какой-то из модулей будет перемещен (кроме базового, разумеется), то изменения придется внести один раз

          Именно. Изменения придётся внести. Если у вас зависимость (logger) используется в одном модуле (service), то придётся делать одно изменение. А если у вас таких сервисов десять тысяч, то придётся делать десять тысяч изменений. Вы всего лишь передвинули один модуль (logger), а менять пути импорта придётся в десяти тысячах скриптов, которые этот модуль используют.

          как бы не пришлось в коде без js-импортов каждый раз указывать jsdoc-импорт типа service.

          Именно это и приходится делать - использовать JSDoc для описания типов зависимостей.

          Вот смотрите, довольно распространённая ситуация. Вы пишите сервис, который должен логировать данные в ходе выполнения своей работы. У вас есть два логгера - консольный (для разработки) и файловый (для прода). Во "взрослых" языках программирования вы можете определить интерфейс логгера. Например так:

          /** @interface */
          class ILogger {
              error(msg) {}
              info(msg) {}
          }

          затем использовать этот интерфейс при разработке сервиса:

          class Service {
              /** @type {ILogger} */
              logger;
          
              exec(opts) {
                  this.logger.info('Executing...');
              }
          }

          Всё, для разработки сервиса вам не нужно знать, где собственно находятся имплементации логгера и какие (консольные, файловые, сетевый, базы данных).

          Но. Чтобы это дело заработало, каким-то образом нужно внедрить зависимость logger в класс service. Обычно это делают через конструктор:

          class Service {
              /** @type {ILogger} */
              logger;
              /** @param {ILogger} logger */
              constructor(logger) {
                  this.logger = logger;
              }
          }

          Смотрите, вот валидный JavaScript код, который соответствует поставленной задаче (сервис с логгированием) и в котором нет ни одного импорта. Мы просто резанули задачу по месту склейки (интерфейсу) и можем распараллелить процесс разработки: один разработчик делает сервис, второй - файловый логгер, третий - логгер для перенаправления логов на сервис Sentry.

          Вы правы, что без импортов ничего не закрутится. Как разработчик сервиса вы можете поднять окружение (привет TDD!), которое мокирует зависимости в соответствии с заданным интерфейсом и проверяет корректность имплементации:

          import assert from 'assert';
          import {describe, it} from 'mocha';
          import {Service} from './Service.js'; 
          
          /** @implements ILogger */
          const logger = {
              error(mg) {},
              info(msg) {}
          };
          
          describe('Service', () => {
              it('does the job', () => {
                  const service = new Service(logger);
                  service.exec({});
                  assert(true);
              });
          });

          Вот здесь, в тестовом окружении, вы import'ы и используете. Вот это уже инверсия управления. Разраб сервиса ничего не знает о том, где находится код логгера и какой из логгеров (консольный или файловый) будет использоваться - это вне рамок поставленной ему задачи. Он лишь знает, что каким-то образом его сервис получит логгер при создании. Для разработки сервиса разраб мокирует зависимости, имплементируя нужное ему поведение зависимостей. Это в данном случае логгер просто делает что-то и не возвращает ничего. Зачастую зависимости возвращают какой-то результат и разработчик сервиса может запрограммировать этот результат (или набор результатов) в своей имплементации зависимости. Более того, для разных тестов можно создавать разные имплементации одних и тех же зависимостей. Зацепление кода резко снижается. В JS код цепляется импортами. Нет импортов - нет зацепления.

          Подобная красота достигается вот таким типовым кодом:

          export class Service {
              constructor(dep1, dep2, dep3, ...) { }
          }

          Да, на самом деле, с JSDoc'ами, код должен выглядеть вот так вот:

          export class Service {
              /**
               * @param {Type1} dep1
               * @param {Type2} dep2
               * @param {Type3} dep3
               * ...
               */
              constructor(dep1, dep2, dep3, ...) { }
          }

          А на практике вообще вот так:

          export class Service {
              /**
               * @param {Type1} dep1
               * @param {Type2} dep2
               * @param {Type3} dep3
               * ...
               */
              constructor({
                              Type1: dep1,
                              Type2: dep2,
                              Type3: dep3,
                              ...
                          }) { }
          }

          Приходится по сути дублировать JSDoc'ами типы зависимостей. Но. В замен такому геморрою мы получаем не только слабое зацепление но и ещё кое-какие DI'ные плюшки - синглтоны и транзиентные объекты, например.

          И это в ванильном JavaScript'е. Один и тот же код и для браузера, и для ноды. Без какой-либо транспиляции. Ну разве не красота?!


          1. SuperCat911
            14.07.2023 18:57
            +1

            А если у вас таких сервисов десять тысяч, то придётся делать десять тысяч изменений. Вы всего лишь передвинули один модуль (logger), а менять пути импорта придётся в десяти тысячах скриптов, которые этот модуль используют.

            Я пытаюсь донести, что если не хотите указывать все зависимости в виде импортов в ваших пакетах, то можно использовать главный (базовый) модуль-контейнер (аля master of packages), в котором и будет все импортировано один раз. Собственно будет внесено исправление один раз.

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

            Далее по поводу проблемы имплементации логгера, а есть ли вообще проблема? Это вопрос конфигурирования ваших сервисов с учетом окружения. Это вообще другая тема.


    1. flancer Автор
      14.07.2023 18:57

      Коллега @Pab10 ответил совершенно верно. Код с импортами привязан к конкретной имплементации:

      import config from './config.js';

      А если имплементация через импорт имеет свои зависимости, у которых свои импорты, то выстраивается развесистое дерево зависимостей. В общем, если вы код не рефакторите, то и ничего - можно захардкодить архитектуру на импортах. А если рефакторите, тогда желательно обеспечивать максимально низкое зацепление (coupling). IoC как раз об этом.

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

      Во "взрослых" (с точки зрения "энтерпрайзности") языках программирования (Java, C#) уже, наверное, десятилетиями используют контейнеры объектов. Очень удобная штука. Особенно для тестирования. Но там и интерфейсы есть и прочая абстракщина.


      1. SuperCat911
        14.07.2023 18:57
        +2

        В дополнение к моему ответу выше (https://habr.com/ru/articles/748132/comments/#comment_25753998) могу сказать, что получившийся service.js, который доступен через window.service является как раз таковым контейнером.

        Если мы говорим о "пакетах", то внутри папки пакета вполне допустимо и правильно использовать относительные пути.

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


        1. flancer Автор
          14.07.2023 18:57
          +1

          Да, ваш пример совершенно корректен. Так можно и нужно писать код в ES6+ - через import'ы. Как я написал в статье: "Резон использовать этот принцип появляется в тот момент, когда разработчики начинают задумываться не о том, как реализовывать бизнес-функции, а о том, как организовать код так, чтобы можно было продолжать реализовывать бизнес-функции с приемлемой скоростью."

          Грубо говоря, до десятков npm-пакетов и сотен или даже тысяч es-модулей можно не заморачиваться с IoC.