Интерфейсы в JS с помощью @teqfw/di

от автора

На эту статью меня сподвигла переписка в комментах с коллегой @iliazeus и его вопрос, как в @teqfw/di код может зависеть от интерфейса, а не от его имплементации. В своём ответе я попытался провести параллели с героем Джейсона Стэйтэма из фильма «Перевозчик» — с Фрэнком Мартином. У Фрэнка было три правила (условия контракта) и любой, кто удовлетворял этим правилам (и имел достаточно денег), мог нанять Фрэнка в качестве первозчика.

Фрэнка Мартина детали не интересуют

Фрэнка Мартина детали не интересуют.

Ниже я продемонстрирую на примере Фрэнка Мартина, каким образом могут работать интерфейсы в обычном JS (не TS).

Контракт

В первом фильме трилогии (прим. 1) у Фрэнка Мартина было три правила:

  1. Никогда не изменять условия сделки.

  2. Никаких имён.

  3. Никогда не открывать посылку.

Вот третье правило как раз и описывает специфику использования интерфейсов в программировании (не только в 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: второй заказ на перевозку дипломата со взрывчаткой из Ницы в Гренобль.

Примечания

  1. Лично я считаю «Перевозчик» трилогией хотя бы только потому, что Эд Скрейн ну совсем не Джейсон Стэйтэм.


ссылка на оригинал статьи https://habr.com/ru/articles/834002/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *