Интерфейсы в JS с помощью @teqfw/di
На эту статью меня сподвигла переписка в комментах с коллегой @iliazeus и его вопрос, как в @teqfw/di код может зависеть от интерфейса, а не от его имплементации. В своём ответе я попытался провести параллели с героем Джейсона Стэйтэма из фильма «Перевозчик» — с Фрэнком Мартином. У Фрэнка было три правила (условия контракта) и любой, кто удовлетворял этим правилам (и имел достаточно денег), мог нанять Фрэнка в качестве первозчика.
Фрэнка Мартина детали не интересуют.
Ниже я продемонстрирую на примере Фрэнка Мартина, каким образом могут работать интерфейсы в обычном JS (не TS).
Контракт
В первом фильме трилогии (прим. 1) у Фрэнка Мартина было три правила:
Никогда не изменять условия сделки.
Никаких имён.
Никогда не открывать посылку.
Вот третье правило как раз и описывает специфику использования интерфейсов в программировании (не только в JS, а вообще). Всё, что Фрэнку нужно было знать про посылку — это её размеры и вес, а про поездку — адрес начала и конца маршрута. На основании этой информации Фрэнк прикидывал, стоит ли браться за работу и за сколько.
В JS можно упрощённо описать эти условия так:
/** @interface */
class Trans_Api_Package {
/** @return {{length: number, width: number, height: number}} */
getSize() {}
/** @return {number} */
getWeight() {}
}
/** @interface */
class Trans_Api_Route {
/** @return {string} */
getPlaceFrom() {}
/** @return {string} */
getPlaceTo() {}
}
На текущий момент нативных интерфейсов в JS пока ещё не завезли, поэтому приходится обходиться обычными классами (class
) и аннотациями JSDoc — @interface
и @implements
.
Чтобы взять заказ и выполнить его, Фрэнку нужно знать лишь то, что он декларировал в контракте (интерфейсах). Детали за пределами оговоренного контракта его не волнуют на профессиональном уровне — »Никогда не открывать посылку.»
Договор Фрэнка о предоставляемой услуге на языке JS мог бы выглядеть так:
class Trans_Drive {
/**
* @param {Trans_Api_Package} pack
* @param {Trans_Api_Route} route
*/
constructor(pack, route}
) {...}
}
В переводе на простой язык:»Вы даёте мне посылку, говорите, куда ехать — и я еду.»
Фрэнк предоставляет услугу транспортировки любому лицу или организации, кто соответствует его требованиям.
Если переводить на язык программирования всё вышеизложенное в отрыве от Фрэнка Мартина, но в контексте использования @teqfw/di
, то:
Плагин определяет интерфейсы, которым должны соответствовать объекты, с которыми он может работать (классы в пространстве
Trans_Api
).За имплементацию интерфейсов отвечает приложение, которое этот плагин использует (
Client1
,Client2
, …).Так как в
@teqfw/di
IoC реализована в виде внедрения зависимостей через конструктор, то классы плагина используют интерфейсы для обозначения зависимостей, ожидаемых от Контейнера Объектов.Связывание имплементаций с интерфейсами происходит путём конфигурации Контейнера Объектов в соответствующем приложении (
Client1
,Client2
, …).При создании в runtime объектов пла2 гина Контейнер внедряет имплементации в места соответствующих интерфейсов.
На схеме выше каждый блок соответствует отдельному npm-пакету.
Внедрение
В данной статье я исхожу из упрощения, что одно приложение (любой Client) использует плагин (Transporter) для однократной перевозки посылки (хотя бы в силу эксклюзивности услуг Фрэнка и их стоимости). Таким образом, объект поездки Trans_Drive
в пределах любого приложения, его использующего, является объектом-одиночкой и инжектируется такими же зависимостями-одиночками при создании. В терминах @teqfw/di
это выглядит так:
2export default class Trans_Drive {
/**
* @param {Trans_Api_Package} pack
* @param {Trans_Api_Route} route
*/2
constructor(
{
Trans_Api_Package$: pack,
Trans_Api_Route$: route,
}
) {...}
}
Согласно принципам IoC базовые объекты сами не создают нужные им зависимости, но дают возможность Контейнеру Объектов эти зависимости создать и внедрить. Поскольку описание требуемых зависимостей происходит на уровне плагина (пространство имён Trans_
), то, как я отметил выше, в качестве идентификаторов зависимостей выступают имена интерфейсов (префикс Trans_Api_
).
Имплементация
Приложение, чтобы использовать trans-плагин, должно имплементировать соответствующие интерфейсы.
В JS-коде это можно выразить так:
/** @implements Trans_Api_Package */
export default class Client1_Di_Package {
/** @return {{length: number, width: number, height: number}} */
getSize() {
return {length: 150, width: 50, height: 50};
}
/** @return {number} */
getWeight() {
return 50;
}
}
Эти параметры соответствуют сумке с дочкой босса китайской мафии, озвученным в в первом фильме: вес — 50 кг, размер — полтора метра на полметра.
Конфигурация Контейнера Объектов
Каждое приложение, использующее @teqfw/di
, должно первым делом сконфигурировать Контейнер Объектов. Для начала указать правила разрешения имён:
import {dirname, join} from 'node:path';
import {fileURLToPath} from 'node:url';
import Container from '@teqfw/di';
const url = new URL(import.meta.url);
const script = fileURLToPath(url);
const current = dirname(script);
const scope = join(current, 'node_modules', '@flancer64');
const container = new Container();
const resolver = container.getResolver();
resolver.addNamespaceRoot('Client1_', join(current, 'src'));
resolver.addNamespaceRoot('Trans_', join(scope, 'demo-di-if-plugin', 'src'));
А затем указать правила преобразования имён интерфейсов в имена соответствующих имплементаций:
/**
* The preprocessor chunk to replace interfaces with the implementations in this app.
* @implements TeqFw_Di_Api_Container_PreProcessor_Chunk
*/
const replaceChunk = {
modify(depId, originalId, stack) {
// FUNCS
/**
* @param {TeqFw_Di_DepId} id - structured data about interface
* @param {string} nsImpl - the namespace for the implementation
*/
function replace(id, nsImpl) {
id.moduleName = nsImpl;
return id;
}
// MAIN
switch (originalId.moduleName) {
case 'Trans_Api_Package':
return replace(depId, 'Client1_Di_Package');
case 'Trans_Api_Route':
return replace(depId, 'Client1_Di_Route');
}
return depId;
}
};
container.getPreProcessor().addChunk(replaceChunk);
Контейнер объектов в @teqfw/di
имеет возможность подключать цепочку обработчиков в препроцессор. Каждый обработчик должен имплементировать интерфейс TeqFw_Di_Api_Container_PreProcessor_Chunk
и может изменять структуру идентификатора зависимости до того, как будет создан соответствующий ей объект:
container.getPreProcessor().addChunk(new Replace());
В нашем случае проще всего связать каждый интерфейс с соответствующей имплементацией напрямую через switch
. Но в других приложениях маппинг может быть более »кучерявым» (например, через внешний JSON/YAML/XML или через сопоставление структур каталогов в плагине и приложении: id.moduleName.replace('Trans_Api_', 'Client1_Di_'))
.
Итого
Конечно же, код в плагине зависит от интерфейса.
Код в плагине на момент написания, конечно же, зависит от интерфейса — плагин просто ничего не знает про другие приложения и их имплементации его интерфейсов. Зато знают сами приложения (вернее, их разработчики). И эти знания позволяют конфигурировать Контейнер Объектов в приложении таким образом, чтобы в runtime вместо интерфейсов использовались соответствующие им имплементации.
Использование интерфейсов на уровне плагинов позволяют уменьшить зацепление кода между npm-пакетами и увеличить возможности повторного использования npm-пакетов в различных приложениях.
Кстати, совершенно необязательно делать это при помощи IoC — аннотации JSDoc так же хорошо позволяют навигировать по коду и при »ручном» связывании объектов при помощи обычных статических import
'ов (раннее связывание). Но позднее связывание объектов в runtime при помощи Контейнера даёт разработчику больше пространства для манёвра за счёт пред- и особенно пост-обработки создаваемых и внедряемых зависимостей.
Заключение
Контейнер Объектов в
@teqfw/di
позволяет модифицировать идентификатор зависимостей перед созданием соответствующего ему объекта (цепочка обработчиков в препроцессоре).Плагин, который используется в приложении (или другими плагинами), объявляет классы без имплементации методов и маркирует их с помощью JSDoc-аннотации
@interface
.Интерфейсные классы являются по сути документацией и в норме не должны порождать runtime-объектов.
Код внутри самого плагина завязан на интерфейсы через JSDoc-аннотации, что позволяет использовать autocomplete в IDE.
Приложение (или другие плагины) имплементируют соответствующий интерфейс и маркирует имплементацию при помощи JSDoc-аннотации
@implements
для возможности навигации по коду в IDE.Приложение инициализирует Контейнер Объектов при старте и конфигурирует замену в runtime интерфейсов их имплементациями с учётом всех используемых в приложении плагинов.
Исходный код демо-плагина и приложений:
flancer64/demo-di-if-plugin: собственно сам Фрэнк Мартин со своим профессиональным нелюбопытством (интерфейсы)
flancer64/demo-di-if-app1: первый заказ на перевозку из Марселя в Ницу сумки с девушкой-китаянкой внутри.
flancer64/demo-di-if-app2: второй заказ на перевозку дипломата со взрывчаткой из Ницы в Гренобль.
Примечания
Лично я считаю «Перевозчик» трилогией хотя бы только потому, что Эд Скрейн ну совсем не Джейсон Стэйтэм.