Как стать автором
Обновить

Dependency Injection, JavaScript и ES6-модули

Время на прочтение9 мин
Количество просмотров37K

Ещё одна имплементация Dependency Injection в JavaScript — с ES6-модулями, с возможностью использовать один и тот же код в браузере, и в nodejs и не использовать транспиляторы.


image


Под катом — мой взгляд на DI, его место в современных web-приложениях, принципиальная реализация DI-контейнера, способного создавать объекты и на фронте, и на бэке, а также объяснение, при чём тут Майкл Джексон.


Очень сильно прошу тех, кому изложенное в статье покажется банальным, не насиловать себя и не читать до конца, чтобы потом, разочаровавшись, не ставить "минус". Я не против "минусов" — но только если минус сопровождается комментарием, что именно в публикации вызвало отрицательную реакцию. Это техническая статья, поэтому постарайтесь отнестись снисходительно к стилю изложения, а критиковать именно техническую составляющую изложенного. Спасибо.


Объекты в приложении


Я очень уважаю функциональное программирование, но большую часть своей профессиональной деятельности я посвятил созданию приложений, состоящих из объектов. JavaScript мне импонирует тем, что функции в нём также являются объектами. При создании приложений я мыслю объектами, это моя профессиональная деформация.


По времени жизни объекты в приложении можно разделить на следующие категории:


  • постоянные — возникают на каком-то этапе работы приложения и уничтожаются только при завершении приложения;
  • временные — возникают при необходимости выполнения некоторой операции и уничтожаются при завершении выполнения этой операции;

В связи с этим в программировании есть такие шаблоны проектирования, как:



Т.е., с моей точки зрения, приложение состоит из постоянно-существующих одиночек, которые либо сами выполняют требуемые операции, либо для их выполнения порождают временные объекты.


Контейнер Объектов


Внедрение зависимостей — это подход, который облегчает создание объектов в приложении. Т.е., в приложении существует специальный объект, который "знает", каким образом создавать все остальные объекты. Такой объект называется Контейнер Объектов (иногда — Менеджер Объектов).


Контейнер Объектов не является Божественным Объектом, т.к. его задачей является только создание значимых объектов приложения и предоставление доступа к ним другим объектам. Подавляющее большинство объектов приложения, будучи порождёнными Контейнером и размещаясь в нём, никакого представления о самом Контейнере не имеют. Их можно поместить в любую другую среду, снабдить необходимыми зависимостями и они будут также замечательно функционировать и там (тестировщики в курсе, о чём я).


Место внедрения


По большому счёту есть два способа внедрить зависимости в объект:


  • через конструктор;
  • через свойство (или его акцессор);

Я, в основном, использовал первый подход, поэтому дальнейшее описание я буду вести с точки зрения внедрения зависимостей через конструктор.


Допустим, что у нас есть приложение, состоящее из трёх объектов:


image


В PHP (этот язык с давними традициями DI у меня в данный момент находится в активном багаже, к JS я перейду чуть позже) подобная ситуация могла бы быть отражена таким образом:


class Config
{
    public function __construct()
    {
    }
}

class Service
{
    private $config;

    public function __construct(Config $config)
    {
        $this->config = $config;
    }
}

class Application
{
    private $config;
    private $service;

    public function __construct(Config $config, Service $service)
    {
        $this->config = $config;
        $this->service = $service;
    }
}

Этой информации должно хватать, чтобы DI-контейнер (например, league/container) при соответствующей настройке смог по запросу на создание объекта Application также создать его зависимости Service и Config и передать их параметрами в конструктор объекта Application.


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


Каким же образом Контейнер объектов понимает, что конструктору объекта Application требуются два объекта Config и Service? Путём анализа объекта через Reflection API (Java, PHP) или через анализ непосредственно кода объекта (аннотаций к коду). То есть, в общем случае, мы можем определить имена переменных, которые ожидает увидеть на входе конструктор объекта, а если язык типизируемый, то можем получить также и типы этих переменных.


Таким образом, в качестве идентификаторов объектов Контейнер может оперировать либо именами входных параметров конструктора, либо типами входных параметров.


Создание объектов


Объект может быть в явном виде создан программистом и помещён в Контейнер под соответствующим идентификатором (например, "configuration")


/** @var \League\Container\Container $container */
$container->add("configuration", $config);

а может быть создан Контейнером по некоторым определённым правилам. Эти правила, по большому счёту, сводятся к сопоставлению идентификатора объекта его коду. Правила можно задавать явно (маппинг в виде кода, XML, JSON, ...)


[
  ["object_id_1", "/path/to/source1.php"],
  ["object_id_2", "/path/to/source2.php"],
  ...
]

или в виде некоторого алгоритма:


public function getSource($id)
{.
    return "/path/to/source/${id}.php";
}

