В этой статье я объясню, как мы использовали инверсию зависимостей для разделения всех модулей ввода-вывода в нашем React-Redux-Application, что позволило полностью изменить поведение приложений, установив логическое значение во время начальной загрузки.

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

Один из моих любимых и, на мой взгляд, наиболее действенных программных принципов - это Принцип инверсии зависимостей, или сокращенно DIP. Это D в знаменитых принципах SOLID, и он управляет большинством известных мне шаблонов, которые заботятся о разделении и модульности. Более того: использование DIP действительно отличает настоящий объектно-ориентированный дизайн от процедурного, согласно Роберту К. Мартину:

Действительно, именно эта инверсия зависимостей является отличительной чертой хорошего объектно-ориентированного дизайна. […]. Если зависимости [программы] инвертированы, она имеет объектно-ориентированный дизайн. Если его зависимости не инвертированы, он имеет процедурный дизайн.

Роберт К. Мартин - Гибкая разработка программного обеспечения: принципы, шаблоны и практики

Теперь некоторые люди могут остановиться и сказать: но эта статья о сокращении, которое прославляет функциональное программирование. Какое мне дело до объектно-ориентированного программирования и объектно-ориентированного дизайна?

На мой взгляд, не имеет значения, используете ли вы FP или OOP. Понимание и применение DIP чрезвычайно поможет вам в обеих дисциплинах, поскольку вам всегда придется каким-то образом обрабатывать зависимости - будь то класс или функция.

Что такое инверсия зависимостей?

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

Как видно из названия, инверсия зависимостей имеет отношение к зависимостям классов / функций, точнее, к абстракции этих зависимостей.

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

поток управления выглядит следующим образом: Представление реагирует на определенное взаимодействие с пользователем, вызывая функцию в DataService, которая сама вызывает функцию. API или базы данных для запроса необработанных данных, применяет некоторые правила домена до или после вызова, а затем возвращает данные в представление.

Что мы действительно можем видеть на диаграмме, так это то, что цепочка зависимостей точно следует этому потоку управления - представление зависит от DataService, который сам зависит от API.

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

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

Теперь давайте посмотрим, что можно сделать с помощью интерфейса и, следовательно, с применением DIP:

Просто поместив интерфейс DataRepository между службой домена и API, мы уже достигли независимой архитектуры. Что здесь важно: служба домена владеет этим интерфейсом (или: интерфейс является частью нашего домена), в то время как API реализует его только извне.

Это означает, что домен теперь зависит только от себя (это то, что мы хотели) и только указывает что ему нужно от внешнего мира, но не как это нужно. DataService может работать со всем, что реализует интерфейс DataRepository - будь то реальная база данных, HTTP-API или просто In-Memory-Repository с фиктивными данными. Все, что нужно сделать, это ввести правильную зависимость на этапе начальной загрузки нашего приложения.

Далее: мы достигли того, что ApiService фактически зависит от домена, а не от домена в зависимости от API. Это означает, что направление зависимостей обратное потоку управления (красные и черные стрелки) - отсюда и слово инверсия зависимостей.

Как это делается с помощью Redux-Thunks?

Теперь давайте перенесем это в классическое приложение react-redux: есть несколько контейнеров + компоненты (представление), которые отправляют действия (домен) в redux для изменения состояния. Некоторые из этих действий являются асинхронными, поскольку им необходимо получить определенные данные из асинхронного API. Простой способ сделать это - использовать промежуточное программное обеспечение redux-thunk. Следовательно, простая архитектура приложения может выглядеть так:

Реализующий код может выглядеть так (конечно, это одно из наших любимых приложений Todo):

Как и в предыдущем абстрактном примере, контейнер (представление) напрямую зависит от действия (домена) redux-thunk, которое напрямую зависит от метода выборки (API).

Те же проблемы из абстрактного примера теперь возникают более четко:

  1. Прямая связь позволяет довольно стабильному коду домена (преобразователю) зависеть от обычно довольно нестабильного API. Всякий раз, когда API немного меняется (меняется путь), нам нужно будет коснуться преобразователя, а также его тестов.
  2. При непосредственном связывании действия с API тестировать этот преобразователь очень сложно (прямо сейчас вам придется как-то издеваться над глобальным методом fetch)
  3. При разработке мы не всегда можем позволить себе роскошь уже существующего или стабильного API и предпочитаем работать с Mock-Data - наш единственный шанс сделать это с таким подходом - запустить какой-то JSON-Mock-Server, который действительно отвечает HTTP-запрос по правильному URL.

