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

Я разбил весь пост на 7 частей так, чтобы двигаясь от части к части шло наращивание функционала демо-кода вплоть до действующего контейнера объектов.

1. Composition root

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

Вот пример прямого контроля (через статические импорты):

import logger from './logger.js';

export default class Service {
    exec(opts) {
        logger.info(`Service is running with: ${JSON.stringify(opts)}`);
    }
}

Вот пример обратного контроля (через параметры конструктора):

export default class Service {
    #logger;

    constructor(logger) {
        this.#logger = logger;
    }

    exec(opts) {
        this.#logger.info(`Service is running with: ${JSON.stringify(opts)}`);
    }
}

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

import logger from './logger.js';
import Service from './service.js';

const srv = new Service(logger);
srv.exec({name: 'The Basics of IoC'});

Это место в программе называется composition root. Если у нас есть внедрение зависимостей, у нас обязательно есть composition root.

2. Спецификация зависимостей

Язык JavaScript (ES6+) не может похвастаться развитым инструментарием по анализу своего собственного кода (reflection). В нём нет возможности проанализировать типы аргументов конструктора, да и сами имена аргументов могут быть изменены в процессе минификации кода. Но если мы договоримся о том, что все нужные зависимости попадают в конструктор в виде одного единственного объекта - спецификации:

class Service {
    constructor(spec) { }
}

где каждое свойство спецификации представляет собой отдельную зависимость:

class Service {
    constructor({logger, config}) { }
}

то мы защищаемся от изменения имён зависимостей и получаем возможность их (имена) анализировать.

3. Фабрика

Классы - это синтаксический сахар и создание объектов можно выполнять обычными функциями (фабриками):

function Factory({dep1, dep2, ...}) {
    return function (opts) {/* use deps here */};
}

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

// ./logger.js
export default async function Factory() {
    return {
        error: (msg) => console.error(msg),
        info: (msg) => console.info(msg),
    };
};
// ./service.js
export default async function Factory({logger}) {
    return function (opts) {
        logger.info(`Service is running with: ${JSON.stringify(opts)}`);
    };
}

В таком случае наш composition root мог бы выглядеть вот так:

// ./main.js
import fLogger from './logger.js';
import fService from './service.js';

const logger = await fLogger();
const serv = await fService({logger});
serv({name: 'The Basics of Factories'});

4. Импорты

Предположим, что зависимости в спецификациях являются путями к es6-модулям с фабриками зависимостей:

const spec = {
    dep1: './path/to/the/dep1/module.js',
    dep2: './path/to/the/dep2/module.js',
    ...
};

Тогда мы можем использовать динамические импорты внутри фабричных функций, которым для создания результирующих объектов нужны зависимости:

// ./service.js
export default async function Factory({logger: pathToLogger}) {
    // begin of DI functionality workaround
    const {default: fLogger} = await import(pathToLogger);
    const logger = await fLogger();
    // end of DI functionality workaround
    return function (opts) {
        logger.info(`Service is running with: ${JSON.stringify(opts)}`);
    };
}

Таким образом, на этом этапе мы можем полностью избавиться от статических импортов:

// ./main.js
const {default: fService} = await import('./service.js');
const serv = await fService({logger: './logger.js'});
serv({name: 'The Basics of Import'});

5. Прокси

Для анализа зависимостей разработчики awilix предложили использовать Proxy-объект:

export default new Proxy({}, {
    get(target, prop) {
        console.log(`proxy: ${prop}`);
        return target[prop];
    }
});

В таком случае мы можем вынести загрузку и создание зависимостей из сервисов в спецификацию:

// ./spec.js
// workaround to load 'logger' dep
import fLogger from './logger.js';
const logger = await fLogger();
// end of workaround
export default new Proxy({}, {
    get(target, prop) {
        return (prop === './logger.js') ? logger : target[prop];
    }
});

Фабричная функция для сервиса:

// ./service.js
export default function Factory({['./logger.js']: logger}) {}

Composition root для данного случая выглядит так:

// ./main.js
import spec from './spec.js';
import fService from './service.js';

const serv = await fService(spec);
serv({name: 'The Basics of Spec Proxy'});

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

6. Контейнер

