Некоторые любят ездить велосипедах, а некоторые любят их изобретать. Я отношусь к тем, кто изобретает велосипеды, чтобы на них ездить. Пару лет назад я уже писал на Хабр про этот свой "велосипед" - контейнер внедрения зависимостей (DI container) для JavaScript. Последующее обсуждение принципов работы DI-контейнеров и их отличие от "Локатора Сервисов" достаточно сильно продвинуло меня в понимании работы моего собственного "велосипеда" и вылилось не только в ряд статей на Хабре (раз, два, три, четыре), но и в значительной доработке самого "велосипеда".
Под катом - описание работы DI-контейнера (@teqfw/di) по состоянию на
текущий момент. Ограничения: контейнер написан на чистом JavaScript
(ES2015+), работает только с ES2015+ кодом, оформленным в ES-модули
с расширением *.mjs
. Преимущества: позволяет
загружать и использовать одни и те же модули как в браузере, так и
в nodejs-приложениях без дополнительной транспиляции.
Основы работы DI-контейнеров
Типовое обращение к абстрактному контейнеру объектов выглядит примерно так:
const obj = container.get(id);
Последовательность действий контейнера:
-
Определить по id , что за объект хочет получить вызывающая сторона.
-
Проверить контейнер на предмет наличия в нём запрашиваемого объекта, если объект есть в контейнере вернуть его.
-
Если же объект нужно создавать, то по id определить, где находится файл с исходным кодом объекта и загрузить исходники.
-
Разобрать спецификацию входных зависимостей конструктора объекта (набор идентификаторов).
-
Найти в контейнере зависимости согласно спецификации или создать заново.
-
Создать запрашиваемый объект с использованием собранных зависимостей.
-
Сохранить созданный объект в контейнере для последующего использования (при необходимости).
-
Вернуть созданный объект вызывающей стороне.
Последовательность действий рекурсивная повторяется в пункте 5 для каждой зависимости, до тех пор, пока всё дерево зависимостей запрашиваемого объекта не будет создано.
ES-модуль
Прежде всего нужно понимать, что может экспортировать ES-модуль т.е., что именно может использовать DI-контейнер для создания объектов после загрузки исходного кода ES-модуля.
const obj = {name: 'Simple Object'};class Clazz { constructor(spec) { this.name = 'instance from class constructor'; }}function Factory(spec) { return {name: 'instance from factory'};}export { obj as ObjTmpl, Clazz as default, Factory,}
А это пример того, как вызывающая сторона могла бы создавать объекты при помощи кода из этого ES-модуля (вручную, без использования контейнера):
import Def from './es6.mjs';import {Factory} from './es6.mjs';import {ObjTmpl} from './es6.mjs';const spec = {}; // empty specificationconst instClass = new Def(spec);const instFact = Factory(spec);const instTmpl = Object.assign(ObjTmpl, {});
Итого, DI-контейнер, после загрузки ES-модуля, может создавать новые объекты на основе экспорта модуля:
-
используя классы;
-
используя фабричные функции;
Идентификаторы зависимостей
Идентификатор зависимости это строка, идентифицирующая объект, который ожидает получить конструктор объекта (фабричная функция) в качестве зависимости, или который DI-контейнер должен вернуть вызывающей стороне:
constructor(spec) { const dep = spec['depId'];}...await container.get('dep1');
Именованные и импортируемые
В самом простом случае разработчик может создавать объекты вручную и помещать их прямо в контейнер под произвольными идентификаторами:
import Container from '@teqfw/di';const container = new Container();container.set('dep1', {name: 'first'});container.set('dep2', {name: 'second'});const obj = await container.get('dep1');
Но в большинстве случаев нас интересует способ автоматического нахождения контейнером ES-модуля, подгрузка исходников и определение способа создания зависимости (класс, фабричная функция или шаблон-объект).
В @teqfw/di
все идентификаторы делятся на две
большие группы:
-
именованные зависимости: название начинается со строчной буквы (
connection
,conf
,i18n
, ); -
импортируемые зависимости: названия начинаются с прописной буквы (
EsModuleId
);
Именованные зависимости добавляются в контейнер вручную через
container.set(id, obj)
, для импортируемых зависимостей
есть правила сопоставления идентификаторов путям к исходникам
(рассмотрим позднее).
Модуль и экспорт модуля
В @teqfw/di
для загрузки ES-модуля используется
динамический импорт,
результатом которого является специальный объект
Module
(см. пункт Модули в Javascript: исходный код и его
отображение при отладке).
Необходимо различать, хотим ли мы использовать в качестве
зависимости модуль целиком или какой-то определённый экспорт из
данного модуля. В @teqfw/di
для этого используется
знак #
:
-
EsModuleId
: идентификатор для модуля целиком; -
EsModuleId#ExportName
: идентификатор для экспорта с именемExportName
в модулеEsModuleId
; -
EsModuleId#default
иEsModuleId#
: оба идентификатора равнозначны и указывают на экспортdefault
в модулеEsModuleId
;
Функция и результат функции
Зависимости объекта в @teqfw/di
передаются в
спецификации в конструктор или фабричную функцию:
class Clazz { constructor(spec) {}}function Factory(spec) {}
Как различить случай, когда мы хотим получить от контейнера сам
класс (функцию), а когда экземпляр объекта данного класса
(результат работы функции)? В идентификаторе зависимости для
@teqfw/di
это отражается при помощи символа
$
:
-
EsModuleId#ExportName
: получить объект (класс, функцию) с именемExportName
из модуляEsModuleId
. -
EsModuleId#ExportName$
: получить объект, созданный при помощи конструктора (фабричной функции), являющегося экспортомExportName
модуляEsModuleId
.
Для создания объекта из default
-экспорта ES-модуля
нижеприведенные идентификаторы равнозначны:
-
EsModuleId#default$
-
EsModuleId#$
-
EsModuleId$
Singleton и новый экземпляр
Иногда контейнер должен использовать один и тот же экземпляр в
качестве зависимости для всех объектов приложения (или некоторых),
а иногда каждый раз должен создаваться новый экземпляр объекта. В
идентификаторах зависимости это отражается через удвоение символа
"доллар" -$$
:
-
EsModuleId$
иEsModuleId#ExportName$
: объект создается один раз (при первом запросе) и сохраняется в контейнере, для всех последующих запросов используется ранее сохраненный объект. -
EsModuleId$$
иEsModuleId#ExportName$$
: каждый раз создается новый экземпляр объекта.
При этом неважно, какой из объектов первым запросил создание singletonа все остальные получат этот же экземпляр. В некотором смысле любой DI-контейнер является global-объектом. Кто-то может сказать, что это антипаттерн, и отказаться от использования DI его полное право.
Сводная таблица идентификаторов
Итого в @teqfw/di
используются следующие
идентификаторы зависимостей:
let id1 = 'named'; // named singleton been added manuallylet id2 = 'EsModId'; // ES modulelet id3 = 'EsModId#'; // default export of ES modulelet id4 = 'EsModId#name'; // named export of ES modulelet id5 = 'EsModId$'; // singleton from default exportlet id6 = 'EsModId$$'; // new instance from default exportlet id7 = 'EsModId#name$'; // singleton from named exportlet id8 = 'EsModId#name$$'; // new instance from named export
Декларация зависимостей
Вся мощь контейнера раскрывается тогда, когда мы описываем
зависимости, необходимые для создания объекта, в конструкторе
(фабричной функции). В @teqfw/di
это делается так:
constructor(spec) { const named = spec['namedSingleton']; const inst = spec['EsModId#name$$']; const single = spec['EsModId$'];}
Особенностью @teqfw/di
является то, что контейнер
прерывает процесс создания запрошенного объекта, если обнаруживает
неизвестную зависимость, подгружает исходники зависимости и создает
зависимость, после чего вновь пытается создать запрошенный объект.
Таким образом, первые строки конструктора запрошенного объекта
могут выполняться несколько раз, если в процессе приходилось
несколько раз прерывать процесс и подгружать нужные исходники.
Загрузка исходников
Чтобы контейнер по идентификатору зависимости мог обнаружить
файл с исходным кодом соответствующего ES-модуля, нужна карта
сопоставления идентификаторов зависимостей файлам с исходниками. В
@teqfw/di
добавлением позиций в карту делается
так:
container.addSourceMapping('EsModId', './relative/path');container.addSourceMapping('EsModId', '/absolute/path', true);
Первый способ (с относительной адресацией) в основном применяется, если контейнер используется в браузере, второй в nodejs-приложениях. Тем не менее, оба способа могут применяться в обеих средах.
В карте сопоставления, используемой контейнером, прописывается
корневой каталог с исходниками, дальнейшее сопоставление
идентификаторов исходникам идет через использование namespaceов, где
разделителем имен каталогов является _
:
EsModId_PathTo_Mod => /absolute/path/PathTo/Mod.mjs
Резюме
DI-контейнер @teqfw/di позволяет использовать в качестве зависимостей как сами ES-модули, так и отдельные элементы из экспорта ES-модулей, а также создавать новые экземпляры объектов или использовать один единственный объект для всего приложения. Причем один и тот же код может использоваться как в браузерах, так и в nodejs-приложениях.
Типовой код для ES-модуля, используемого в
@teqfw/di
:
export default class Mod { constructor(spec) { const Clazz = spec['Lib_Dep#']; const single = spec['Lib_Dep$']; const inst = spec['Lib_Dep$$']; // ... }}
Обычный import
также можно использовать в коде, но
в таком случае код теряет возможность быть использованным
одновременно и в браузерах, и в nodejs-приложениях, т.к. браузерный
формат import'а не совместим с
nodejs-форматом.