Работа с зависимостями - один из наиболее болезненных вопросов, решение которого зачастую сопровождается многочисленными компромиссами и отдаленными последствиями для всего приложения.

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

С другой стороны, это определение находится в контексте UML, когда под зависимостями часто понимают более широкое понятие, включающее и ассоциацию с её разновидностями - агрегацией и композицией. Это тяготеет к самому широкому толкованию в виде любого стороннего класса, необходимого для работы, если рассмотреть класс структурно, не привязываясь к какой-либо семантике. Т.е. отношение зависимости в классическом варианте не обязательно сопровождается инстанцированием, достаточно любого взаимодействия, например, вызова метода, а в этом случае вопрос замены зависимости может находиться уже на плечах наследования или интерфейсов. На более высоком уровне в игру вступают уже зависимости артефактов друг от друга, сборок и т.п. Для целей статьи я буду подразумевать самые простые структурные зависимости - объекты, необходимые для работы другого.

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

По такой шкале можно сказать, во сколько раз один пакет или класс имеет больше зависимостей в сравнении с другим, но не более, выглядит она достаточно бесполезной. Наиболее полезен анализ графа зависимостей при очень малом их количестве, например, 1-2, так и при очень большом. В первом случае можно попытаться их удалить вообще, в другом - уловить нарушение архитектуры, хотя при будущем масштабировании вполне есть шанс снова их туда возвращать.

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

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

Допустим, что зависимостью выступает объект, который может делать очень многое. Возможно, такое его устройство обусловлено очень сложной предметной логикой, а расслоение затруднит его понимание и отладку. Такая зависимость может заменить собой несколько, с одной стороны - упрощая конструирование зависимого объекта, с другой стороны - делая его заложником своей универсальности.

Зависимость эволюционирует, постоянно изменяется и при будущем расслоении нужно будет исправлять использующий её класс. Выходит, что можно рассмотреть еще одну характеристику - степень использования и интеграцию в функционал зависимого класса, где даже в случае большого количества зависимостей их вызовы могут быть редкими или ограничиваться вспомогательной логикой, как и наоборот. Вероятно, здесь может быть некая связь с идеями рефакторинга в виде Shotgun Surgery (Стрельба дробью) - изменения, затрагивающие сразу множество классов. Эти вызовы даже можно посчитать, хотя их распределение по коду также играет роль.

Косвенно, это пересекается с такой особенностью, как стабильность api. Существует очень условное деление на стабильные зависимости и не очень. Увидеть границу очень трудно, а совсем полное отсутствие изменений ведет к легаси, несовместимости с новыми версиями языка, накоплению уязвимостей в коде и прочим неприятностям. С другой стороны, некоторые ломают совместимость даже в минорных версиях, доставляя много головной боли другим.

Для подстраховки можно признать, что стабильности в этом деле быть не может и в любой момент сторонняя библиотека может отказать, перестать работать и начать приносить больше вреда чем пользы. Удалить зависимость в сквозной логике чрезвычайно сложно, хотя и писать для неё постоянные обертки также. Кроме того, эти обертки станут позже источником проблем из-за тесной своей связи с либой, что требует своевременной реакции на изменения. Поэтому распространен подход, где сторонние библиотеки стараются держать "на периферии", в таком случае внезапно умрет лишь часть приложения.

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

В объектном мире у зависимостей есть жизненный цикл и проникать в зависимую цель они могут по-разному. В случае прямого инстанцирования некий объект играет роль Creator или даже Information Expert по отношению к создаваемым, обладая достаточными знаниями и часто способен ими в какой-то мере управлять. С точки зрения бытовой логики все сходится - кто умеет, тот и может, поэтому сомнительна затея называть антипаттерном создание зависимости через new (т.н. антипаттерн Control Freak), тем более, что некоторые руководства по построению api требуют упрощения создания и использования объектов без любой обширной инициализации. Это проблематично осуществить без прямого инстанцирования объектов, поскольку библиотеки часто не поддерживают никаких стандартов фреймворков или контейнеров, да и это завязывает библиотеку на сторонний проект и также рождает зависимость от него, ну а совсем простые фабрики могут быть недостаточно гибкими или же скрывать нужные настройки объектов.

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

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

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

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

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

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

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

