В предыдущих публикациях (раз, два) я рассматривал возможности использования внедрения зависимостей в чистом 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-пакетах разные форматы идентификаторов зависимостей - главное, чтобы расшифровка этих идентификаторов соответствующим парсером давала ответы на вышеперечисленные вопросы, чтобы контейнер объектов мог найти соответствующий исходник и создать нужную зависимость.

Зачем я всё это написал? Я просто ищу наиболее удобный для себя способ создавать веб-приложения и считаю, что техника внедрения зависимостей очень сильно помогает как в переиспользовании своего собственного кода, так и в разработке больших приложений различными командами. Критика изложенного со стороны читателей Хабра позволяет провести ревизию подхода, посмотреть на него другими глазами, а зачастую найти и "более лучшие" решения :)

Спасибо, что дочитали.

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


  1. nin-jin
    11.08.2023 11:46
    -2

    Ещё немного и вы изобретёте вот эту наркоманию.


    1. flancer Автор
      11.08.2023 11:46

      :D (y)