В своей прошлой статье я прикидывал, какие 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