Внедрение зависимостей в JavaScript

Реализация внедрения конструктора с помощью прокси-серверов JavaScript

Удаление жестко заданных зависимостей в коде и предоставление взаимозаменяемых стратегий для алгоритмов путем внедрения зависимостей с помощью Inversion of Control-Container.

Обновление от 30 марта 2023 г.: эта статья была перемещена на мой личный сайт. Вы найдете последние обновления этой статьи по адресу https://thorsten.suckow-homberg.de/docs/articles/dependency-injection-in-javascript

Мотивация

Низкая связанность и высокая связанность не означают полного отсутствия ассоциаций: использование уровней абстракции по праву требует специфики, обеспечивающей конкретные реализации, удовлетворяющие контрактам между частями системы. В конце концов, программа не работает, если нет ничего, что соответствовало бы ее Интерфейсам и Абстрактным классам.

Однако то, как эти зависимости связаны с исходным кодом, часто имеет горький привкус: зависимости обнаруживаются в запутанном и глубоко вложенном программном коде, эффективно нарушающем SRP и DIP.

Нам нужны инструменты и шаблоны проектирования, которые могут помочь нам в распутывании кода, иначе мы получим программу со сложным шаблонным кодом для настройки макетов и заглушек для тестирования, или, что еще хуже: становится кошмаром для интеграции с различные среды и инфраструктуры. Конечно, такие языки, как JavaScript, позволяют легко имитировать зависимости во время тестов, но другие языки не так снисходительны, и тестовые случаи, как правило, становятся более сложными, чем больше зависимостей жестко запрограммировано.

Следующий исходный код был взят из реализации REPOSITORY, в которой используется конкретный Storage-класс, скрывающий инфраструктуру, используемую для записи данных:

class DataRepository {

    async storeData(data) {
         const  {Storage} = await import("storageApi");
        
         const store = new Storage(...);
    
         store.save(data);
    }

}

Кажется, он хорошо согласуется с нашими стандартами кода — он маленький, четко объясняет свое предназначение без дополнительных комментариев и делегирует полномочия подпрограмме, отвечающей за связь с инфраструктурой, которая нас не интересует.

Однако этот метод использует аппаратную зависимость от storageApi, которая импортируется как модуль. Более того, все, что требуется конструктору Storage, должно обрабатываться внутри storeData()-метода. Для тестирования этого кода разработчик должен создать Mock не только для Storage, но и заглушить вызов import. DataRepository напрямую обращается к низкоуровневому API из своих границ, что приводит к сильной связи между двумя разными уровнями. Этого не должно происходить в данном storeData()-методе, хотя DataRepository, очевидно, должен знать, что должен быть какой-то инфраструктурный уровень.

Принцип инверсии зависимостей требует, чтобы модули высокого уровня ничего не импортировали из модулей низкого уровня. Это один из принципов дизайна SOLID.

В этой статье представлен контейнер Inversion of Control (IoC), который определяет во время выполнения, определены ли в коде дополнительные зависимости и должна ли какая-либо существующая зависимость разрешаться IoC. -Контейнер. Это реализуется с помощью привязок, настроенных клиентом и переданных в IoC-Container: они предоставляют информацию для конкретного, который должен быть создан для типа , т.е. Интерфейсили любой (абстрактный) класс,требуемый произвольным хостом.

Привязки можно использовать и настраивать для всего приложения, но рекомендуется указывать их во время начальной загрузки. Это позволяет легко запускать программы с различными реализациями для выбранных клиентов, контекстов или сред, и все это работает без необходимости изменения ни одной строки низкоуровневого/высокоуровневого кода.

Прокси помогают с реализацией разрешения объектов и зависимостей, и этот подход не является исключительным для JavaScript: например, Java имеет прокси, Spring использует их для своего IoC и AOP.

Если вам нужно понять концепцию прокси и то, как они работают в JavaScript, я рекомендую вам прочитать эту подробную статью, в которой подробно рассказывается, как использовать прокси с Promises для создания Свободные интерфейсы.

В следующих примерах я буду постоянно ссылаться на реализацию IoC-контейнера coon.core.ioc: это реализация, специфичная для среды Sencha Ext JS, но ее концепции легко переносятся. на другие фреймворки или независимый от фреймворка код.

Как это работает

Целевые классы должны предоставлять информацию о том, являются ли они внедряемыми, то есть должны ли они учитываться контейнером IoC во время создания экземпляра. Это необходимо, потому что мы хотим автоматически связывать зависимости, чтобы сделать нашу программу максимально гибкой: IoC-Container настраивается с помощью привязок во время последовательности запуска, а затем заботится о публикации настроенных типов с их конкретными реализациями во время выполнения приложения.

