В предыдущих публикациях (раз, два) я рассматривал возможности использования внедрения зависимостей в чистом JavaScript (без TypeScript, аннотаций и транспиляции). В данной публикации я продолжаю погружаться в вопросы использования DI в JS и более пристально рассматриваю роль идентификатора зависимости в создании объектов контейнером.
Внимание! В данной публикации я не рассказываю, как надо программировать, я лишь показываю, как можно.
Предусловия
Как уже было сказано в предыдущих публикациях, заморачиваться с инверсией контроля (и внедрением зависимостей) есть смысл только в больших проектах, состоящих из большого количества es6-модулей и большого количества npm-пакетов.
В силу исторических причин браузерные приложения собираются при помощи бандлеров, которые трансформируют исходный код (зачастую TypeScript) и которым не в напряг обработать аннотации и разрезолвить зависимости (как это сделано в Angular). Рассматриваемый мной метод внедрения зависимостей работает и в браузерных приложениях, но он явно уступает по скорости загрузки исходных модулей классическому варианту с бандлами. Если для PWA/SPA такой подход ещё можно, с некоторой натяжкой, считать допустимым (с учётом кэширования исходников на стороне браузера), то для обычных веб-страничек это явный перебор. Поэтому сказанное в посте относите, преимущественно, к серверным приложениям (nodejs).
Исходный код nodejs-приложения, как правило, состоит из npm-пакетов, которые состоят из es6-модулей, которые состоят из export'ов. "Кирпичём" ES6+ приложения является отдельный экспорт. Таким образом, идентификатор зависимости должен уметь адресовать отдельный экспорт.
Экспортом может быть класс, функция, объект, примитив. Зависимости от других экспортов могут быть (а могут и не быть) у класса или функции. Если класс или функция имеют зависимости, то они должны передаваться в виде отдельного объекта - "спецификации" (см. "2. Спецификация зависимостей"), где каждое свойство спецификации представляет собой отдельную зависимость:
export function Factory({dep1, dep2, dep3}) {}
dep1
, dep2
и dep3
- как раз и являются идентификаторами зависимостей.
Варианты описания зависимостей
Конфигурационный файл
Самым гибким, но не самым удобным способом является создание конфигурационного файла, в котором для каждой зависимости определялся бы метод её создания (XML-файл, например, является традиционным способом в Spring Framework):
<beans>
<bean id="dep1" package="..." export="...">
<!-- collaborators and configuration for this bean go here -->
</bean>
</beans>
Премуществом этого способа является то, что мы можем определять в очень широком диапазоне правила создания зависимостей контейнером. Вплоть до отказа от спецификации, как единственного параметра конструктора/фабричной функции:
// with specification
export function Factory({dep1, dep2, dep3}) {}
// without specification
export function Factory(dep1, dep2, dep3) {}
Неудобством - то, что этот файл нужно вести отдельно от самого JS-кода.
Аннотации
Я считаю аннотации вторым по гибкости способом описания зависимостей, но аннотации требуют предобработки (компиляции, транспиляции). Для интерпретируемых языков я считаю предобработку излишним шагом, а нативных декораторов в JS пока ещё нет.
Ключ в Спецификации Зависимостей
В JS ключом в объекте (именем свойства объекта) может быть строка:
const spec = {
logger,
config: config,
'Практически any text': dep,
};
Object.keys(spec); // [ 'logger', 'config', 'Практически any text' ]
В варианте 'depId': dep
мы можем описывать нужные нам зависимости практически как угодно, хоть на естественном языке (спасибо @TheShock за наводку).
Что нужно для создания зависмости?
Путь к исходникам
Допустим, что у нас совсем простая зависимость:
function Factory({logger}) {}
Если мы создаём нужный нам объект вручную при помощи фабрики, то это выглядит так:
import {logger} from '@vendor/package/src/Logger.js'
const obj = Factory({logger});
Т.е., нам как минимум нужно знать путь к es6-модулю, содержащему исходный код зависимости.
Имя экспорта
Если нужная нам зависимость - это именованный экспорт, то код для импорта выглядит так, как выше. А если зависимость экспортируется по-умолчанию, то импорт выглядит так:
import logger from '@vendor/package/src/Logger.js'
Т.е., нам нужно знать имя экспорта внутри es6-модуля (именованный или по-умолчанию).
Фабрика
В некоторых случаях экспорт может использоваться, как есть:
export default const Configuration = {...};
В других случаях экспортируется фабричная функция или класс, которые используются для создания зависимости:
// ./config.js
export default {name: 'Demo'}
// ./logger.js
export default function (msg) {}
// ./dep.js
export default function ({config}) { return ...;} // фабричная функция
// ./service.js
export default function ({logger, dep}) {}
// ./main.js
import config from './config.js';
import logger from './logger.js';
import Dep from './dep.js';
import Service from './service.js';
const dep = Dep({config}); // создаём новый объект при помощи фабричной функции
const serv = Service({logger, dep});
Если весь наш проект "инвертирован по зависимостям", то для создания любого объекта, имеющего зависимости, должна использоваться фабричная функция (или конструктор), в которую мы передаём зависимости в виде объекта спецификации (spec
). Ну вот такие у нас предусловия. Но иногда в зависимости нужно просто использовать класс, как класс, а не как фабрику для нового объекта:
// ./clazz.js
export default class Clazz {
constructor() {}
}
// ./main.js
import Clazz from './clazz.js';
import Service from './service.js';
const serv = Service({Clazz}); // класс, а не экземпляр класса!
Таким образом, нам нужно не только получить какой-то экспорт, но и знать, как его использовать - в качестве самостоятельной зависимости или в качестве фабрики для создания зависимости.
Lifestyle
Некоторые объекты в приложениях нужны в одном экземпляре (singleton), например - конфигурация приложения. Некоторые объекты создаются каждый раз заново (например, контекст какого-либо запроса).
// ./config.js
export default {}
// ./context.js
export default class Context {}
// ./service1.js & ./service2.js
export default function ({config, context}) {}
import config from './config.js';
import Context from './context.js';
import Service1 from './service1.js';
import Service2 from './service2.js';
const serv1 = Service1({config, context: new Context()});
const serv2 = Service2({config, context: new Context()});
Здесь config
является "одиночкой", а context
создаётся заново для каждого сервиса.
Декораторы
Иногда бывает, что зависимость перед внедрением нужно обернуть декоратором.
Вот сервис, который выполняет некоторую работу:
// ./service.js
export default function (req) { return req + 2;}
Вот диспетчер, который принимает в качестве зависимостей несколько сервисов (в данном случае - один) и перенаправляет запрос на соответствующий сервис:
// ./dispatcher.js
export default function ({service}) {
return function (req) {
return service(req);
};
}
А вот декоратор для сервиса, который логирует входные параметры запроса:
// ./logger.js
export default function (fn) {
return (...args) => {
console.log(args);
return fn(...args);
};
}
Создание, обёртывание и внедрение зависимости:
import logger from './logger.js';
import service from './service.js';
import Dispatcher from './dispatcher.js';
const wrapped = logger(service);
const disp = Dispatcher({service: wrapped});
Структура идентификатора
Исходя из вышесказанного, можно сделать вывод, что, если мы используем в проекте внедрение зависимостей и связываем runtime-объекты приложения через идентификаторы (а не через конфигурационный файл или аннотации), то идентификатор зависимости должен содержать следующую информацию:
путь к es6-модулю с исходным кодом зависимости
имя соответствующего экспорта внутри es6-модуля
режим создания зависимости (фабричная функция или as-is)
lifestyle (одиночка или новый экземпляр)
используемые декораторы
Я думаю, что можно придумать ещё какие-либо атрибуты, относящиеся к созданию и внедрению зависимости, но я перечислил те, которые использовал в своих проектах я сам.
Примеры идентификаторов
Я напоминаю, что идентификатор зависимости в исходном коде является инструкцией по созданию и внедрению зависимостей в режиме выполнения (в отличие от статического связывания через import
).
Nodejs style
Самый прямой способ для идентификации es6-модуля в проекте - это указать путь к нему относительно корня проекта:
node_modules/@vendor/package/src/path/to/mod.js
затем через какой-либо разделитель (например, ":") добавить имя экспорта, режим создания и lifestyle:
node_modules/@vendor/package/src/path/to/mod.js:default:factory:instance
после чего добавить список декораторов:
.../mod.js:default:factory:instance:[logger,timer,validator]
С такими идентификаторами внедрение зависимостей могло бы выглядеть так:
export default function (
{
'./src/Configuration.js:default:asis:singleton': config,
'node_modules/@vendor/package/src/Service.js:default:factory:instance:[logger]': service,
}
) {}
Этот вариант вполне себе рабочий, хотя и слишком многословный.
Кодирование имён пакетов
Аналогичный способ используется в PHP (PSR-4). Каждому npm-пакету ставим в соответствие некоторый уникальный код (namespace) и составляем карту этих соответствий:
const map = {
'@vendor/package': 'node_modules/@vendor/package/src',
'app': './src',
};
Также к namespace'у можно привязать расширение, используемое для исходников данного пакета (*.js, *.mjs, *.es6, ...). Использование подобной карты в контейнере объектов (composition root) позволяет сократить идентификаторы до такого вида:
app/Configuration:default:asis:singleton
@vendor/package/Service:default:factory:instance:[logger]
export default
Если предположить, что один es6-модуль отвечает за одну единственную задачу (SOLID), то можно оставлять в модуле один единственный экспорт - default. В таком случае большинство идентификаторов станет короче, а для именнованного экспорта придётся ввести дополнительный разделитель (например, "."):
app/Configuration:asis:singleton
@vendor/package/Service:factory:instance:[logger]
app/Utils.formatDate:asis:singleton
Способ создания и lifestyle
Поскольку способ создания (factory или as-is) и lifestyle (singleton или transient|instance) являются бинарными атрибутами, то их также можно использовать по-умолчанию, а второй вариант кодировать каким-либо символом (например, "$" и "@").
Допустим, что большинство экспортов в нашем проекте используются as-is и как одиночки (singleton). В таком случае идентификаторы могли бы выглядеть так:
app/Configuration // default:asis:singleton
@vendor/package/Service$@:[logger] // default:factory:instance:...
app/Utils.formatDate // :asis:singleton
Использование значений по умолчанию делают идентификаторы зависимостей не такими уж страшными:
function Factory(
{
'app/Configuration': config,
'@vendor/package/Service$@:[logger]': service,
'app/Utils.formatDate': formatDate,
}
) {}
Имена JS-переменных
Вот теперь пойдёт совсем ненормальное программирование, но я просто развиваю мысль в этом направлении :)
В качестве имени переменной в JS может выступать любой буквенный или цифровой символ, подчёркивание или $. Если заменить разделитель пути "/" подчёркиванием и задать, что по-умолчанию используется default-экспорт, режим создания - "as-is" и lifestyle как singleton, а также выкинуть все недопустимые символы из пути к файлу с исходником, то идентификатор может стать валидным именем JS-переменной:
function Factory(
{
App_Configuration: config,
Vendor_Package_Service: service,
}
) {}
А если теперь использовать допустимый знак "$" и модификаторы после него (режим создания: A - as-is, F - factory, lifestyle: S - singleton, I - instance), то идентфикаторы сокращаются до:
function Factory(
{
App_Helper_Price$AI: price, // as-is, instance
Vendor_Package_Web_Service$FS: service, // factory, singleton
}
) {}
Можно даже вот так:
function Factory(
{
App_Helper_Price$FS,
Vendor_Package_Web_Service$FI,
}
) {}
Наши идентификаторы зависимостей теперь могут служить и переменными для фабричной функции.
Разумеется, что таким образом можно закодировать только определённые комбинации свойств идентификатора зависимости, а для описания всех возможных свойств придётся использовать полную версию в виде строки с произвольными символами:
function Factory(
{
'Vendor_Package_Web_Service$FS[logger]': service,
'App_Utils.formatDate': formatDate,
}
) {}
Заключение
Какой бы способ внедрения зависимостей не использовался, чтобы внедрить зависимость в конструктор или фабричную функцию, разработчику (или программе-контейнеру) нужно ответить на следующие вопросы:
где находится файл с исходным кодом (es6-модуль)
какой экспорт es6-модуля использовать
использовать ли экспорт "как есть" или использовать экспорт как фабричную функцию (конструктор) для создания зависимости
внедрять новую зависимость (instance) или использовать уже имеющуюся (singleton)
нужно ли декорировать зависимость перед внедрением
Можно ответы на все эти вопросы кодировать через отдельный конфигурационный файл, через аннотации (нужна транспиляция) или "зашивать" информацию в идентификаторе зависимости. Можно использовать смешанные подходы. Можно даже использовать в разных npm-пакетах разные форматы идентификаторов зависимостей - главное, чтобы расшифровка этих идентификаторов соответствующим парсером давала ответы на вышеперечисленные вопросы, чтобы контейнер объектов мог найти соответствующий исходник и создать нужную зависимость.
Зачем я всё это написал? Я просто ищу наиболее удобный для себя способ создавать веб-приложения и считаю, что техника внедрения зависимостей очень сильно помогает как в переиспользовании своего собственного кода, так и в разработке больших приложений различными командами. Критика изложенного со стороны читателей Хабра позволяет провести ревизию подхода, посмотреть на него другими глазами, а зачастую найти и "более лучшие" решения :)
Спасибо, что дочитали.