Интерфейсы в 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, то:

На схеме выше каждый блок соответствует отдельному 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 при помощи Контейнера даёт разработчику больше пространства для манёвра за счёт пред- и особенно пост-обработки создаваемых и внедряемых зависимостей.

Заключение

Исходный код демо-плагина и приложений:

Примечания

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

@flancer
06.08.2024 19:20 UTC
Первоисточник

Комментарии

@Zukomux
08.08.2024 03:52 UTC
+3

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

@flancer
08.08.2024 10:10 UTC
0

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

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