Формат описания идентификатора зависимости в JS DI

af320ac1d1bd3a6dce87a607d6e6f0a2.jpg

Эта статья для тех, кто знает, что такое »внедрение зависимостей» и имеет практический опыт его использования. Меня зовут Алекс Гусев и я являюсь автором библиотеки »@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.

Препроцессинг и постпроцессинг

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

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

  1. Добавление в новый экземпляр логгера идентификатора базового объекта перед внедрением логгера. Это позволяет логгеру добавлять в сообщениях, кто именно является источником сообщения.

  2. Оборачивание результирующего объекта другим объектом для переопределения или дополнения функционала результирующего объекта. В Magento подобный функционал называется plugin/interceptor.

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

  4. Создание на базе идентификатора зависимости фабрики по производству новых экземпляров зависимости и внедрение в качестве зависимости самой фабрики.

В первом случае достаточно анализа идентификатора зависимости и выполнения дополнительных действий над внедряемым объектом. Второй случай также не влияет на возможный формат идентификатора зависимости. А вот третий (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-is

  • App_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 и/или в других языках программирования, какие у моего подхода есть плюсы-минусы и насколько интуитивно понятен предложенный формат для идентификатора зависимости. Пишите свои отзывы в комментариях, если вам интересна эта тема. Или хотя бы поучаствуйте в опросе:)

Спасибо за прочтение и отзывы.

Ретроспектива

Ретроспектива моих публикаций на эту тему, если вдруг кому-то покажется, что я »толку воду в ступе». При прочтении можно заметить, как в течение пяти лет постепенно изменялось моё понимание сущности вопроса.

© Habrahabr.ru