Большинство языков программирования и их платформы уже предоставляют инструменты для обработки дополнительной информации, написанной с помощью исходного кода: Метаданные часто создаются с помощью аннотаций в Java или Атрибутов в PHP8.

Метаданные: статические сборки и конфигурация среды выполнения

С почти непостижимым количеством опций инструментов для JavaScript использование аннотаций, вероятно, не потребует больших усилий; однако это наверняка будет означать, что стек сборки нашего проекта изменится: дополнительный инструмент с реализацией для синтаксического анализа нашего исходного кода также извлекает и переводит метаданные и гарантирует, что полученная сборка не сломается во время выполнения.

Аннотация в виде

/**
 * @injectable store:Storage
 */
class Repository {
   ...
}

было бы полезно, и дополнительные аннотации могут быть определены в каком-то словаре.

Затем синтаксический анализатор может создавать файлы конфигурации из метаданных, найденных в источниках, связывать их с именами целевых классов (и путями к импортируемым объектам) вместе со свойствами (т. е. именами переменных экземпляров целевых классов). которые ожидают определенного типа, а затем соединяют все это вместе, применяя привязки, настроенные разработчиками и хранящиеся в контейнере IoC.

Мы стремимся к реализации, которая не нуждается в таких дополнительных инструментах: мы предоставим метаданныекак статические члены класса поверх внедряемых классов.

Следующий исходный код демонстрирует использование static-свойства: Свойство с именем required является корнем для метаинформации, которую ищет IoC-Container и его преобразователь зависимостей: Он содержит все имена переменных экземпляра, которые ожидают специфики данного типа: В примере экземпляр Repository работает только с членом store, который содержит ссылку на экземпляр Storage.

class Repository {
   static required = {
      store: "Storage"
   }

   ...
}

Затем с помощью Proxy-Api мы можем добавить ловушку для вызовов конструктора внедряемых классов, в данном случае Repository- сорт:

class Repository {
    static required = {
      store: "Storage"
    }
   

   constructor({store}) {
       this.store = store;
   }
   
   ...
}


const constructorHandler = {

    construct (target, argumentsList, newTarget) {
        if (target.required) {
            // container holds a reference to the ioc-container
            container.inject(argumentsList, target.require); 
        }      

        return new target(...argumentsList);
    }

};


Repository = new Proxy(Repository, constructorHandler)

Обработчик делегирует IoC-Container до создания экземпляра целевого класса: затем IoC-Container проверяет список аргументов и ищет любые отсутствующие свойства в ранее заключенном объекте-аргументе, который используется для настройки экземпляра. Обозначаемое свойством required, имя переменной экземпляра должно совпадать с именем объекта конфигурации, содержащего свойство, необходимое конструктору:

// IoC-container will not inject anything, since the instance gets configured
// with a "store"-property
new Repository({store: new Storage(), uri: "/resourceUri"});


// since the "store"-property is missing, the IoC-container will
// inject a concrete of "Storage" according to the available bindings
new Repository({uri: "/resourceUri"});

Создание привязок

Привязки — это точка истины для нашего приложения, поскольку мы полагаемся на сборщики и распознаватели, которые настраиваются заранее и заботятся о сборке ассоциаций во время выполнения. Привязки сопоставляют конкретные подтипы с типами, что означает: они привязывают типизированную переменную к конкретной реализации Тип, чтобы наш IoC-Container знал, что применять и где применять (подразумевается когда с помощью конструктора инжектора). Запрошенный специфический реализует интерфейс или расширяет (абстрактный) класс, а LSP дает нам свободу предоставлять произвольные реализации этого заданного Типа.

Если для каждого объекта o1 типа S существует объект o2 типа T, такой что для всех программ P, определенных в терминах T, поведение P не изменяется при замене o2 на o1, то S является подтипом T. Барбара Лисков, Абстракция данных и иерархия

Так как мы слабо типизированы, наш Dependency Resolver (представьте его как своего рода Builder,) должен убедиться, что наши особенности действительно являются экземплярами требуемого типа.

Нахождение общего языка

Мы представим модельный язык, который поможет нам сформулировать привязки, необходимые для всей нашей программы.

У нас есть класс A, который использует экземпляр класса B:

Код для

«A имеет зависимость от типа B, и эта зависимость отражена в переменной экземпляра A

может выглядеть так:

// Pseudo code

abstract class B {
    abstract calculate();
}


class A {

    constructor (B b)
    {
      this.b = b;
    }


    calculation()
    {
        this.b.calculate();
    }
} 

Очевидно, что источники, которые полагаются на A, не будут работать без [экземпляра A].b — как только calculation() делегирует b.calculate()и b равно undefined, будет выдано исключение.

