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