9.1 Dependency Inversion

Помнишь, мы когда-то говорили, что в серверном приложении нельзя просто так создавать потоки через new Thread().start()? Потоки должен создавать только контейнер. Теперь мы разовьем эту мысль еще сильнее.

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

  • Модули верхнего уровня не должны зависеть от модулей нижнего уровня. И те, и другие должны зависеть от абстракций.
  • Абстракции не должны зависеть от деталей. Реализация должна зависеть от абстракции.

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

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

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

Очень хорошим решением является безумная идея о том, чтобы сконцентрировать создание новых объектов в рамках специализированных объектов и модулей — фабрик, сервис локаторов, IoC-контейнеров.

В каком-то смысле такое решение следует Принципу единственного выбора (Single Choice Principle), который говорит: "Всякий раз, когда система программного обеспечения должна поддерживать множество альтернатив, их полный список должен быть известен только одному модулю системы".

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

Пример 1

Было бы разумно вместо new ArrayList писать что-то типа List.new(), JDK подставила бы вам правильную реализацию листа: ArrayList, LinkedList или даже ConcurrentList.

Например, компилятор смотрит, что к объекту есть обращения из различных потоков и ставит туда потоко-безопасную реализацию. Или слишком много вставок в середину листа, тогда реализация будет основана на LinkedList.

Пример 2

Это уже произошло с сортировками, например. Когда последний раз ты писал алгоритм сортировки для сортировки коллекции? Вместо этого теперь все пользуются метод Collections.sort(), а элементы коллекции должны поддерживать интерфейс Comparable (сравниваемый).

Если в метод sort() передать коллекцию из меньше чем 10 элементов, ее вполне можно отсортировать сортировкой пузырьком (Bubble sort), а не Quicksort.

Пример 3

Компилятор уже следит за тем, как ты конкатенируешь строки и заменят ваш код на StringBuilder.append().

9.2 Инвертирование зависимостей на практике

Теперь самое интересное: давай подумаем, как нам совместить теорию и практику. Каким образом модули могут корректно создавать и получать свои “зависимости” и не нарушать Dependency Inversion?

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

  • что модуль делает, какую функцию выполняет;
  • то модулю нужно от его окружения, то есть с какими объектами/модулями ему придется иметь дело;
  • и как он это будет получать.

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

И тут возможны следующие варианты:

  • модуль сам создает объекты;
  • модуль берет объекты из контейнера;
  • модуль понятия не имеет откуда берутся объекты.

Проблема в том, что для создания объекта необходимо вызвать конструктор конкретного типа, и в результате модуль будет зависеть не от интерфейса, а от конкретной реализации. Но если мы не хотим, чтобы в коде модуля объекты создавались явно, то можно использовать паттерн Фабричный Метод (Factory Method).

"Суть заключается в том, что вместо непосредственного инстанцирования объекта через new, мы предоставляем классу-клиенту некоторый интерфейс для создания объектов. Поскольку такой интерфейс при правильном дизайне всегда может быть переопределен, мы получаем определенную гибкость при использовании низкоуровневых модулей в модулях высокого уровня".

В случаях, когда нужно создавать группы или семейства взаимосвязанных объектов, вместо Фабричного Метода используется Абстрактная Фабрика (Abstract factory).

9.3 Использование Service Locator

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

Этот подход реализуется шаблоном Локатор Сервисов (Service Locator), основная идея которого заключается в том, что в программе имеется объект, знающий, как получить все зависимости (сервисы), которые могут потребоваться.

Главное отличие от фабрик в том, что Service Locator не создает объекты, а фактически уже содержит в себе инстанцированные объекты (или знает где/как их получить, а если и создает, то только один раз при первом обращении). Фабрика при каждом обращении создает новый объект, который ты получаешь в полную собственность и можешь делать с ним что хочешь.

Важно! Локатор сервисов выдает ссылки на одни и те же уже существующие объекты. Поэтому с объектами, выданными Service Locator, нужно быть очень осторожным, так как одновременно с тобой ими может пользоваться кто-то еще.

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

Service Locator иногда называют антипаттерном и не рекомендуют использовать (потому что он создает неявные связности и дает лишь видимость хорошего дизайна). Подробно можно почитать у Марка Симана:

9.4 Dependency Injection

Модуль вообще не заботится о “добывании” зависимостей. Он лишь определяет, что ему нужно для работы, а все необходимые зависимости ему поставляются (внедряются) извне кем-то другим.

Это так и называется — Внедрение Зависимостей (Dependency Injection). Обычно требуемые зависимости передаются либо в качестве параметров конструктора (Constructor Injection), либо через методы класса (Setter injection).

Такой подход инвертирует процесс создания зависимости — вместо самого модуля создание зависимостей контролирует кто-то извне. Модуль из активного эмитента объектов становится пассивным — не он создает, а для него создают другие.

Такое изменение направления действия называется Инверсия Контроля (Inversion of Control), или Принцип Голливуда — “Не звоните нам, мы сами вам позвоним”.

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

Обеспечение модуля всем необходимым для работы — это отдельная задача, которой должен заниматься соответствующий “специалист” (обычно управлением зависимостями и их внедрениями занимается некий контейнер — IoC-контейнер).

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

Не будет преувеличением сказать, что использование интерфейсов для описания зависимостей между модулями (Dependency Inversion) + корректное создание и внедрение этих зависимостей (прежде всего Dependency Injection) являются ключевыми техниками для снижения связанности.

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

Принцип Inversion of Control (вместе с Dependency Injection и Service Locator) детально разбирается Мартином Фаулером. Есть переводы обеих его статей: "Inversion of Control Containers and the Dependency Injection pattern" и “Inversion of Control”.