Рассмотрим сам процесс появления объекта в типовом ООП-языке. Такая зависимость может быть получена извне, а также создана внутри. В первом варианте должно быть звено, которое управляет зависимостями до их использования в целевом классе, передавая их ему через конструктор, метод, рефлексивно, с помощью AST-трансформации и подобным образом. Как правило, сборка потребует фабрики, билдера или самого упрощенного метода, который пробрасывает нужное от одного объекта к другому, идет обмен гибкости на сложность. Второй вариант - создавать их непосредственно, тогда в случае полей передача зависимостей дочерним объектам сокращается до вызова родительского конструктора или метода инициализации, упрощение обменивается на гибкость.

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

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

Кроме того, в случае множества разных фабрик создание объектов разбрасывается по всему приложению, как и создание самих фабрик - в простом случае это мало чем отличается от создания зависимостей на полях самого создаваемого класса (хотя опять таки это могут обозвать антипаттерном "Диктатор"\Control Freak), который ровно также может их донастроить и сам, имея в том числе перегрузку конструктора или сеттеры для подмены зависимостей. Преимущество и упрощение с дефолтными зависимостями в этом случае может быть выше, чем возня с фабриками и проблемами в иерархии уже самих фабрик. Проблемы могут тут быть из-за требования многих языков вызова родительского конструктора самым первым, что затрудняет любую донастройку зависимостей, который дочерний класс захочет подменить.

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

Рассмотрим два крайних случая - все зависимости инстанцируются в коде и разбросаны по нему, второй - все зависимости поставляются извне каким-либо способом.

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

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

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

Где-то между этими проблемами балансирует IoC-контейнер, тесно переплетаясь с темами инверсии зависимостей, сервис-локатором и т.п. в чем очень легко запутаться. С некоторыми недостатками самого контейнера вполне можно смириться ценой предлагаемого удобства: вероятность рантаймовых ошибок, невалидных объектов можно уменьшить разными проверками, да и эти ошибки все же будут относительно редкими. Сложность понимания, отслеживание графа зависимостей - проблема, но тоже не слишком критичная, поскольку код и так быстро забывается. Растущая и неудобная конфигурация может быть проблемой, но уменьшается по мере стабилизации api.

А вот точка отказа может быть серьезной и неочевидной опасностью, можно упереться в замкнутый круг: нужно переводить приложение на новую версию, но это невозможно из-за особенностей контейнера, работа же на старой тоже невозможна из-за ошибок или либ, особенно если это связано с безопасностью. При этом избавиться от него уже никак нельзя - он пропитывает весь код приложения. Можно попробовать уменьшить риск, отдавая IoC-контейнеру только бизнес-логику, которая наиболее часто меняется. Тогда в критичном случае нужно будет помучиться только с ней, выпиливая только контейнер и не трогая остальное. С другой стороны, такое же может произойти с любой сквозной логикой, например, логгером, хотя заменить его всяко проще.

Вероятно, наиболее сильный недостаток контейнера - это проблема с нарушением жизни информационных экспертов (GRASP) в приложении, хотя при рапространенном подходе репозиторий+сервис или похожем на него проблем может и не возникнуть, поскольку донастраивать эти объекты может быть запрещено или же не нужно. Но в особенно коварных случаях есть определенные пересечения между Creator и Information Expert: класс, который должен создавать объект может внезапно потребовать некоторого управления им, ведь он обладает достаточными знаниями о нем. IoC часто полностью убирает создание объекта или делегирует это самым высоким слоям приложения, оставляя нижние безучастными, лишая их всякого управления, которое вполне может быть выражено и в выборе зависимостей при создании объекта. Обычно контейнер имеет настройку - создаются ли объекты каждый раз или реиспользуются, что влечет последствия для, например, объектов с внутренним состоянием. В случае глобальных сервисов (вспомним, что существует совет делать сервисы\службы не имеющими никакого состояния) это не слишком большая беда, но в случае более специализированных зависимостей могут быть проблемы. Да и от этого могут пострадать те же контроллеры, как наиболее активные эксперты.

