Узоры инверсия контроля (инверсия зависимостей, DI) известны давно, но пока не нашли широкого применения во фронтенд-мире.
Этот доклад отвечает на вопрос, как использовать возможности JS для построения надежной архитектуры на основе DI-контейнера.
Автор отчета – Евгений ftdebugger Шпилевский, руководитель группы разработки интерфейсов Яндекс.
Коллекций.
— Насколько я знаю, инверсия зависимостей, DI-контейнеры и другие паттерны, придуманные ещё в 70-х, не так уж близко вошли в мир фронтенд-разработки.
Вероятно, для этого есть причина.
Частично проблема в том, что многие люди не понимают, зачем они вообще нужны.
Я постараюсь объяснить, что такое DI, что такое инверсия зависимостей, как она может помочь вам в вашем проекте и какие приятные бонусы вы можете получить, если начнете ее использовать.
Начнем с самой базовой концепции.
Что такое инверсия зависимостей? Когда мы проектируем какую-либо фичу, мы хотим разложить ее на такое маленькое состояние, чтобы конкретный отдельный класс выполнял строго одну функцию и не более того.
В примере на слайде есть User и UserSettings. Все, что касается пользовательских настроек, вынесено в отдельный класс.
Как мне это сделать? Есть два подхода: создать экземпляр этого класса внутри или принять его извне.
Это основной принцип инверсии зависимостей.
Если мы создадим несколько экземпляров снаружи, а затем перенесем их внутрь, мы получим некоторое преимущество.
На самом деле причина только одна — мы больше не полагаемся на конкретную реализацию, а начинаем полагаться исключительно на интерфейсы.
Когда мы говорим, что какая-то небольшая декомпозированная функция вынесена в отдельный класс, для нас уже не имеет значения, как она была реализована.
Мы можем просто использовать его, и не имеет значения, какой экземпляр какого класса будет подсунут, лишь бы интерфейсы совпадали.
А так как в JS нет интерфейса, то вызывается более крупный метод и это нормально.
Пример UserSettings немного вырван из контекста, вряд ли это тот код, который вы пишете каждый день.
Чуть более приземленный код, более близкий к реальности и реалиям JS, — синхронный.
Если мы хотим создать модель данных, нам нужно откуда-то получить эти данные.
Один из способов, самый распространенный — зайти через Ajax на сервер, синхронно получить данные и создать их.
Если мы начнем писать этот код в стиле инверсии зависимостей, то получим примерно следующее.
Этот код не только написан не самым оптимальным образом, но и достаточно чудовищен.
Похоже, нам нужен был только один компонент, который будет отображать список изображений нашего пользователя, и для этого нам нужно было написать такой большой код. В реальном проекте количество компонентов идет на десятки, и такая сборка даже для страницы будет достаточно чудовищной.
Что может быть сделано? Сделайте код еще хуже.
Наш первый DI-контейнер, самый примитивный, мы можем сделать сразу.
Мы возьмем все, что написали ранее, и упакуем в методы.
Давайте поместим их в хеши, которые назовем DI, и будем считать это DI-контейнером.
Тогда мы сделаем первый шаг к тому, чтобы сделать наше будущее немного лучше.
Суть в том, что в любой момент времени, когда вам понадобится пользователь, настройки, картинки или любой из сотен других методов, которые можно было бы здесь описать, вы можете взять DI, вызвать его, и вам будет все равно, как устроено.
.
Весь код, отвечающий за создание ваших моделей, классов и компонентов, будет изолирован в одном контейнере.
Естественно, писать код таким образом будет сложно; этот файл быстро станет большим.
У него уже есть проблемы.
Тот, кто немного изучит код, обнаружит, что как минимум пользователь загружается дважды.
Это плохо и этого следует избегать.
Кроме того, весь код является шаблонным.
Мы можем заменить его, используя некоторые функции.
И мы можем написать свой контейнер, который решит все наши проблемы, это будет круто и быстро.
Что вы хотите.
Это то, что я хотел от него.
Он должен сам искать занятия.
Их нужно откуда-то импортировать, затем создать и использовать.
Я этого не хочу, пусть контейнер справится сам.
Я хочу, чтобы он создавал экземпляры асинхронно.
Если мы изначально зададим такое требование к контейнеру, у нас не возникнет проблем с тем, как мы будем создавать экземпляры в дальнейшем, будут ли они запускаться в Ajax, будут ли тратиться на это время или нет, будут ли они запускаться синхронно.
.
Если создание асинхронное, все уже предусмотрено.
Повторное использование.
Это очень важно.
Если мы начнем писать большой контейнер и не будем повторно использовать экземпляры в нем, мы рискуем получить массу бесполезных, ненужных запросов к серверу.
Я хочу избежать этого.
Последний пункт. Я почти уверен, что никому не понравился императивный простой код, который я показал на предыдущем слайде.
Вместо этого я хочу написать обычный декларативный JSON, в котором бы все это было описано, и у меня бы все работало.
Давайте шаг за шагом выясним, как можно решить каждую проблему.
Как мы можем преподавать классы с динамическим поиском? Вы можете использовать вебпак, он имеет функцию динамического импорта.
Этот код, который кажется немного странным, будет работать достаточно хорошо после объединения Webpack. Более того: все классы, попадающие под эти условия, автоматически станут отдельными бандлами и начнут загружаться асинхронно.
И весь наш код загрузки классов будет выглядеть так.
Мы просто синхронно запрашиваем класс и получаем его.
Функция getClass может выглядеть именно так, как вы хотите.
Если вы хотите статически загрузить некоторые зависимости, вы можете написать их здесь.
Если вам нужна более разумная комплектация, вы можете описать ее здесь.
Все это, в общем-то, зависит от вас.
Существует два способа создания экземпляров.
Можно придумать жуткую конфигурацию того, как это будет происходить, или ввести какую-то условность.
Мне нравится путь, основанный на соглашениях, потому что вам не нужно писать код, вам просто нужно что-то запомнить, а затем всегда следовать этим соглашениям.
В данном случае я ввожу следующее соглашение: любой класс должен иметь статический фабричный метод. Он будет отвечать за то, как будет построен этот класс и какие зависимости к нему будут добавлены.
Он несет ответственность за все.
CreateInstance оказался очень простым; фабрика может быть синхронной или асинхронной.
Ну, код простого создания пользователя стал другим, но по-прежнему уродливым.
Повторное использование экземпляров.
Для этого мы вводим новую концепцию.
Мы назначим идентификатор любому экземпляру, созданному в контейнере DI. Мы придумаем эти идентификаторы; они будут описывать некоторые сущности из нашей системы.
В этом случае в последней строке мы опишем текущего пользователя.
Мы каким-то образом получим класс ранее написанной функции, создадим из него экземпляр и поместим его в кеш.
В этом примере есть пара ошибок.
Полная реализация метода CreateInstance с учетом кэша занимает примерно 100 строк.
Какая разница? возможно, прочитаю это позже .
И последнее — зависимости.
Мы опишем обычный хеш, где ключи — это идентификаторы из DI-контейнера, а значения — конфигурация, с помощью которой мы можем создать все описанное выше.
Давайте возьмем и создадим класс UserSettings. В currentUser мы возьмем класс пользователя и поместим его в качестве зависимостей в UserSettings. Что такое пользовательские настройки? То, что мы анонсировали ранее.
Описав такую структуру, можно разработать простой алгоритм, который будет проходить по всему образующемуся дереву зависимостей.
Фактически там граф формируется из этого дерева.
Такой алгоритм соберет все, что нам нужно.
Чтобы уменьшить количество шума на слайде, я введу еще одно соглашение.
Почему бы не написать что-нибудь кроме JSON и не описать все в более простой форме? Если вам нужен класс, мы просто возьмем его как строку; если нам нужен класс и зависимости, мы будем использовать массив.
Неважно, какой формат вы выберете.
Главное, чтобы вам нравилось и вы понимали, что здесь происходит. Это тот же слайд, только переписанный.
В результате, если мы все это реализовали и собрали, то получим автоматическую бандлинг.
Здесь вы получите такую интересную опцию, что если вы сделаете запрос к текущему пользователю, то ваш DI-контейнер сможет одновременно загружать бандл, содержащий этот класс, и уже асинхронно загружать нужные ему зависимости.
Дело в том, что теперь у него есть информация, где находится класс — возможно, в каком-то бандле — и какие зависимости ему потребуются.
Обыденный пример: если мы хотим сделать компонент, который будет отображать список картинок, то JS, где лежит код, рисующий эти компоненты с картинками, просто загрузится, и в этот же момент может произойти запрос к серверу.
быть отправлено для получения данных.
Когда они оба наконец исполнятся, мы получим это.
Этого можно добиться, просто используя DI-контейнер, больше ничего не требуется.
Наша доставка зависимостей проста.
Когда вы только начинаете использовать DI-контейнер на полную катушку, там начинает появляться все из вашего мира: все библиотеки, все распространенные утилиты, компоненты, модели данных.
И если в какой-то момент вам нужно что-то получить, вы можете просто описать одну строку зависимостей, и не беспокоиться о том, как это должно быть создано, настроено или описать весь сложный процесс, который должен пройти все этапы.
Вы просто получите его из контейнера как зависимость.
Повторное использование кода.
Если мы начнем писать так, чтобы ни в одном отдельном классе не создавать явно экземпляры других классов, то мы перестанем быть привязанными к реализации.
Мы можем вставлять в класс любые экземпляры в качестве зависимостей.
В рамках одного и того же компонента изображения мы можем загружать любые изображения и вставлять их откуда угодно.
Внутри контейнера все это будет отличаться просто строкой в конфигурации.
Вы просто возьмете другую зависимость и все, для вас это будет очень просто.
Как только контейнеру отводится очень важное место в вашем проекте, вы начинаете использовать его просто как основу для всего.
Я хочу продемонстрировать, как можно сделать обычный многостраничный SPA с помощью DI-контейнера.
Возьмем какой-нибудь Роутер.
Его реализация не важна.
Важно, что при совпадении с каким-либо URL-адресом этой странице будет присвоено какое-то имя.
В данном случае возможно домашний и профильный.
Давайте возьмем наш контейнер и опишем его как ключи дома и профиля.
Опишем все, что мы не хотим туда попасть.
И мы хотим получить какой-то компонент, который мы возьмем и вставим в тело.
В данном случае это какой-то Layout. Лейаут используется и там и там, просто к нему добавляются разные зависимости.
Что делать дальше? На этом этапе не важно, какие компоненты будут идти глубже, потому что они уже как-то работают, их кто-то настроил.
Нас волнует только уровень абстракции, над которым мы сейчас работаем.
Всё, мы можем запросить какую-то зависимость от DI-контейнера по ключу, по имени страницы.
В данном случае это знак на Макете, и этот Макет уже будет содержать все необходимые данные, все компоненты, все, что мы хотим сделать.
Остаётся только добавить его в тело.
А как насчет проверяемости всего этого? Как только мы начинаем это использовать, возникает ситуация, что классы не зависят напрямую от контейнера.
Вы никогда и нигде не будете использовать контейнер напрямую в том смысле, что мне нужна такая зависимость, я возьму и получу.
Нет, скорее, он будет лежать в самом низу архитектуры, в самом бутстрепе вашего приложения, как показано на предыдущем слайде.
Фактически ваши занятия от этого никак не зависят; вы можете взять их откуда угодно и перенести куда угодно.
Все зависимости передаются при создании, и если мы говорим о тестировании, то в это место мы легко можем вставить моки, данные фикстур и что угодно — просто потому, что так уже работает. DI-контейнер заставил нас написать код, чтобы все работало именно так.
Несколько примеров.
Во время создания, когда мы проводим тестирование и хотим подправить настройки пользователей, чтобы они делали именно то, что мы хотим, а не то, что написали, мы можем создать пользователя и подсунуть под него тестовые данные.
Мы можем использовать для этого контейнер — дерево зависимостей уже сформировано, некоторые из них мы можем переопределить.
В будущем, просто по логике работы с DI, каждый, кто когда-либо хотел получить UserSettings, получит их, где бы они ни находились.
Мы также можем использовать его для тестирования.
Есть еще один интересный пример.
Если предположить, что все модели данных, которые идут куда-то на сервер за данными, будут использовать какой-то ajaxAdapter, написанный специально для этой цели, то во время тестов мы можем заменить его собственным классом TestAjaxAdapter, который сможет реализовать логику.
Именно так это и реализовано, например в синоне, если кто-то пытался с его помощью взломать.
Или мы можем пойти еще глубже.
Мы реализовали логику в этом адаптере так, что при первом использовании в тестах он начинает записывать запросы и ответы от реального сервера, а при повторных запусках просто воспроизводит их из кеша.
Мы добавляем этот кеш в репозиторий вместе с нашими тестовыми данными.
А когда мы хотим делать тестирование на фикстурах и боимся, что они со временем изменятся из-за того, что логика общения с сервером здесь уже реализована, мы заменяем TestAjaxAdapter. В репозитории формируется некий кэш, который потом будет использоваться повторно.
Как это можно использовать еще более интересным образом? Здесь уже упоминалось Тестирование Близнецов .
Это разновидность визуального регрессионного тестирования.
Кто не знает, Gemini — это метод тестирования, при котором мы делаем скриншот некоторых наших блоков на готовой странице, помещаем его вместе с тестовыми данными в репозиторий, и когда мы хотим провести обратный тест, мы повторно запусти, заново сделай скриншот и попиксельно сравни его.
Если где-то пиксели не совпадают, тест не пройден.
Это очень простой и эффективный вид тестирования, проверка визуальных регрессий.
Мы работаем с CSS, у него есть особенность: он постоянно ломается.
Близнецы помогают нам избавиться от этих срывов.
Что мы делали в этом месте? Поскольку всё было реализовано через DI-контейнер, мы специально подготовили сервер, на который в качестве параметров можно было передавать идентификаторы из DI-контейнера.
Он просто сформировал его, нарисовал на странице один тот компонент, который мы хотели.
В данном случае есть что-то связанное с рецептами, какая-то карта, реальные данные, на которых проводились тесты, реальный скриншот. После запуска теста были заменены ajaxAdapter и сформирован кеш, связанный с тем, как общается сервер.
У нас есть эти данные — постоянно воспроизводятся с течением времени, и тесты становятся стабильными.
Этот подход применим к любому типу испытаний.
Если вы хотите покомпонентно войти в Selenium и щелкнуть мышью, вас ничто не остановит, потому что вы получаете полностью работающую часть функциональности, которую хотите использовать.
А можно даже сделать несколько блоков одновременно, просто выведите их на страницу и кликните по ним.
Блоки имеют между собой какие-то событийные связи или что-то еще.
Даже если блоки не соответствуют реальному сайту, таким образом можно проверить некоторую логику.
Я прочитал краткий отчет о том, что такое DI. Надеюсь, это кого-то заинтересует. Если вам нужна более подробная информация, я доступен по следующим ссылкам: почта , GitHub , Телеграмма , Твиттер .
Вот ссылки, по которым можно найти новую информацию о том, что здесь произошло.
Например, полностью реализованный DI-контейнер, о котором я говорил, Inversify DI-контейнер, — это действительно классная вещь для TypeScript. Здесь есть еще несколько ссылок, которые помогут вам разобраться, как все собрать воедино.
- github.com/ftdebugger/di.js
- dev.to/kayis/dynamic-imports-with-webpack-2
- www.npmjs.com/package/inversify
- github.com/vlyahovich/quantum-router
Теги: #open source #CSS #JavaScript #frontend #interfaces #gemini #Ajax #инверсия зависимостей
-
Черные Дыры При Разработке Веб-Проекта
19 Oct, 24