Мы ищем формальное (но простое) определение, которое можно использовать с JavaScript для настройки этих зависимостей: Мы согласны с форматом JSON, так как он допускает пары ключ/значение, тогда как ключи имеют тип string и их значения могут быть любыми из string, integer, boolean, NULL, object и array — мы будем использовать string и object.

Уточним задачу для разрешения зависимостей A:

when A
  requires B
  give new instance of B

Это довольно простой термин, который позже будет преобразован в назначение с помощью Dependency Resolver. На данный момент это так, как это перенесено в JSON (не обращайте внимания на пояснительные комментарии):

 {
    /* when */
    "A": {
      /* "needs": "give" */ 
      "B" : "InstanceOfB" 
    }   
}

Пример использования: внедрение методов аутентификации

С coon.core.ioc как частью приложения coon.js, вот типичный вызов coon.core.ioc.Container.bind():

// Some class names have been shortened in favor of
// readability.
coon.core.ioc.Container.bind({
        "conjoon.dev.cn_mailsim": {
            "conjoon.SimletAdapter": "conjoon.BasicAuthSimletAdapter"
        },
        "conjoon.cn_mail": {
            "coon.core.data.request.Configurator": {
                "$ref": "#/$defs/RequestConfiguratorSingleton"
            }
        },        
        "$defs": {
            "RequestConfiguratorSingleton": {
                "xclass": "conjoon.cn_imapuser.data.request.Configurator",
                "singleton": true
            }
        }

});

Эта конфигурация представляет привязки пакета extjs-app-imapuser-, пакета npm, обеспечивающего аутентификацию пользователя для conjoon extjs-app-webmail, который почтовый клиент, написанный на JavaScript.

extjs-app-webmail взаимодействует с бэкендом, который не зависит от используемой аутентификации — его архитектура позволяет защищать конечные точки с помощью произвольных методов аутентификации: это может быть базовая аутентификация доступа. , или API может использовать защиту, основанную на аутентификации на основе токенов. Вот почему запрашивающий клиент — в данном случае extjs-app-webmail — должен быть настроен с использованием надлежащей техники безопасности, понятной серверной части. Это делается с помощью конфигураторов запросов, которые подключаются к (некоторым/всем/никаким) исходящим запросам и добавляют аутентификационную информацию, если это требуется серверной части, например. поле заголовка Authorization, содержащее Bearer-, Basic- или другую информацию.

Объяснение привязок

Давайте подробно рассмотрим данную конфигурацию привязки. Во-первых, настроенные здесь привязки вводятся с пространствами имен вместо имен классов. Это просто еще один способ определения привязок для набора классов, принадлежащих пространству имен (т. е. целому модулю): вместо индивидуального определения зависимостей для

conjoon.dev.cn_mailsim.A, conjoon.dev.cn_mailsim.B, conjoon.dev.cn_mailsim.C, …

мы возвращаемся к их общему пространству имен, поэтому Dependency Resolver может искать привязки, настроенные для этого модуля, когда целевой класс явно не указан в конфигурации. Целевым классам всегда отдается приоритет, затем запрашиваются пространства имен.

То же самое относится и к следующему разделу, хотя значение импликации give- не является именем класса: это конфигурация, которая ссылается на другой раздел.

  "conjoon.cn_mail": {
      "coon.core.data.request.Configurator": {
         "$ref": "#/$defs/RequestConfiguratorSingleton"
     }
  }

На основе спецификации схемы JSON $ref использует URI для ссылки на другой раздел документа, в который он встроен, что позволяет определить многоразовую сложную конфигурацию в одном месте, а затем повторно использовать этот конфигурации по всему документу, ссылаясь на него.

(Resolved)$ref в приведенном выше примере утверждает, что

when any class of conjoon.cn_mail
  requires coon.core.data.request.Configurator
  give Singleton of conjoon.cn_imapuser.data.request.Configurator
       

Синглтоны хороши, когда нужны объекты без состояния, и уменьшают объем памяти в целевом приложении.

Разрешение зависимостей — отлов фабрик

Система классов Sencha Ext JS почти исключительно использует фабрики при создании экземпляров. Это полезно для динамической загрузки классов: его микрозагрузчик позаботится о сопоставлении имен классов с существующей структурой каталогов проекта, но загрузка происходит синхронно (в худшем случае, если класс не был предварительно загружен). Использование Sencha Ext JS без собственной системы классов практически невозможно. В некоторых случаях это неприятный сюрприз для пользователей, начинающих с Ext JS в 2022 году.