С другой стороны, такой недостаток достаточно специфичен и опять-таки может поглощаться преимуществами и удобством автоматизации. Есть еще одна проблема, которая может сильно повлиять на его использование - принцип "всякой задаче - свой инструмент" предполагает наличие в инфраструктуре языка IoC-контейнеров разной степени сложности. Для сложного энтерпрайзного веб-приложения он должен быть мощным, а для мелкого десктопного - простым и легковесным. К сожалению, порой соблюсти это невозможно, а в общем случае будет попытка притянуть в приложение то, что хотя бы пока не умерло, поскольку прекращение поддержки такого критичного функционала - событие катастрофичное.

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

Чтож, пришло время вспомнить наиболее известные паттерны внедрения зависимостей:

  • Внедрение через конструктор
  • Внедрение в метод.
  • Окружающий контекст.
  • Внедрение через свойство.

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

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

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

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

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

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

Если ситуация становится проблемной, то можно попытаться использовать более экзотические трюки, например, использовать для глобальных сервисов Object Mother в целях защиты конструкторов от изменения, передавая такую супер-фабрику как зависимость в конструктор или иным способом. Это не слишком обезопасит от переделок, но по крайней мере позволит упростить конструктор на начальных этапах, хотя бы и порождая другие недостатки. Проблема разрушения конструкторов по всей иерархии особо остро стоит в динамических языках, а также в случае отсутствия IDE или очень слабых редакторов. В таких ситуациях любая переделка таит в себе опасность, а разрушение сложной иерархии объектов просто фатально.

С другой стороны, реиспользовать такой код очень трудно, если зависимости будут иметь состояния или часто изменяться, то также будут нарушать всё и вся, удалять их становится очень проблемно и т.п. Альтернативным вариантом может быть делегирование разными способами: от обратных вызовов до определения объекта-делегата, методы которого будут использоваться. Еще более экзотический способ - метапрограммирование.

По теме можно почитать знаменитую книжку Марка Симана и др. - Внедрение зависимостей на платформе .NET, которая выдержала множество переизданий. Хотя некоторые вопросы и выводы достаточно холиварны и при отсутствии поправок на язык, фреймворк, задачу и т.п. факторы могут сыграть злую шутку, но это характерно для любого инструмента и достаточно интуитивно, поскольку любое решение несет в себе плюсы и минусы.

Можно сделать небольшие выводы:

  • В количественном анализе зависимостей можно опираться на графы и генерацию UML-диаграмм из кода.
  • Качественный анализ может предположить о будущих проблемах, поскольку степень интеграции и возможности самой зависимости, как и её эволюция сильно влияют на последующие изменения.
  • При таком анализе нужно учитывать множество самых разных факторов, некоторые из которых трудно формализовать, начиная от особенностей внимания, понимания и прочей когнетики и заканчивая спецификой предметной области.
  • Прямое инстанцирование упрощает работу с зависимостями, особенно для передачи их в классы-потомки, но понижает управляемость и гибкость.
  • Сочетание внешних и создаваемых зависимостей может помочь, но такой метод упирается также в модель, которую описывает код.
  • IoC-контейнер также имеет различные множественные недостатки и не может быть панацеей. В конце концов, останется создание объектов в коде библиотек или в менее контролируемых контейнером частях.
  • Внедрение зависимостей имеет свои недостатки, смотря какой метод использовался: конструктор понижает управляемость объектом, а сеттеры создают мутабельность и позволяют делать ошибки.

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