Итак, здесь мы также хотим использовать инверсию зависимостей, чтобы абстрагироваться от API за интерфейсом и позволить преобразователю зависеть только от этого интерфейса.

Давайте очистим

Чтобы понять суть этого примера, я выделил наиболее важные фрагменты кода. Вы можете найти полную рабочую реализацию примера в моем репозитории Github

Целевая структура должна выглядеть примерно так:

Итак, сначала давайте извлечем интерфейс TodoRepository и создадим APIService с именем HttpTodoRepository, реализующий его. Обратите внимание, как этот простой рефакторинг уже отделяет роль (являющуюся хранилищем данных) от реализации и используемой технологии (используйте HTTP для получения данных):

Теперь нам нужно каким-то образом передать HttpTodoService как TodoRepository преобразователю. Мы могли бы сделать это, добавив его в список параметров самого преобразователя, но это будет означать, что все контейнеры, отправляющие действие, должны знать об услуге, и это просто не их задача (подумайте: Шаблон единой ответственности ).

К счастью, redux-thunk позволяет нам указать третий параметр во время начальной загрузки, который будет передаваться каждому вызываемому преобразователю. Мы можем просто использовать этот параметр как механизм внедрения зависимостей:

И теперь мы можем использовать его в самом преобразователе следующим образом:

TodoRepository теперь является третьим параметром функции преобразования. Кроме того, thunk-action больше ничего не знает о конкретной реализации в HttpTodoRepository. Мы успешно отделили домен от API.

Чтобы увидеть реальную мощь этой абстракции, давайте теперь также напишем MockTodoRepository, реализующий наш интерфейс TodoRepository:

Все, что осталось сделать, это передать MockTodoRepository вместо HttpTodoRepository, когда наш магазин создается, и приложение внезапно начинает работать полностью независимо от интернет-соединений и без работающего API.

Это можно улучшить, используя переменную среды, которая будет передаваться извне, чтобы решить, какой из двух репозиториев-реализаций будет использоваться:

Установка этой переменной может быть полностью динамической. Обычно мы делаем это с помощью DefinePlugin Webpack, где мы устанавливаем для MOCK_API значение true или false в зависимости от заданного параметра во время сборки. Таким образом, мы можем запустить наше приложение в любом режиме из интерфейса командной строки (npm start против npm start:mock)

С помощью одной простой переменной мы теперь можем полностью изменить поведение данных нашего приложения, не изменяя при этом его логическое поведение. В этой архитектуре изменение вашего API с REST на GraphQL или с HTTP на HTTP2 является не чем иным, как написанием новой службы, реализующей интерфейс DataRepository.

Но как это масштабировать?

На самом деле мы используем эту архитектуру в более крупном приложении и получили очень хорошие впечатления от нее. Чтобы иметь возможность масштабирования, мы фактически не внедряем сам APIService, но (также зависящий от среды) Factory, содержащий все наши зависимости. Таким образом, каждый преобразователь имеет возможность запрашивать любую нужную ему зависимость, а не только API.

Когда наш API был недоступен в течение двух недель, мы все еще могли легко разработать наше интерфейсное приложение и даже позволить нашим заказчикам логически протестировать его, подделав поведение серверной части с помощью API в памяти (так что операции Create / Update / Delete, где это возможно).

tl; dr

  • вы можете использовать thunk.withExtraArgument(deps) функцию redux-thunk в качестве механизма внедрения зависимостей
  • чтобы избежать тесной связи, позвольте вашему преобразователю (или вашему домену) указывать зависимости, которые ему нужны, как интерфейсы, а не как уже конкретные реализации
  • реализовать эти интерфейсы в другом месте и передать их в преобразователи с помощью функции выше во время начальной загрузки
  • используйте переменные среды (например, установленные Webpacks DefinePlugin), чтобы решить, какую реализацию ваших доменных интерфейсов вы передаете в приложение, чтобы управлять поведением ваших приложений на самом высоком уровне