Мы не можем заранее знать о всех зависимостях проекта, но мы можем анализировать зависимости по мере того, как объекты запрашивают их через прокси-спецификацию.

Пусть у нас в контейнере будет такой код для прокси-спецификации:

// ./container.js
const DEP_KEY = 'depKey'; // key for an exception to transfer dependency key
const deps = {}; // all created deps

const proxy = new Proxy({}, {
    get(target, prop) {
        if (deps[prop]) return deps[prop];
        else {
            const e = new Error('Unresolved dependency');
            e[DEP_KEY] = prop;
            throw e;
        }
    }
});

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

Пусть в этом же модуле ./container.js находится функция, которая использует асинхронную фабрику объектов из пункта 3:

// ./container.js - продолжение
async function useFactory(fnFactory) {
    let res;
    // try to create the Object
    do {
        try {
            // Object is created when all deps are created
            res = await fnFactory(proxy);
        } catch (e) {
            if (e[DEP_KEY]) {
                // we need to import another module to create dependency
                const depKey = e[DEP_KEY];
                const {default: factory} = await import(depKey);
                deps[depKey] = await useFactory(factory);
            } else {
                // this is a third-party exception, just re-throw
                throw e;
            }
        }
        // if Object is not created then retry (some dep was not imported yet)
    } while (!res);
    return res;
}

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

Ну и в конце у нас код самого контейнера:

// ./container.js - продолжение
export default {
    /**
     * Get some object from the Container.
     * @param {string} key
     * @return {Promise<*>}
     */
    get: async function (key) {
        const {default: factory} = await import(key);
        const res = await useFactory(factory);
        deps[key] = res;
        return res;
    }
};

В общем-то сейчас у нас контейнер (./container.js) и является composition root.

полный исходный код контейнера
const DEP_KEY = 'depKey'; // key for exception to transfer dependency key (path for import)
const deps = {}; // all created deps

const proxy = new Proxy({}, {
    get(target, prop) {
        if (deps[prop]) return deps[prop];
        else {
            const e = new Error('Unresolved dependency');
            e[DEP_KEY] = prop;
            throw e;
        }
    }
});

async function useFactory(fnFactory) {
    let res;
    // try to create the Object
    do {
        try {
            // Object is created when all deps are created
            res = await fnFactory(proxy);
        } catch (e) {
            if (e[DEP_KEY]) {
                // we need to import another module to create dependency
                const depKey = e[DEP_KEY];
                const {default: factory} = await import(depKey);
                deps[depKey] = await useFactory(factory);
            } else {
                // this is a third-party exception, just re-throw
                throw e;
            }
        }
        // if Object is not created then retry (some dep was not imported yet)
    } while (!res);
    return res;
}

export default {
    /**
     * Get some object from the Container.
     * @param {string} key
     * @return {Promise<*>}
     */
    get: async function (key) {
        const {default: factory} = await import(key);
        const res = await useFactory(factory);
        deps[key] = res;
        return res;
    }
};

Основной модуль приложения становится таким:

// ./main.js
import container from './container.js';
const serv = await container.get('./service.js');
serv({name: 'The Basics of Container'});

По сути, мы переместили пути к es6-модулям с исходным кодом из статических импортов в зависимости в фабричных функциях и сами статические импорты заменили динамическим импортом в функции useFactory.

7. Карта зависимостей

Добавим в наш контейнер объектов карту зависимостей, чтобы отвязаться от путей к модулям с исходниками в коде фабрик объектов:

// ./container.js
...
const map = {}; // objectKey-to-importPath map
...

async function useFactory(fnFactory) {
    let res;
    do {
        try {...} catch (e) {
            if (e[DEP_KEY]) {
                ...
                const path = map[depKey] ?? depKey;
                const {default: factory} = await import(path);
                ...
            } else {... }
        }
        // if Object is not created then retry (some dep was not imported yet)
    } while (!res);
    return res;
}

export default {
    /**
     * Get some object from the Container.
     * @param {string} key
     * @return {Promise<*>}
     */
    get: async function (key) {
        const path = map[key] ?? key;
        const {default: factory} = await import(path);
        ...
    },
    setMap: function (data) {
        Object.assign(map, data);
    },
};

