На эту статью меня сподвигла переписка в комментах с коллегой @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. Лично я считаю "Перевозчик" трилогией хотя бы только потому, что Эд Скрейн ну совсем не Джейсон Стэйтэм.

Комментарии (2)


  1. Zukomux
    08.08.2024 03:52
    +3

    Ну и для чего этот велосипед когда есть TS, message pack и protobuf?


    1. flancer Автор
      08.08.2024 03:52

      Интерфейсы - это "внеязыковое" понятие. Это способ мышления при конструировании ПО. Вы можете создавать код на любом языке. Можете на TS, можете на JS, на Go, Java, python, Rust, ... Можете использовать при этом интерфейсы, а можете и не использовать. Этот велосипед для тех, кто пишет код на JS и хочет использовать интерфейсы.

      Вы просто "вошли не в ту дверь" и поняли это ближе к концу статьи. Только этим я могу объяснить обиженный тон вашего коммента, коллега ;)