В своей прошлой статье я прикидывал, какие 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/packagePackage_Module1 => ./node_modules/package_mod1Package_Module1_Region => ./node_modules/package_mod1_reg
после чего путь к искомому модулю определяется по наибольшей длине имеющегося маппинга:
Package_Module2_Mod => ./node_modules/package/Module2/Mod.jsPackage_Module1_Sub_Mod => ./node_modules/package_mod1/Sub/Mod.jsPackage_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#fnNs_App_Plugin_Shared_Util#ClazzNs_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; // truetrn1 === 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:2import $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 APIexport 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 APIexport default function Demo_Main_Plugin_Shared(param) { return {pluginShared: param};}
Резюме
Вот он - @teqfw/di