- PVSM.RU - https://www.pvsm.ru -
Ещё одна имплементация Dependency Injection в JavaScript [1] — с ES6-модулями, с возможностью использовать один и тот же код в браузере, и в nodejs и не использовать транспиляторы.
Под катом — мой взгляд на DI, его место в современных web-приложениях, принципиальная реализация DI-контейнера, способного создавать объекты и на фронте, и на бэке, а также объяснение, при чём тут Майкл Джексон.
Очень сильно прошу тех, кому изложенное в статье покажется банальным, не насиловать себя и не читать до конца, чтобы потом, разочаровавшись, не ставить "минус". Я не против "минусов" — но только если минус сопровождается комментарием, что именно в публикации вызвало отрицательную реакцию. Это техническая статья, поэтому постарайтесь отнестись снисходительно к стилю изложения, а критиковать именно техническую составляющую изложенного. Спасибо.
Я очень уважаю функциональное программирование, но большую часть своей профессиональной деятельности я посвятил созданию приложений, состоящих из объектов. JavaScript мне импонирует тем, что функции в нём также являются объектами. При создании приложений я мыслю объектами, это моя профессиональная деформация.
По времени жизни объекты в приложении можно разделить на следующие категории:
В связи с этим в программировании есть такие шаблоны проектирования, как:
Т.е., с моей точки зрения, приложение состоит из постоянно-существующих одиночек, которые либо сами выполняют требуемые операции, либо для их выполнения порождают временные объекты.
Внедрение зависимостей [6] — это подход, который облегчает создание объектов в приложении. Т.е., в приложении существует специальный объект, который "знает", каким образом создавать все остальные объекты. Такой объект называется Контейнер Объектов (иногда — Менеджер Объектов).
Контейнер Объектов не является Божественным Объектом [7], т.к. его задачей является только создание значимых объектов приложения и предоставление доступа к ним другим объектам. Подавляющее большинство объектов приложения, будучи порождёнными Контейнером и размещаясь в нём, никакого представления о самом Контейнере не имеют. Их можно поместить в любую другую среду, снабдить необходимыми зависимостями и они будут также замечательно функционировать и там (тестировщики в курсе, о чём я).
По большому счёту есть два способа [8] внедрить зависимости в объект:
Я, в основном, использовал первый подход, поэтому дальнейшее описание я буду вести с точки зрения внедрения зависимостей через конструктор.
Допустим, что у нас есть приложение, состоящее из трёх объектов:
В 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 [9]) при соответствующей настройке смог по запросу на создание объекта Application
также создать его зависимости Service
и Config
и передать их параметрами в конструктор объекта Application
.
Каким же образом Контейнер объектов понимает, что конструктору объекта Application
требуются два объекта Config
и Service
? Путём анализа объекта через Reflection API (Java [10], PHP [11]) или через анализ непосредственно кода объекта (аннотаций к коду). То есть, в общем случае, мы можем определить имена переменных, которые ожидает увидеть на входе конструктор объекта, а если язык типизируемый, то можем получить также и типы этих переменных.
Таким образом, в качестве идентификаторов объектов Контейнер может оперировать либо именами входных параметров конструктора, либо типами входных параметров.
Объект может быть в явном виде создан программистом и помещён в Контейнер под соответствующим идентификатором (например, "configuration")
/** @var LeagueContainerContainer $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 [12]), в Java сопоставление идёт на уровне конфигурации JVM (class loader [13]). Если Контейнер предусматривает автоматический поиск исходников при создании объектов, то имена классов являются достаточно хорошими идентификаторами для объектов в таком Контейнере.
Обычно в проекте, помимо собственного кода, используются также сторонние модули. С появлением менеджеров зависимостей (maven, composer, npm) использование модулей очень сильно упростилось, а количество модулей в проектах очень сильно увеличилось. Пространства имён позволяют существовать в едином проекте одноимённым элементам кода из различных модулей (классы, функции, константы).
Есть языки, в которых пространство имён встроено изначально (Java):
package vendor.project.module.folder;
есть языки, в которых пространство имён добавлено в ходе развития языка (PHP):
namespace VendorProjectModuleFolder;
Хорошая реализация пространства имён позволяет однозначно адресовать любой элемент кода:
DoctrineCommonAnnotationsAnnotationAttribute::$name
Пространство имён решает задачу по упорядочиванию множества программных элементов в проекте, а файловая структура решает задачу по упорядочиванию файлов на диске. Поэтому между ними не просто много общего, а иногда и очень много — в Java, например, публичный класс в пространстве имён однозначно должен быть привязан к файлу с кодом этого класса.
Таким образом, использование в Контейнере в качестве идентификаторов объектов идентификатора класса объекта в пространстве имён проекта является хорошей идеей и может служить основой для создания правил по автоматическому обнаружению исходных кодов при создании требуемого объекта.
$container->add(VendorProjectModuleObjectType::class, $obj);
В PHP composer
пространство имён модуля маппится на файловую систему внутри модуля в дескрипторе модуля composer.json
:
"autoload": {
"psr-4": { "Doctrine\Common\Annotations\": "lib/Doctrine/Common/Annotations" }
}
JS-сообщество могло бы делать аналогичный маппинг в package.json
, если бы в JS были пространства имён.
Выше я обозначил, что в качестве идентификаторов Контейнер может использовать либо имена входных параметров конструктора, либо типы входных параметров. Проблема в том, что:
Разработчики DI-контейнера awilix [14] предлагают использовать объект в качестве единственного входного параметра конструктора, а в качестве зависимостей — свойства этого объекта:
class UserController {
constructor(opts) {
this.userService = opts.userService
}
}
Идентификатор [15] свойства объекта в 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 предлагает [16] общую конструкцию для загрузки 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();
Импорт работает как в браузере, так и в nodejs, но есть нюансы. Например, браузер не понимает импорта nodejs-модулей:
import path from "path";
В браузере получаем ошибку:
Failed to resolve module specifier "path". Relative references must start with either "/", "./", or "../".
То есть, если мы хотим, чтобы наш код работал и в браузере, и в nodejs, мы не можем использовать конструкции которые не понимает браузер или nodejs. Я специально акцентирую на этом внимание, потому что такой вывод слишком естественен, чтобы о нём думать. Как дышать.
Это сугубо моё личное мнение, обусловленное моим персональным опытом, как и всё остальное в этой публикации.
В web-приложениях JS практически безальтернативно занимает своё место на фронте, в браузере. На серверной стороне плотно окопались Java, PHP, .Net, Ruby, python,… Но с появлением nodejs JavaScript также проник и на сервер. А технологии, используемые в других языках, в том числе и DI, начали проникать в серверный JS.
Развитие JavaScript обусловлено асинхроностью работы кода в браузере. Асинхронность не является исключительной особенностью JS, скорее врождённой. Сейчас наличие JS и на сервере, и на фронте уже никого не удивляет, а скорее, стимулирует к использованию одних и тех же подходов на обоих "концах" web-приложения. И одного и того же кода. Разумеется, что фронт и бэк слишком различаются по своей сути и по решаемым задачам, чтобы использовать один и тот же код и там, и там. Но можно предположить, что в более-менее сложном приложении будет код браузерный, серверный и общий.
DI уже сейчас используется на фронте, в RequireJS [17]:
define(
["./config", "./service"],
function App(Config, Service) {}
);
Правда тут идентификаторы зависимостей прописываются в явном виде и сразу в виде ссылок на исходники (можно настроить маппинг идентификаторов в конфиге загрузчика).
В современных web-приложениях DI существует не только на серверной стороне, но и в браузере.
При включении [18] поддержки ES-модулей в nodejs (флаг --experimental-modules
) движок идентифицирует содержимое файлов с расширением *.mjs
как EcmaScript-модули (в отличие от Common-модулей [19] с расширением *.cjs
).
Иногда такой подход называют "Michael Jackson Solution [20]", а скрипты — Michael Jackson Scripts (*.mjs
).
Согласен, что так себе интрига с КДПВ разрешилась, но… камон ребят, Майкл Джексон... [21]
Ну и как полагается, собственный велосипед DI-модуль — @teqfw/di [22]
Это не готовое "к бою" решение, а скорее принципиальная реализация. Все зависимости должны представлять из себя ES-модули и использовать общие для браузера и nodejs возможности.
Для разрешения зависимостей в модуле применяется подход awilix [14]:
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? [23]
ESM [24]!
Автор: flancer
Источник [25]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/javascript/327820
Ссылки в тексте:
[1] Dependency Injection в JavaScript: https://www.npmjs.com/search?q=Dependency%20Injection
[2] singleton: https://ru.wikipedia.org/wiki/%D0%9E%D0%B4%D0%B8%D0%BD%D0%BE%D1%87%D0%BA%D0%B0_(%D1%88%D0%B0%D0%B1%D0%BB%D0%BE%D0%BD_%D0%BF%D1%80%D0%BE%D0%B5%D0%BA%D1%82%D0%B8%D1%80%D0%BE%D0%B2%D0%B0%D0%BD%D0%B8%D1%8F)
[3] abstract factory: https://ru.wikipedia.org/wiki/%D0%90%D0%B1%D1%81%D1%82%D1%80%D0%B0%D0%BA%D1%82%D0%BD%D0%B0%D1%8F_%D1%84%D0%B0%D0%B1%D1%80%D0%B8%D0%BA%D0%B0_(%D1%88%D0%B0%D0%B1%D0%BB%D0%BE%D0%BD_%D0%BF%D1%80%D0%BE%D0%B5%D0%BA%D1%82%D0%B8%D1%80%D0%BE%D0%B2%D0%B0%D0%BD%D0%B8%D1%8F)
[4] builder: https://ru.wikipedia.org/wiki/%D0%A1%D1%82%D1%80%D0%BE%D0%B8%D1%82%D0%B5%D0%BB%D1%8C_(%D1%88%D0%B0%D0%B1%D0%BB%D0%BE%D0%BD_%D0%BF%D1%80%D0%BE%D0%B5%D0%BA%D1%82%D0%B8%D1%80%D0%BE%D0%B2%D0%B0%D0%BD%D0%B8%D1%8F)
[5] pool: https://ru.wikipedia.org/wiki/%D0%9E%D0%B1%D1%8A%D0%B5%D0%BA%D1%82%D0%BD%D1%8B%D0%B9_%D0%BF%D1%83%D0%BB
[6] Внедрение зависимостей: https://ru.wikipedia.org/wiki/%D0%92%D0%BD%D0%B5%D0%B4%D1%80%D0%B5%D0%BD%D0%B8%D0%B5_%D0%B7%D0%B0%D0%B2%D0%B8%D1%81%D0%B8%D0%BC%D0%BE%D1%81%D1%82%D0%B8
[7] Божественным Объектом: https://ru.wikipedia.org/wiki/%D0%91%D0%BE%D0%B6%D0%B5%D1%81%D1%82%D0%B2%D0%B5%D0%BD%D0%BD%D1%8B%D0%B9_%D0%BE%D0%B1%D1%8A%D0%B5%D0%BA%D1%82
[8] два способа: https://habr.com/ru/post/352530/
[9] league/container: https://packagist.org/packages/league/container
[10] Java: https://docs.oracle.com/javase/8/docs/api/index.html?java/lang/reflect/package-summary.html
[11] PHP: https://www.php.net/manual/ru/book.reflection.php
[12] PSR-4: https://www.php-fig.org/psr/psr-4/
[13] class loader: https://en.wikipedia.org/wiki/Java_Classloader
[14] awilix: https://www.npmjs.com/package/awilix
[15] Идентификатор: https://www.w3schools.com/js/js_syntax.asp
[16] предлагает: https://frontender.info/es6-modules/
[17] RequireJS: https://requirejs.org/
[18] включении: https://nodejs.org/api/esm.html#esm_enabling
[19] Common-модулей: https://nodejs.org/api/esm.html#esm_package_scope_and_file_extensions
[20] Michael Jackson Solution: https://anasshekhamis.com/2017/09/14/es6-modules-arrived-to-node-js/
[21] камон ребят, Майкл Джексон...: https://www.youtube.com/watch?v=FZzsb4Q4Pfs
[22] @teqfw/di: https://github.com/teqfw/di/tree/0.1.1#teqfwdi
[23] AMD, CommonJS, UMD?: https://www.davidbcalhoun.com/2014/what-is-amd-commonjs-and-umd/
[24] ESM: https://nodejs.org/api/esm.html
[25] Источник: https://habr.com/ru/post/464347/?utm_campaign=464347&utm_source=habrahabr&utm_medium=rss
Нажмите здесь для печати.