В своей прошлой статье я прикидывал, какие namespace'ы мне нужны для упорядочивания кода в ES6-модулях. В этой статье я описываю, какие namespace'ы у меня получились и как их использовать при порождении объектов и разрешении зависимостей (dependency injection).
Для чего нужны namespace'ы
Для тех, кто не видит важность namespace'ов, могу привести в пример почтовые адреса:
страна / область / город / улица / дом / квартира
или адресацию в файловой системе:
/var/lib/dpkg/status
В общем, namespace'ы нужны для упорядочивания большого количества однотипных элементов: жителей планеты, файлов на диске, доменов в интернете, кода в крупных приложениях. Если вы не согласны с этим утверждением, лучше дальше не читайте - вы точно не поймёте, зачем вам все эти навороты, потеряете время и испортите себе настроение.
Дуальный характер JavaScript'а
Отличие JavaScript'а от остальных языков программирования в том, что JS-программы исполняются в двух очень различных средах:
браузер: с ориентацией преимущественно на сетевые ресурсы;
сервер: с ориентацией преимущественно на файловые ресурсы;
Один и тот же ES6-модуль может быть адресован в абсолютной нотации таким образом (браузер и сервер):
http://demo.com/node_modules/@vendor/package/src/module.mjs
/home/alex/demo/node_modules/@vendor/package/src/module.mjs
Текущая проблема адресации ES6-модулей
В nodejs-приложениях относительная адресация элементов кода идёт относительно каталога ./node_modules/
:
import ModuleDefault from '@vendor/module/src/Module.mjs';
а в браузерных приложениях такая адресация приводит к ошибке:
Uncaught (in promise) TypeError: Failed to resolve module specifier "@vendor/module/src/Module.mjs". Relative references must start with either "/", "./", or "../".
Если же изменить адрес на ./@vendor/module/src/Module.mjs
, как того требует браузерная среда, то в nodejs-приложениях получим ошибку:
internal/process/esm_loader.js:74
internalBinding('errors').triggerUncaughtException(
^
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/.../@vendor/module/src/Module.mjs' imported from /.../test.mjs
at finalizeResolution (internal/modules/esm/resolve.js:276:11)
at moduleResolve (internal/modules/esm/resolve.js:699:10)
Это приводит к тому, что мы не имеем возможности использовать один и тот же код со статическим оператором import
, ссылающийся на модули в сторонних пакетах, одновременно и для браузерных приложений, и для nodejs-приложений.
Подробнее
Т.е., если у нас есть модуль ClassA.mjs
в пакете packageA
:
export default class ClassA {}
И есть модуль ClassB.mjs
в пакете packageB
, в котором мы импортируем содержимое из модуля packageA/ClassA.mjs
:
import ClassA from 'packageA/ClassA.mjs'
export default class ClassB {}
то мы не сможем выполнить этот код в браузере, а код:
import ClassA from './packageA/ClassA.mjs'
export default class ClassB {}
в nodejs-приложении.
Теоретически, мы сможем и там, и там выполнить код:
import ClassA from '/packageA/ClassA.mjs'
export default class ClassB {}
но для такого варианта мы должны будем выложить все npm-пакеты в корень файловой системы сервера, на котором выполняется nodejs-приложение.
Адресация ES6-модуля на основе путей
В предыдущей статье я приводил основание для преобразования путей к модулям из вида:
@vendor/package1/src/module1.js
к виду:
@vendor/package1/module1
У этого преобразования есть существенный недостаток - адрес содержит разделитель "/", который не может присутствовать (без экранирования) в идентификаторах JS-кода (именах переменных, функций, классов):
const obj = {name, version, Path/Like/Id}
Независимая адресация ES6-модуля
В PHP namespace'ы появились с версии 5.3, а до этого программисты использовали подход, применяемый, например, в Zend1 (old style):
class My_Long_NestedComponent_ClassName {}
Этот подход неплохо зарекомендовал себя в PHP (до появления встроенных в язык namespace'ов), поэтому его вполне можно перенести на JS, используя при именовании пространств символы, разрешённые для идентификаторов JS-кода:
const obj = {name, version, Zend_Like_Id}
К тому же у него есть дополнительный плюс по отношению к адресации на основе путей.
Имя простого npm-пакета (package
) не может содержать разделителей (/
), максимум, что можно - поместить пакет в scope (@vendor/package
). Если в процессе разработки наш пакет разросся и мы захотим разделить его на несколько пакетов, то нам придётся переделывать адреса для всех элементов кода:
package/module1/region/area/path/to/module // old 'package'
package_module1/region/area/path/to/module // new 'package_module1'
package_module1_region/area/path/to/module // new 'package_module1_region'
Адресация в стиле Zend1 лишена этого недостатка. Нам нужно подставить правила маппинга для соответствующих узлов идентификатора:
Package => ./node_modules/package
Package_Module1 => ./node_modules/package_mod1
Package_Module1_Region => ./node_modules/package_mod1_reg
после чего путь к искомому модулю определяется по наибольшей длине имеющегося маппинга:
Package_Module2_Mod => ./node_modules/package/Module2/Mod.js
Package_Module1_Sub_Mod => ./node_modules/package_mod1/Sub/Mod.js
Package_Module1_Region_Area => ./node_modules/package_mod1_reg/Area.js
В общем, решение, проверенное временем, и гораздо лучше адресации на основе путей.
Адресация кода внутри ES6-модуля
ES6-модуль предоставляет доступ к своему содержимому через инструкцию export
. Допустим, что у нас есть пространство Ns_App_Plugin
c маппингом на путь ./node_modules/@vendor/app_plugin/src
, в котором определён ES6-модуль ./Shared/Util.mjs
с таким экспортом:
export function fn() {};
export class Clazz {};
export default {fn, Clazz};
Используя namespace с независимой от пути адресацией, можно адресовать сам модуль как:
Ns_App_Plugin_Shared_Util
а используя символ "#" (по аналогии с подобным символом в URL), можно адресовать соответствующий экспорт внутри модуля:
Ns_App_Plugin_Shared_Util#fn
Ns_App_Plugin_Shared_Util#Clazz
Ns_App_Plugin_Shared_Util#default
Символ "#" так же, как и символ "/", не может использоваться в идентификаторах JS-кода без экранирования. Вместо него можно было бы использовать символ "$", но я его приберёг для другого (об этом чуть ниже).
Default export
В Java каждый класс располагается в отдельном java-файле. В PHP можно в одном php-файле можно разместить несколько классов, но, как правило, придерживаются аналогичного подхода: один класс - один файл.
В ES6-модуле также можно размещать один элемент кода (класс, функция, объект) на файл и экспортировать его по-умолчанию:
export default function Ns_App_Plugin_Shared_Util() {}
Если придерживаться этого правила - "один файл - один экспорт", то у нас пропадает необходимость в использовании символа "#". В зависимости от контекста адрес Ns_App_Plugin_Shared_Util
может означать как ES6-модуль, так и default экспорт из этого модуля.
Порождение объектов
В своём приложении мы, как правило, оперируем элементами кода не на уровне модулей, а на уровне экспорта этих модулей. Т.е., нам в коде нужны не сами модули, а объекты, функции и классы, экспортируемые модулями. Иногда нам в приложении нужен один единственный экземпляр какого-то объекта (конфигурация приложения), а в некоторых случаях нам нужно создавать новый объект при помощи функции или класса. Первый вариант называется одиночкой, а второй - фабрика (различие).
Допустим, у нас в приложении есть контейнер, в который мы можем помещать объекты двух типов. Пусть одиночки в контейнере у нас адресуются строкой, начинающейся с прописной буквы (dbConfig
), а фабрики - такой же строкой, только с двумя знаками доллара на конце - dbTransaction$$
. Тогда запросы к контейнеру могли бы выглядеть так:
const cfg1 = container.get('dbConfig');
const cfg2 = container.get('dbConfig');
const trn1 = container.get('dbTransaction$$');
const trn2 = container.get('dbTransaction$$');
cfg === cfg2; // true
trn1 === trn2; // false
Дпустим, что у нас имена модулей начинаются с заглавной буквы (чтобы отличать идентификатор объекта в контейнере от идентификатора модуля). Тогда мы могли бы таким образом идентифицировать объекты, поставляемые контейнером:
const singleton = container.get('dbConfig');
const newInstance = container.get('dbTransaction$$');
const module = container.get('Ns_App_Plugin_Shared_Util');
const defExport = container.get('Ns_App_Plugin_Shared_Util#');
const defExportSingleton = container.get('Ns_App_Plugin_Shared_Util$');
const defExportNewInstance = container.get('Ns_App_Plugin_Shared_Util$$');
Dependency Injection
Пространство имён с независимой от файловой структуры адресацией позволяет отвязать логическую структуру кода от его файлового представления и перейти от импорта файлов к декларации зависимостей.
Чуть больше года назад я уже делал реализацию DI-контейнера и даже опубликовал его описание на Хабре. Но на тот момент я не понимал значения ES6-модуля как элемента JS-кода. Такое понимание пришло только при написании статьи "Javascript: исходный код и его отображение при отладке" (это понимание и есть практическая ценность статьи, о которой интересовался коллега @Zenitchik). Предыдущая реализация моего контейнера позволяла вставлять только одиночные зависимости и зависимости на основе фабрик (вновь порождённые объекты). В текущей реализации контейнер позволяет получать доступ к следующими элементам:
singleton, добавленный в контейнер вручную:
dbConfig
;новый объект, созданный при помощи фабрики, добавленной в контейнер вручную:
dbTransaction$$
;ES6-модуль:
Ns_App_Module
;отдельный экспорт:
Ns_App_Module#name
(именованный) иNs_App_Module#
(default);singleton, созданный из default-экспорта модуля:
Ns_App_Module$
;singleton, созданный из именованного экспорта модуля:
Ns_App_Module#name$
;новый объект, созданный при помощи фабрики, полученной из default-экспорта модуля:
Ns_App_Module$$
;новый объект, созданный при помощи фабрики, полученной из именованного экспорта модуля:
Ns_App_Module#name$$
;
Таким образом, на данным момент я могу использовать в своих JS-приложениях namespace'ы, аналогичные тем, которые есть в PHP и Java, а типовой модуль моего приложения выглядит примерно так:
export default function Ns_App_Plugin_Fn(spec) {
const singleton = spec.dbConfig;
const newInstance = spec.dbTransaction$$;
const module = spec.Ns_App_Plugin_Shared_Util;
const defExport = spec['Ns_App_Plugin_Shared_Util#'];
const defExportSingleton = spec.Ns_App_Plugin_Shared_Util$;
const defExportNewInstance = spec.Ns_App_Plugin_Shared_Util$$;
// ...
}
или так:
export default class Ns_App_Plugin_Class {
constructor(spec) {
const singleton = spec.dbConfig;
// ...
}
}
И этот код может использоваться без изменений как в nodejs, так и в браузере.
Недостатки
Наследование
При данном подходе не получается использовать наследование:
export default class Ns_App_Plugin_Mod extends Ns_App_Plugin_Base {}
вместо этого приходится использовать композицию, что для моих задач считаю приемлемым.
Расширение
Не всегда получается загрузить ES6-модули в виде файлов с расширением *.js
. Появляется ошибка:
(node:506150) Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension.
/home/alex/.../src/Server.js:2
import $path from 'path';
^^^^^^
Для стабильности лучше размещать все ES6-модули в файлах с расширением *.mjs
. В этом случае загрузчик принудительно использует схему ES6.
Демо
Я подготовил небольшой демо-проект, в котором есть два модуля:
@flancer64/demo_teqfw_di_mod_main
@flancer64/demo_teqfw_di_mod_plugin
В каждом из которых по три скрипта:
./src/Front.mjs
./src/Server.mjs
./src/Shared.mjs
Front-скрипты и server-скрипты используют shared-скрипты, а скрипты main-модуля используют скрипты plugin-модуля.
В README проекта расписаны обе стороны, а здесь даю краткое описание взаимодействия элементов со стороны фронта.
Загрузка контейнера, его настройка, получение из контейнера объекта для запуска на фронте:
<script type="module">
const baseUrl = location.href;
// load DI container as ES6 module (w/o namespaces)
import(baseUrl + 'node_modules/@teqfw/di/src/Container.mjs').then(async (modContainer) => {
// init container and setup namespaces mapping
/** @type {TeqFw_Di_Container} */
const container = new modContainer.default();
const pathMain = baseUrl + 'node_modules/@flancer64/demo_teqfw_di_mod_main/src';
const pathPlugin = baseUrl + 'node_modules/@flancer64/demo_teqfw_di_mod_plugin/src';
container.addSourceMapping('Demo_Main', pathMain, true, 'mjs');
container.addSourceMapping('Demo_Main_Plugin', pathPlugin, true, 'mjs');
// get main front as singleton
/** @type {Demo_Main_Front} */
const frontMain = await container.get('Demo_Main_Front$');
frontMain.out('#main', '#plugin');
});
</script>
Код основного фронт-объекта с подтягиванием зависимостей в конструкторе через spec
объект:
// frontend code cannot use traditional npm packages but can use browser API
export default class Demo_Main_Front {
/** @type {Demo_Main_Shared} */
singleMainShared
/** @type {Demo_Main_Plugin_Front} */
instPluginFront
constructor(spec) {
// get default export as singleton (class)
this.singleMainShared = spec.Demo_Main_Shared$;
// get default export as new instance (class)
this.instPluginFront = spec.Demo_Main_Plugin_Front$$;
}
//...
}
Код shared-объекта из plugin-пакета:
// shared code cannot use traditional npm packages and cannot use browser API
export default function Demo_Main_Plugin_Shared(param) {
return {pluginShared: param};
}
Резюме
Вот он - @teqfw/di