В своём предыдущем посте я попытался объяснить, что такое "инверсия контроля" и в каких случаях использование внедрения зависимостей в 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). В карты можно добавлять условия для имплементации "интерфейсов" и для замены одних модулей другими (в особо экзотических случаях). В общем, простор для экспериментов присутствует.