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

© Habrahabr.ru