Теперь мы можем использовать "логические" имена зависимостей в своём коде:

// ./service.js
export default async function Factory({logger, config}) {
    return function (opts) {
        logger.info(`Service '${config.appName}' is running with: ${JSON.stringify(opts)}`);
    };
}

Контейнер самостоятельно преобразует логические имена зависимостей в пути к es6-модулям при помощи карты. Нужно только её задать в головном файле приложения:

// ./main.js
import container from './container.js';

container.setMap({
    service: './service.js',
    logger: './logger.js',
    config: './config.js',
});
const serv = await container.get('service');
serv({name: 'The Basics of Resolver'});

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

const mapBack = {
    service: '/absolute/path/to/service.js',
    logger: '/absolute/path/to/logger.js',
    config: '/absolute/path/to/config.js',
};

const mapFront = {
    service: 'https://domain.com/app/service.js',
    logger: 'https://domain.com/app/logger.js',
    config: 'https://domain.com/app/config.js',
};

Резюме

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

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


  1. impwx
    25.07.2023 07:20
    +1

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


    1. flancer Автор
      25.07.2023 07:20

      Да, думаю, неплохо может получиться. Это без спецификации (spec) возможно переименование аргументов при минификации, а если все зависимости идут как ключи объекта, то можно и регуляркой доставать.


  1. SWATOPLUS
    25.07.2023 07:20
    +1

    За export default существует отдельный котел в аду.


    1. flancer Автор
      25.07.2023 07:20

      Чем так плох `export default`, что за него так сурово карают в послежизни?


      1. LEXA_JA
        25.07.2023 07:20

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


        1. flancer Автор
          25.07.2023 07:20

          Вполне возможно. Но на практике я пока что не сталкивался с необходимостью протягивать имя класса через статические импорты. IDE прекрасно подобное разруливает. PhpStorm, как минимум. Даже с опечатками.


      1. olegbarabanov
        25.07.2023 07:20

        Хорошее объяснение, почему не рекомендуется export default есть в Google TypeScript Style Guide (раздел "Экспорт") (хоть и TS, но также подходит и к JS).

        Пример поведения, который не всегда является желанным:

        • bar.ts :

        export default class Foo { ... }
        
        • custom-module.ts:

        import Foo from './bar';  // Валидно.
        import Bar from './bar';  // Также валидно.
        

        Потом при ревью, отладке и пр., в голове приходится держать, что Foo === Bar, что не всегда очевидно и может приводить к неприятным неожиданностям в коде.


        1. flancer Автор
          25.07.2023 07:20
          -1

          Спасибо за пояснение. Но примерно такие же проблемы могут возникнуть и без default'а:

          // ./bar.js
          export class Foo {}
          import {Foo} from './bar.js';
          import {Foo as Bar} from './bar.js';
          
          const f1 = new Foo();
          const b1 = new Bar();

          IMHO, иметь один класс в одном модуле - это очень хорошая практика для бэка, судя по Java/PHP. Да и вообще SOLID'но (single-responsibility principle). А раз так, то больше одного экспорта и не особо-то нужно.

          По рукам, на мой взгляд, нужно бить не за то, что кто-то что-то по default'у экспортирует, а как раз вот за такое использование (если оно ни чем не обусловлено):

          import Foo from './bar';  // Валидно.
          import Bar from './bar';  // Также валидно.

          впрочем, как и за:

          import {Foo} from './bar.js';
          import {Foo as Bar} from './bar.js';


          1. olegbarabanov
            25.07.2023 07:20
            +1

            Обратите внимание пожалуйста на разницу этих двух примеров:

            import Bar from './bar';
            

            и

            import {Foo as Bar} from './bar';
            

            Несколько моментов:

            • В первом случае импортируется "что-то", что далее будет именоваться как Bar и нет никаких гарантий, что это будет именно класс Foo. Во втором случае, вы явно импортируете конкретно класс Foo и при помощи оператора as четко даете понять ревьюверу, что вам необходим именно класс Foo, но в данном модуле он будет именоваться как класс Bar.

            • Далее, если взглянуть на первый вариант, будет ли очевидно без помощи IDE, что для того, чтобы понять, что такое Bar и какое у него назначение, вам нужно будет в справке прочитать раздел про класс Foo модуля bar. Во втором случае, вcе гораздо очевидней (класс Foo, модуль bar);

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

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


            1. flancer Автор
              25.07.2023 07:20

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

              Согласен.

              За export default существует отдельный котел в аду.

              Т.е., это какие-то внутрипроектные соглашения, но вынесенные глобальный на уровень.

              Так же, как я у себя в примере взял за основу, что любой es6-модуль по-умолчанию экспортирует фабричную функцию:

              export default async function Factory() {
                  return /* что-угодно */;
              };

              Но только не грозил никому отдельным котлом в постмортеме за их неприменение ;)

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

              const Bar = {
                  name: 'Bar'
              };
              
              export {
                  Bar as Foo
              };

              И будет ни чуть не лучше и не хуже, чем с default:


              1. SWATOPLUS
                25.07.2023 07:20

                Вот лично для меня, статья выглядит как какая-то поделка новичка. Если вы хотите раскрыть довольно сложную тему, то нужно показывать куски кода близкие к production-ready. То есть должен быть ts, мало-мальски адекватный код стаил, продуманная система типов. Про паттерны порождения объектов и di написано много статей как и на многие другие темы. А вот качественных шаблонов для той или иной задачи единицы, как раз их я и ожидал прочитав заголовок данной статьи.


                1. flancer Автор
                  25.07.2023 07:20
                  +1

                  Не согласен с вашей точкой зрения.

                  • код в туториале должен быть понятен, а не близок к production ready.

                  • Продуманная система типов в задаче уровня "Hello World!" не нужна, нужна демонстрация базовых принципов.

                  • "Качественный шаблон" для решения задачи IoC и внедрения зависимостей в ES6+ ниже, но ваша вера в Типы Скриптовы не позволит вам его увидеть:

                  export default async function (spec) {
                      return /* что-угодно */;
                  };

                  Ну и вы сами виноваты в своих неоправдавшихся ожиданиях - статья изначально называлась "Внедрение зависимостей в ES6+ 'на пальцах'". В ES6+, а не в TS. В TS, как и в Java, для этой цели зачастую используются аннотации. Ибо транспиляция позволяет.


  1. SuperCat911
    25.07.2023 07:20

    Спасибо за публикацию. Вопрос по поддержке типизации, если уместен.

    Подскажите, пожалуйста, а Вы пробрасываете типы из динамически загружаемого модуля через контейнер в другие es-модули?


    1. flancer Автор
      25.07.2023 07:20

      Я не пробрасываю типы (вернее, классы/объекты/функции), я использую их напрямую там, где в них есть необходимость. Контейнер знает, каким образом получить доступ к каждому es-модулю проекта и его содержимому (экспорту). Он может напрямую создавать требуемые объекты, без посредников. В данном примере это описано в пункте "7. Карта зависимостей" в виде переменной map, где зависимости между идентификатором зависимости и местом расположения его исходников прописано вручную.

      В реальных проектах идентификаторы зависимостей выглядят более кучеряво и, как правило, основаны на полном имени класса, который создаёт нужную для внедрения зависимость (например com.vendor.project.module.BaseClass - в Java или /Vendor/Project/Module/BaseClass в PHP). Так как идентификатор зависимости - это строка, то лишь от фантазии разработчика зависит, какие правила он использует для преобразования строки в:

      • путь к файлу с исходниками для импорта;

      • определение нужного экспорта внутри es6-модуля;

      • флаг создания нового экземпляра зависимости (transient) или передачи уже существующего (singleton);

      • флаг добавления адаптора (wrapper'а) к создаваемой зависимости;

      Зависимость вполне может выглядеть, как URL:

      @vendor/package/esmodule.mjs#exportName?singleton&scope=request

      Зависимость - это не просто тип (уровень исходного кода), это объект, существующий в runtime. Тут не столько в терминах исходного кода нужно мыслить, сколько в терминах работающей программы (те же синглтоны).

      Спасибо за вопрос, очень полезный оказался, как минимум для меня.


      1. SuperCat911
        25.07.2023 07:20

        Я прекрасно понимаю о чем Вы говорите. Спасибо за развернутый ответ.