Тем не менее, использование фабрик и фабричных методов во всей структуре позволяет действительно легко применять к ним прокси и играет нам на руку с нашим подходом внедрения конструктора. Тщательный выбор конструкторов внедряемых целевых классов устаревает, и мы можем полагаться на внутреннюю часть фреймворка, когда нам нужно решить, нужно ли внедрять зависимости.

Wrapper-Proxy устанавливается, как только вызывается coon.core.ioc.Container.bind():

 installProxies () {
        const me = this;

        Ext.Factory = new Proxy(
            Ext.Factory, 
            Ext.create("coon.core.ioc.sencha.resolver.FactoryHandler")
        );

        Ext.create = new Proxy(
            Ext.create, 
            Ext.create("coon.core.ioc.sencha.resolver.CreateHandler")
        );
    },

Есть два обработчика прокси, которые служат двум разным целям. Давайте посмотрим на них:

Обработчик для Ext.create

CreateHandler – это метод, перехватывающий вызовы Ext.create. Он проверяет, является ли аргумент, переданный в Ext.create(),

  • a String: в этом случае предполагается, что это имя класса, для которого должен быть создан экземпляр.
  • Объект: Обычно это указывает на то, что клиент отправляет конфигурацию, содержащую xtype или xclass, предоставляющую дополнительные сведения о классе, который служит шаблоном. Все свойства, содержащиеся в объекте, обычно передаются в конструктор целевого класса, кроме xtype/ xclass
{
    "xtype": "alias-of-class",
    // or "xclass": "fqn.of.class"
    "cArg1": "foo",
    "cArg2": "bar"  
}

Как показано на Рис. 9, после успешного разрешения целевого класса с учетом внутренних особенностей Ext JS обработчик запускает событие classresolved вместе с информацией об имени класса и прототипе JavaScript. em> разрешенного класса.

Обработчик для Ext.Factory

FactoryHandler реализует ловушки для свойств, запрошенных клиентом (используя get), и ловушку для любого метода, который может быть методом фабрики. .

Фабричные методы основаны на типах, которые публикуются с определениями классов. Псевдонимы постоянно используются в Sencha Ext JS и облегчают ленивое создание экземпляров. Эти псевдонимы используют префиксы, которые представляют домен, который они обслуживают, например, псевдонимы для Ext.data.Store имеют префикс "store.”, Ext.app.Controller используют префикс "controller.” и так далее.

Ext.Factory связывает эти конфигурации с соответствующими фабричными методами, и именно здесь в игру вступает обработчик применения, ранее установленный обработчиком get: он работает так же, как CreateHandler и отличается лишь незначительными деталями при тщательном анализе аргументов фабричных методов. В конце концов, этот прокси вызовет событие classresolved по той же причине, что и его дополнение.

if (cls) {
    const className = Ext.ClassManager.getName(cls);
    me.fireEvent("classresolved", me, className, cls);
}

Проксирование конструкторов

Все сводится к ConstructorInjector: после публикации события classresolved ConstructorInjectorиспользуется с наблюдатель, чтобы решить, должен ли он вводить зависимости в конструктор целевого класса (информация о целевом классе предоставляется с подробностями события). Он проверяет, является ли класс внедряемым, и если это так, он применяет ловушку для конструктора целевого класса.

Подумайте об шаблоне стратегии, который позволяет нам динамически изменять детали реализации. Следует отметить, что ConstructorInjector работает с объектами, переданными конструкторув качестве аргументов, а не список аргументов. Поэтому ConstructorInjector больше похож на Property Injector. Название было выбрано потому, что ConstructorInjector лучше отражает шаг в цепочке сборки, в которую вплетен инжектор: Реализация для Sencha Ext JS framework остается простой и предоставляет в основном (но не менее) qol-улучшений для этого фреймворка.

Ловушка для конструктора будет проверять целевой класс на наличие всех необходимых зависимостей (определяемых как метаинформация), а затем использовать Dependency Resolver, чтобы создать что-то полезное из определения привязки, которое ранее был зарегистрирован с coon.core.io.Container.bind().

Дополнительные ресурсы

Репозиторий для coon.js и реализации IoC-Container находится на Github. Он уже используется в новейшей версии conjoon 1.0.4, которая является промежуточным релизом и прокладывает путь для дополнительных подключаемых модулей аутентификации, запланированных для 1.1.0.

Ранее я писал о Sencha Ext JS: Beyond ES 5.



Соединять

Торстен Зуков-Хомберг| Гитхаб| Твиттер| Ютуб| Инстаграм

Значительные изменения

  • 05 марта 2023 г.: Незначительные исправления кода.
  • 16 января 2023 г .: Незначительные обновления формулировок и содержания во время работы над немецким переводом.
  • 17 декабря 2022 г .: опубликована первая часть.