В PHP составление правил сопоставления имени класса файлу с его исходным кодом стандартизированы (PSR-4), в Java сопоставление идёт на уровне конфигурации JVM (class loader). Если Контейнер предусматривает автоматический поиск исходников при создании объектов, то имена классов являются достаточно хорошими идентификаторами для объектов в таком Контейнере.


Namespaces


Обычно в проекте, помимо собственного кода, используются также сторонние модули. С появлением менеджеров зависимостей (maven, composer, npm) использование модулей очень сильно упростилось, а количество модулей в проектах очень сильно увеличилось. Пространства имён позволяют существовать в едином проекте одноимённым элементам кода из различных модулей (классы, функции, константы).


Есть языки, в которых пространство имён встроено изначально (Java):


package vendor.project.module.folder;

есть языки, в которых пространство имён добавлено в ходе развития языка (PHP):


namespace Vendor\Project\Module\Folder;

Хорошая реализация пространства имён позволяет однозначно адресовать любой элемент кода:


\Doctrine\Common\Annotations\Annotation\Attribute::$name

Пространство имён решает задачу по упорядочиванию множества программных элементов в проекте, а файловая структура решает задачу по упорядочиванию файлов на диске. Поэтому между ними не просто много общего, а иногда и очень много — в Java, например, публичный класс в пространстве имён однозначно должен быть привязан к файлу с кодом этого класса.


Таким образом, использование в Контейнере в качестве идентификаторов объектов идентификатора класса объекта в пространстве имён проекта является хорошей идеей и может служить основой для создания правил по автоматическому обнаружению исходных кодов при создании требуемого объекта.


$container->add(\Vendor\Project\Module\ObjectType::class, $obj);

Автозагрузка кода


В PHP composer пространство имён модуля маппится на файловую систему внутри модуля в дескрипторе модуля composer.json:


"autoload": {
    "psr-4": { "Doctrine\\Common\\Annotations\\": "lib/Doctrine/Common/Annotations" }
}

JS-сообщество могло бы делать аналогичный маппинг в package.json, если бы в JS были пространства имён.


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


Выше я обозначил, что в качестве идентификаторов Контейнер может использовать либо имена входных параметров конструктора, либо типы входных параметров. Проблема в том, что:


  1. JS — язык с динамической типизацией и не предусматривает указание типов при объявлении функции.
  2. В JS используются минификаторы, которые могут переименовывать входные параметры.

Разработчики DI-контейнера awilix предлагают использовать объект в качестве единственного входного параметра конструктора, а в качестве зависимостей — свойства этого объекта:


class UserController {
  constructor(opts) {
    this.userService = opts.userService
  }
}

Идентификатор свойства объекта в JS может состоять из буквенно-цифровых символов, "_" и "$", причем не может начинаться с цифры.


Так как нам для автозагрузки нужно будет мапить идентификаторы зависимостей на путь к их исходникам в файловой системе, то лучше отказаться от использования "$" и воспользоваться опытом PHP. До появления оператора namespace в некоторых framework'ах (например, в Zend 1) использовали такие наименования для классов:


class Zend_Config_Writer_Json {...}

Таким образом, мы могли бы отразить наше приложение из трёх объектов (Application, Config, Service) на JS как-то так:


class Vendor_Project_Config {
    constructor() {
    }
}

class Vendor_Project_Service {
    constructor({Vendor_Project_Config}) {
        this.config = Vendor_Project_Config;
    }
}

class Vendor_Project_Application {
    constructor({Vendor_Project_Config, Vendor_Project_Service}) {
        this.config = Vendor_Project_Config;
        this.service = Vendor_Project_Service;
    }
}

Если мы размещаем код каждого класса:


export default class Vendor_Project_Application {
    constructor({Vendor_Project_Config, Vendor_Project_Service}) {
        this.config = Vendor_Project_Config;
        this.service = Vendor_Project_Service;
    }
}

в своём файле внутри модуля нашего проекта:


  • ./src/
    • ./Application.js
    • ./Config.js
    • ./Service.js

То мы можем связать корневой каталог модуля с корневым "namespace'ом" модуля в конфигурации Контейнера:


const ns = "Vendor_Project";
const path = path.join(module_root, "src"); 
container.addSourceMapping(ns, path);

а затем, отталкиваясь от этой информации, конструировать на основании идентификатора зависимости (Vendor_Project_Config) путь к соответствующим исходникам (${module_root}/src/Config.js).


ES6 Модули


ES6 предлагает общую конструкцию для загрузки ES6-модулей:


import { something } from 'path/to/source/with/something';

Так как нам нужно один объект (класс) привязывать к одному файлу, то есть смысл в исходнике экспортировать этот класс по-умолчанию:


export default class Vendor_Project_Path_To_Source_With_Something {...}

В принципе, можно не писать такое длинное имя для класса, достаточно просто Something и тоже будет работать, но в Zend 1 писали и не переломились, а уникальность имени класса в пределах проекта положительно сказывается как на возможностях IDE (autocomplete и контекстные подсказки), так и при отладке:


image


Импорт класса и создание объекта в таком случае выглядит так:


import Something from 'path/to/source/with/something';
const something = new Something();

Front & Back импорт


Импорт работает как в браузере, так и в nodejs, но есть нюансы. Например, браузер не понимает импорта nodejs-модулей:


import path from "path";

В браузере получаем ошибку:


Failed to resolve module specifier "path". Relative references must start with either "/", "./", or "../".

То есть, если мы хотим, чтобы наш код работал и в браузере, и в nodejs, мы не можем использовать конструкции которые не понимает браузер или nodejs. Я специально акцентирую на этом внимание, потому что такой вывод слишком естественен, чтобы о нём думать. Как дышать.


Место DI в современных web-приложениях


Это сугубо моё личное мнение, обусловленное моим персональным опытом, как и всё остальное в этой публикации.


В web-приложениях JS практически безальтернативно занимает своё место на фронте, в браузере. На серверной стороне плотно окопались Java, PHP, .Net, Ruby, python,… Но с появлением nodejs JavaScript также проник и на сервер. А технологии, используемые в других языках, в том числе и DI, начали проникать в серверный JS.


Развитие JavaScript обусловлено асинхроностью работы кода в браузере. Асинхронность не является исключительной особенностью JS, скорее врождённой. Сейчас наличие JS и на сервере, и на фронте уже никого не удивляет, а скорее, стимулирует к использованию одних и тех же подходов на обоих "концах" web-приложения. И одного и того же кода. Разумеется, что фронт и бэк слишком различаются по своей сути и по решаемым задачам, чтобы использовать один и тот же код и там, и там. Но можно предположить, что в более-менее сложном приложении будет код браузерный, серверный и общий.


DI уже сейчас используется на фронте, в RequireJS:


define(
    ["./config", "./service"],
    function App(Config, Service) {}
);

Правда тут идентификаторы зависимостей прописываются в явном виде и сразу в виде ссылок на исходники (можно настроить маппинг идентификаторов в конфиге загрузчика).


В современных web-приложениях DI существует не только на серверной стороне, но и в браузере.


При чём тут Майкл Джексон?


При включении поддержки ES-модулей в nodejs (флаг --experimental-modules) движок идентифицирует содержимое файлов с расширением *.mjs как EcmaScript-модули (в отличие от Common-модулей с расширением *.cjs).


Иногда такой подход называют "Michael Jackson Solution", а скрипты — Michael Jackson Scripts (*.mjs).


Согласен, что так себе интрига с КДПВ разрешилась, но… камон ребят, Майкл Джексон...


Yet Another DI Implementation


Ну и как полагается, собственный велосипед DI-модуль — @teqfw/di


Это не готовое "к бою" решение, а скорее принципиальная реализация. Все зависимости должны представлять из себя ES-модули и использовать общие для браузера и nodejs возможности.


Для разрешения зависимостей в модуле применяется подход awilix:


constructor(spec) {
    /** @type {Vendor_Module_Config} */
    const _config = spec.Vendor_Module_Config;
    /** @type {Vendor_Module_Service} */
    const _service = spec.Vendor_Module_Service;
}

Для запуска back-примера:


import Container from "./src/Container.mjs";
const container = new Container();
container.addSourceMapping("Vendor_Module", "../example");
container.get("Vendor_Module_App")
    .then((app) => {
        app.run();
    });

на сервере:


$ node --experimental-modules main.mjs

Для запуска front-примера (example.html):


<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>DI in Browser</title>
    <script type="module" src="./main.mjs"></script>
</head>
<body>
<p>Load main script './main.mjs', create new DI container, then get object by ID from container.</p>
<p>Open browser console to see output.</p>
</body>
</html>

нужно выложить модуль на сервер и открыть страницу example.html в браузере (или воспользоваться возможностями IDE). Если открывать example.html напрямую, то в Chrom'е ошибка:


Access to script at 'file:///home/alex/work/teqfw.di/main.mjs' from origin 'null' has been blocked by CORS policy: Cross origin requests are only supported for protocol schemes: http, data, chrome, chrome-extension, https.

Если всё прошло удачно, то в консоли (браузера или nodejs) будет примерно такой вывод:


Create object with ID 'Vendor_Module_App'.
Create object with ID 'Vendor_Module_Config'.
There is no dependency with id 'Vendor_Module_Config' yet.
'Vendor_Module_Config' instance is created.
Create object with ID 'Vendor_Module_Service'.
There is no dependency with id 'Vendor_Module_Service' yet.
'Vendor_Module_Service' instance is created (deps: [Vendor_Module_Config]).
'Vendor_Module_App' instance is created (deps: [Vendor_Module_Config, Vendor_Module_Service]).
Application 'Vendor_Module_Config' is running.

Резюме


AMD, CommonJS, UMD?


ESM!

Теги:
Хабы:
+11
Комментарии40

Публикации

Истории

Работа

Ближайшие события

Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн