На эту статью меня сподвигла переписка в комментах с коллегой @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: второй заказ на перевозку дипломата со взрывчаткой из Ницы в Гренобль.
Примечания
-
Лично я считаю «Перевозчик» трилогией хотя бы только потому, что Эд Скрейн ну совсем не Джейсон Стэйтэм.
ссылка на оригинал статьи https://habr.com/ru/articles/834002/
Добавить комментарий