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

в 19:14, , рубрики: dependency injection, ES6, import, javascript, modules, Программирование, Разработка веб-сайтов

Ещё одна имплементация 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 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), в Java сопоставление идёт на уровне конфигурации JVM (class loader). Если Контейнер предусматривает автоматический поиск исходников при создании объектов, то имена классов являются достаточно хорошими идентификаторами для объектов в таком Контейнере.

Namespaces

Обычно в проекте, помимо собственного кода, используются также сторонние модули. С появлением менеджеров зависимостей (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 были пространства имён.

Идентификаторы зависимостей в 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!

Автор: flancer

Источник


* - обязательные к заполнению поля