DI в JS: идентификаторы зависимостей

от автора

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

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

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


ссылка на оригинал статьи https://habr.com/ru/articles/754030/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *