Ещё одна имплементация Dependency Injection в JavaScript — с ES6-модулями, с возможностью использовать один и тот же код в браузере, и в nodejs и не использовать транспиляторы.
Под катом — мой взгляд на DI, его место в современных web-приложениях, принципиальная реализация DI-контейнера, способного создавать объекты и на фронте, и на бэке, а также объяснение, при чём тут Майкл Джексон.
Очень сильно прошу тех, кому изложенное в статье покажется банальным, не насиловать себя и не читать до конца, чтобы потом, разочаровавшись, не ставить "минус". Я не против "минусов" — но только если минус сопровождается комментарием, что именно в публикации вызвало отрицательную реакцию. Это техническая статья, поэтому постарайтесь отнестись снисходительно к стилю изложения, а критиковать именно техническую составляющую изложенного. Спасибо.
Объекты в приложении
Я очень уважаю функциональное программирование, но большую часть своей профессиональной деятельности я посвятил созданию приложений, состоящих из объектов. JavaScript мне импонирует тем, что функции в нём также являются объектами. При создании приложений я мыслю объектами, это моя профессиональная деформация.
По времени жизни объекты в приложении можно разделить на следующие категории:
- постоянные — возникают на каком-то этапе работы приложения и уничтожаются только при завершении приложения;
- временные — возникают при необходимости выполнения некоторой операции и уничтожаются при завершении выполнения этой операции;
В связи с этим в программировании есть такие шаблоны проектирования, как:
- singleton;
- abstract factory, builder, pool, ...
Т.е., с моей точки зрения, приложение состоит из постоянно-существующих одиночек, которые либо сами выполняют требуемые операции, либо для их выполнения порождают временные объекты.
Контейнер Объектов
Внедрение зависимостей — это подход, который облегчает создание объектов в приложении. Т.е., в приложении существует специальный объект, который "знает", каким образом создавать все остальные объекты. Такой объект называется Контейнер Объектов (иногда — Менеджер Объектов).
Контейнер Объектов не является Божественным Объектом, т.к. его задачей является только создание значимых объектов приложения и предоставление доступа к ним другим объектам. Подавляющее большинство объектов приложения, будучи порождёнными Контейнером и размещаясь в нём, никакого представления о самом Контейнере не имеют. Их можно поместить в любую другую среду, снабдить необходимыми зависимостями и они будут также замечательно функционировать и там (тестировщики в курсе, о чём я).
Место внедрения
По большому счёту есть два способа внедрить зависимости в объект:
- через конструктор;
- через свойство (или его акцессор);
Я, в основном, использовал первый подход, поэтому дальнейшее описание я буду вести с точки зрения внедрения зависимостей через конструктор.
Допустим, что у нас есть приложение, состоящее из трёх объектов:
В 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
Выше я обозначил, что в качестве идентификаторов Контейнер может использовать либо имена входных параметров конструктора, либо типы входных параметров. Проблема в том, что:
- JS — язык с динамической типизацией и не предусматривает указание типов при объявлении функции.
- В 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 и контекстные подсказки), так и при отладке:
Импорт класса и создание объекта в таком случае выглядит так:
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.
Резюме
ESM!