Формат описания идентификатора зависимости в JS DI
Эта статья для тех, кто знает, что такое »внедрение зависимостей» и имеет практический опыт его использования. Меня зовут Алекс Гусев и я являюсь автором библиотеки »@teqfw/di». Цель моей библиотеки — дать возможность использовать функционал »внедрение зависимостей через конструктор» в проектах на JS (фронт и бэк) и TS (бэк). Минимальной единицей внедрения является отдельный экспорт es6-модуля. Поэтому библиотека не может использоваться с модулями CJS или UMD.
В основу внедрения зависимостей заложена идея о том, что вместо статического связывания исходного кода на этапе написания (через import
) применяется динамическое связывание объектов программы в режиме выполнения. В моей библиотеке это достигается за счёт размещения в коде конструкторов (или фабричных функций) инструкций по созданию нужных им зависимостей, которые интерпретируются Контейнером Объектов при работе программы и на основании которых загружаются нужные исходники и создаются нужные зависимости.
В этой статье я сформулировал правила для создания этих инструкций и хотел бы узнать у сообщества, насколько эти правила интуитивно понятны и покрывают ли все варианты использования или я что-то упустил.
Сущность идентификатора зависимости
Функционал библиотеки @teqfw/di
сделан по образу и подобию RequireJS, но с применением концепции »пространства имён» из Zend 1 (My_Component_ClassName
). Так, в requirejs
требуемые зависимости описывались в таком виде:
requirejs(['helper/util'], function (util) {
// use the 'util' dep
});
В @teqfw/di
аналогичный код выглядит так:
function ({'App_Helper_Util': util}) {
// use the 'util' dep
}
Видно, что в обоих случаях идентификатор зависимости является строкой:
Другими словами,»идентификатор зависимости» — это строка, содержащая информацию, которую Контейнер Объектов использует для нахождения исходного кода требуемой зависимости и создания на его основе нового объекта для последующего внедрения в конструируемый объект.
Адресация файла
Принципы адресации файла целиком взяты из Zend 1. Файлу каждого загружаемого es6-модуля (php-скрипту в Zend1) ставится в соответствие некоторая строка, в которой между разделителями (»_» — подчёркивание) отражен путь к соответствующему файлу относительно некоторой начальной точки.
Например:
App_Helper_Util => /home/alex/prj/app/src/Helper/Util.js
Т.е., если мы для адреса App
примем за начальную точку каталог /home/alex/app/src
, то мы сможем адресовать исходный код внутри этого каталога:
Адресация экспорта
Как я уже отметил выше, @teqfw/di
работает только с es6-модулями, которые описывают доступные другим модулям объекты через es6 export. Requirejs и Zend1 не сталкивались с es6 export, поэтому дальше по аналогии не получается. Но если думать логически, то нужен какой-то дополнительный разделитель, чтобы отличать элементы пути к файлу (каталоги и имя файла) от имени экспорта в этом файле:
App_Helper_Util.format
App_Helper_Util#format
App_Helper_Util/format
Тип импорта
В своей практике я сталкивался с такими видами зависимостей:
зависимость от es-модуля целиком
зависимость от отдельного экспорта в es-модуле
зависимость от результата выполнения отдельного экспорта в es-модуле
Продемонстрирую это на примерах:
// ./Lib.js
export class Service {}
Зависимость от es6-модуля целиком:
import * as Lib from './Lib.js';
const Service = Lib.Service;
Зависимость от отдельного экспорта:
import {Service} from './Lib.js';
const MyService = Service;
Зависимость от результата выполнения отдельного экспорта:
import {Service} from './Lib.js';
const service = new Service();
Таким образом, в идентификаторе зависимости нужно не только отобразить путь к отдельному es6-модулю и имя экспорта внутри модуля, но и то, в каком виде использовать этот экспорт — as-is или в качестве фабрики для создания зависимости.
Я уже касался этой темы год назад и предлагал кодировать тип специальными символами в идентификаторе зависимости:
App_Helper_Util.format$A - as-is
App_Helper_Util.format$F - фабрика
Но мой опыт за прошедший год показал, что подобное кодирование типа импорта не пользуется популярностью даже в моём собственном коде.
Одиночка и Экземпляр
Всё потому, что я предпочитаю разбивать свой код на две большие группы: данные и функции. Данные (DTO) описывают структуру обрабатываемой информации, а функции, соответственно, эту информацию обрабатывают. Если создавать код для обработчиков в функциональном стиле, то значительная часть runtime-объектов в приложении будет одиночками/singletons (включая фабрики для создания экземпляров DTO). Другими словами, 80% моих внедряемых зависимостей — это одиночки (singletons), и только 20% — это отдельные экземпляры (instances) и as-is (80/20 — это не точно, на глаз).
Типовой код внедряемой зависимости в моих приложениях примерно такой:
export default class App_Service_Auth {
constructor({App_Act_User_Read$: actRead}) {
// ...
}
}
Каждый es6-модуль в большинстве случаев (80%) использует один-единственный default экспорт.
Контейнер объектов в большинстве случаев (80%) создаёт один-единственный экземпляр этого объекта (функциональный стиль!) и раздаёт его в качестве зависимости всем нуждающимся.
Правила создания идентификаторов объектов в JS разрешают использовать без кавычек только »буквенно-цифровые символы, подчёркивание и знак $». Поэтому для наиболее частого варианта описания зависимости я хочу применять описание без кавычек.
Можно примерно так раскрыть Контейнеру Объектов суть инструкций наиболее частой формы идентификатора:
App_Service_Auth$
— возьми default-экспорт из скрипта ».../src/Service/Auth.js
», используй его для создания объекта (внедри в него все зависимости, если они там есть), сохрани этот объект как singleton у себя в памяти и внедряй его во все остальные объекты, где он понадобится.
Для описания того, что я хочу в качестве зависимости получить новый экземпляр объекта, я использую сдвоенный знак $$
, но вот этот вариант встречается даже реже, чем просто импорт всего es6-модуля или использование отдельного именованного экспорта as-is.
Препроцессинг и постпроцессинг
Как правило, внедрение зависимостей предполагает возможность конфигурации Контейнера Объектов на пред-обработку входных данных (идентификатор зависимости) и пост-обработку выходных данных (самой внедряемой зависимости). Пред-обработка идентификатора зависимости предполагает (в большинстве случаев с которыми я сталкивался) замену одного идентификатора другим. Например, именно таким образом происходит замена интерфейсов их имплементациями.
Но на формат идентификатора зависимости влияет, скорее, пост-обработка. Необходимость пост-обработки в в моей практике встречалась как минимум в четырёх вариантах:
Добавление в новый экземпляр логгера идентификатора базового объекта перед внедрением логгера. Это позволяет логгеру добавлять в сообщениях, кто именно является источником сообщения.
Оборачивание результирующего объекта другим объектом для переопределения или дополнения функционала результирующего объекта. В Magento подобный функционал называется plugin/interceptor.
Создание на базе идентификатора зависимости прокси-объекта, который создаёт и возвращает нужную зависимость не в конструкторе или фабричной функции, а при обращении к прокси-объекту. Подобный функционал позволяет разрывать кольцевые зависимости в конструкторах.
Создание на базе идентификатора зависимости фабрики по производству новых экземпляров зависимости и внедрение в качестве зависимости самой фабрики.
В первом случае достаточно анализа идентификатора зависимости и выполнения дополнительных действий над внедряемым объектом. Второй случай также не влияет на возможный формат идентификатора зависимости. А вот третий (interceptor) и четвёртый (factory) случаи, по сути, однотипны и имеют влияние на формат. Ведь получается, что вместо того, чтобы вернуть зависимость, указанную в идентификаторе, Контейнер Объектов возвращает другой объект, который в какой-то мере зависит от объекта, указанного в идентификаторе. Возможное решение — указать типы пост-обработчиков в виде массива:
App_User_Auth$(proxy,factory)
— зависимость от прокси-объекта, который при первом обращении к нему вернёт фабрику, которая может создавать объекты типа App_User_Auth.
Это не очень популярный сценарий, но он также должен быть учтён при выборе формата идентификатора зависимости.
Структура идентификатора зависимости
Таким образом, в строке идентификатора зависимости должна быть закодирована следующая информация:
moduleName — путь к файлу с исходным кодом (es6-модулю).
exportName — имя экспорта, который должен быть использован для создания зависимости.
composition — использовать экспорт as-is для внедрения или как конструктор/фабрику.
life — определяет жизненный стиль внедряемой зависимости (singleton или instance).
wrappers — список декораторов для пост-обработки.
Наиболее частый случай, когда внедряется синглтон, созданный из default-экспорта какого-либо es6-модуля — App_Service_User$
. Этот идентификатор можно писать без кавычек:
export default class App_Main {
constructor(
{
App_Service_User$: srvUser, // singleton, factory, default export
}
) {}
}
Самый универсальный идентификатор — внедрение es6-модуля целиком (App_Service_User
):
export default class App_Main {
constructor(
{
App_Service_User: ServiceUser, // es6 module as-is
}
) {
// import {create, read, update, drop} from './Service/User.js';
const {create, read, update, drop} = ServiceUser;
}
}
Можно ещё использовать сдвоенный $$
для обозначения, что нужно внедрять не синглтон, а новый экземпляр, созданный из default-экспорта соответствующего модуля:
App_Logger$$: logger
И на этом хорошие возможности использования идентификаторов без кавычек исчерпаны.
Чтобы можно было указывать в идентификаторе зависимости именованный экспорт, нужно ещё один разделитель к »_» (каталоги на пути к файлу с исходным кодом) и »$» (singleton or instance), но правила наименования в JS больше разделителей без кавычек не предусматривают.
В качестве разделителя имени экспорта от пути к es6-модуля я выбрал точку (».») и получил такие варианты для описания зависимостей (все уже с кавычками):
'App_Service_User.create'
— использовать именованный экспорт as-is.'App_Service_User.create$'
— использовать именованный экспорт в качестве фабричной функции для создания и внедрения синглтона.'App_Service_User.create$$'
— использовать именованный экспорт в качестве фабричной функции для создания и внедрения нового экземпляра.
Применяя декораторы постобработки можно получить вот такие экзотические экземпляры идентификаторов зависимостей:
export default class App_Main {
constructor(
{
'App_Service_User.create$$(proxy,factory)': factoryServiceUserCreate
}
) { }
}
Возникает некоторая неловкость при конструировании идентификатора зависимости, который указывает, что в каком-то es6-модуле нужно использовать default-экспорт как он есть (as-is). Если брать точку в качестве разделителя, то получается визуально не очень выразительно:
'App_Service_User.': user
Либо же нужно использовать более длинный вариант:
'App_Service_User.default': user
Предлагаемый формат идентификатора зависимости
В моей библиотеке возможно использовать разные форматы идентификатора зависимости. Жёстко задана только структура идентификатора (TeqFw_Di_DepId), а упаковка этой информации в строку идентификатора может происходить по разным правилам (разные разделители, порядок следования частей и т.п.).
За разбор строки идентификатора и воссоздание структуры идентификатора отвечает объект TeqFw_Di_Container_Parser, который является набором парсеров и может применять различные схемы декодирования идентификатора (каждый парсер в наборе должен имплементировать интерфейс TeqFw_Di_Api_Container_Parser_Chunk).
Я думаю, что подобный подход является излишним в условиях стабильности, но в условиях, когда у меня правила составления идентификатора слегка менялись от проекта к проекту он вполне оправдан. Это позволило мне использовать одну и ту же библиотеку с разными форматами кодирования идентификаторов зависимости.
На данный момент у меня довольно хорошо сложилось представление о своих ожиданиях от идентификатора и я хочу в качестве default-формата заложить вот такой:
App_Service
— es6-модуль as-is'App_Service.default'
или'App_Service.'
— default-экспорт as-is'App_Service.name'
— именованный сервис as-isApp_Service$
,'App_Service.default$'
— создание синглтон-объекта из default-экспорта'App_Service.name$'
— создание синглтон-объекта из именованного экспортаApp_Service$$
,'App_Service.default$$'
— создание экземпляра объекта из default-экспорта'App_Service.name$$'
— создание экземпляра объекта из именованного экспорта'…(proxy,factory)'
— добавление на этапе постобработки декораторов к внедряемому объекту (имена декораторов определяются в пост-обработчиках, применяемых приложением)
В большинстве случаев будет использоваться вариант App_Service$
(синглтон), вариант App_Service
позволяет внедрить es6-модуль целиком, аналогично статическому импорту, но динамически (вот здесь и включается имплементация интерфейсов через пред-обработку путём замены интерфейсов типа Plugin_Api_IService
на их имплементацию App_Service_Impl
в конфигурации Контейнера Объектов). Остальное — по мере необходимости.
Хотелось бы узнать у коллег, имеющих опыт работы с IoC в JS/TS и/или в других языках программирования, какие у моего подхода есть плюсы-минусы и насколько интуитивно понятен предложенный формат для идентификатора зависимости. Пишите свои отзывы в комментариях, если вам интересна эта тема. Или хотя бы поучаствуйте в опросе:)
Спасибо за прочтение и отзывы.
Ретроспектива
Ретроспектива моих публикаций на эту тему, если вдруг кому-то покажется, что я »толку воду в ступе
». При прочтении можно заметить, как в течение пяти лет постепенно изменялось моё понимание сущности вопроса.