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

Видно, что в обоих случаях идентификатор зависимости является строкой:

  • requirejs: 'helper/util'

  • @teqfw/di: 'App_Helper_Util'

Другими словами, “идентификатор зависимости” - это строка, содержащая информацию, которую Контейнер Объектов использует для нахождения исходного кода требуемой зависимости и создания на его основе нового объекта для последующего внедрения в конструируемый объект.

Адресация файла

Принципы адресации файла целиком взяты из Zend 1. Файлу каждого загружаемого es6-модуля (php-скрипту в Zend1) ставится в соответствие некоторая строка, в которой между разделителями (“_” - подчёркивание) отражен путь к соответствующему файлу относительно некоторой начальной точки.

Например:

App_Helper_Util => /home/alex/prj/app/src/Helper/Util.js

Т.е., если мы для адреса App примем за начальную точку каталог /home/alex/app/src, то мы сможем адресовать исходный код внутри этого каталога:

  • App_Act_User_Read

  • App_Dto_Sale_Item

  • App_Service_Sale_CreateOrder

Адресация экспорта

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

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

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

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

Комментарии (5)


  1. iliazeus
    31.07.2024 14:26
    +2

    Как я понял, в вашей библиотеке именно клиент зависимости управляет тем, как она будет создана, с помощью вот этой сложной системы идентификаторов. Но мне кажется, что от DI скорее ждут того, чтобы клиент получал уже готовую зависимость. То, как ее можно создавать - фабрикой или конструктором, например - могла бы сообщать контейнеру сама зависимость. А синглтоном она будет или нет, кажется, должно решать само приложение, тот его код и конфигурация, которые собирают все модули воедино.

    Более того, насколько вижу, у вас структура этих идентификаторов получается привязанной к пути к файлу с реализацией зависимости - а как при этом зависеть от интерфейса, а не конкретной реализации?

    В итоге, ваши примеры кажутся полностью идентичными просто обычным импортам, без каких-либо преимуществ. Мы все так же просто указываем файл с реализацией зависимости и как ее создать - просто теперь почему-то в виде отдельного DSL.


    1. flancer Автор
      31.07.2024 14:26

      Но мне кажется, что от DI скорее ждут того, чтобы клиент получал уже готовую зависимость. То, как ее можно создавать - фабрикой или конструктором, например - могла бы сообщать контейнеру сама зависимость.

      Так и есть. В es6-модуле экспортом может быть что угодно, включая класс и фабричную функцию. Будет ли этот экспорт внедрён as-is или будет внедрён результат выполнения фабричной функции (конструктора класса) - зависит от контейнера. Который конфигурируется при помощи идентификаторов зависимости в месте внедрения зависимости (могла бы сообщать контейнеру сама зависимость). Так в одном случае один и тот же экспорт должен внедряться as-is, в другом - как синглтон, в третьем - как инстанс (можно даже в пределах одного конструктора):

      export default class Service {
          constructor(
              {
                  'Ns_Module.export': asIs,
                  'Ns_Module.export$': asSingleton,
                  'Ns_Module.export$$': asInstance,
              }
          ) {}
      }

      Тут можно говорить ещё об одном контуре IoC - когда конфигурация контейнера идёт не через сам контейнер, а через клиентов, которые он обслуживает.

      Более того, насколько вижу, у вас структура этих идентификаторов получается привязанной к пути к файлу с реализацией зависимости - а как при этом зависеть от интерфейса, а не конкретной реализации?

      Вы правы, идентификатор привязывается к пути в исходнику, если исходник существует. Но в моей реализации контейнера после этапа разбора идентификатора (например, Fl32_Auth_Back_Api_Mod_User) идёт этап пред-обработки, где Контейнер может заменить в структуре идентификатора любую из его частей или все сразу:

      Если в настройках Контейнера на уровне соответствующего приложения прописать, что такой-то интерфейс должен заменяться такой-то имплементацией, то в таком-то приложении будет использоваться соответствующая имплементация вместо интерфейса.

      В саму библиотеку этот функционал не входит. На её уровне реализованы только возможности подключения цепочек препроцессоров и постпроцессоров, а правила пред- и пост-обработки уже закладываются отдельно, в зависимости от приложения и его процессоров.

      В итоге, ваши примеры кажутся полностью идентичными просто обычным импортам, без каких-либо преимуществ.

      Без пред- и пост-обработки так бы и было. Но теперь связывание выполняется в runtime, а не в коде, и правила связывания могут динамически меняться в зависимости от приложения (подключенных к Контейнеру процессоров). Например, плагин аутентификации, работающий на своём уровне с интерфейсом Fl32_Auth_Back_Api_Mod_User, в одном приложении получит одну модель пользователя, а в другом - другую. В задачи плагина аутентификации входит связывание этой модели с контекстом HTTP-запроса и его не интересует, что там за модель. Как Стетхем в "Перевозчике" - "Никогда не открывать посылку". Плагин в одном месте запрашивает у приложения модель пользователя, а в другом - предоставляет приложению возможность эту модель забрать. И один и тот же плагин может работать без изменений в разных приложениях, если они придерживаются контракта (имплементируют интерфейс).


  1. nin-jin
    31.07.2024 14:26

    Зачем столько сложностей, если всё делается куда проще?

    export class $my_app extends $my_base {
      
      do_something() {
        const user = this.$.$my_user.create()
        this.$.$acme_log_rise( 'User created with id=', user.id )
      }
      
    }


    1. qdanik
      31.07.2024 14:26
      +1

      Я зашел проверить есть ли ты тут)) Ты как всегда - хорош!


    1. flancer Автор
      31.07.2024 14:26

      "Инверсия контроля на голом TypeScript без боли" vs. "Формат описания идентификатора зависимости в JS DI"

      (TS != JS) && (DI != AmbientContext) 

      Если вы этого не понимаете, то я не смогу вам ответить на вопрос "зачем" :(