Держите фреймворк — изучение шаблонов внедрения зависимостей
Опубликовано: 2022-03-11Традиционные взгляды на инверсию управления (IoC), по-видимому, проводят жесткую границу между двумя разными подходами: локатором сервисов и шаблонами внедрения зависимостей (DI).
Практически каждый известный мне проект включает в себя структуру DI. Людей привлекают они, потому что они обеспечивают слабую связь между клиентами и их зависимостями (обычно посредством внедрения конструктора) с минимальным кодом шаблона или без него. Хотя это отлично подходит для быстрой разработки, некоторые люди считают, что это может затруднить отслеживание и отладку кода. «Магия за кадром» обычно достигается за счет рефлексии, которая может принести целый набор новых проблем.
В этой статье мы рассмотрим альтернативный шаблон, который хорошо подходит для кодовых баз Java 8+ и Kotlin. Он сохраняет большинство преимуществ DI-фреймворка, будучи столь же простым, как и сервисный локатор, не требуя внешних инструментов.
Мотивация
- Избегайте внешних зависимостей
- Избегайте отражения
- Продвижение внедрения конструктора
- Минимизируйте поведение во время выполнения
Пример
В следующем примере мы смоделируем реализацию ТВ, в которой можно использовать разные источники для получения контента. Нам необходимо сконструировать устройство, способное принимать сигналы от различных источников (например, наземных, кабельных, спутниковых и т. д.). Мы построим следующую иерархию классов:
Теперь давайте начнем с традиционной реализации DI, в которой среда, такая как Spring, подключает все за нас:
public class TV { private final TvSource source; public TV(TvSource source) { this.source = source; } public void turnOn() { System.out.println("Turning on the TV"); this.source.tuneChannel(42); } } public interface TvSource { void tuneChannel(int channel); } public class Terrestrial implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Adjusting dish frequency to channel %d\n", channel); } } public class Cable implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Changing digital signal to channel %d\n", channel); } }
Мы замечаем некоторые вещи:
- Класс TV выражает зависимость от TvSource. Внешняя структура увидит это и внедрит экземпляр конкретной реализации (наземной или кабельной).
- Шаблон внедрения конструктора упрощает тестирование, поскольку вы можете легко создавать экземпляры TV с альтернативными реализациями.
У нас хорошее начало, но мы понимаем, что использование инфраструктуры DI для этого может быть излишним. Некоторые разработчики сообщали о проблемах с отладкой построения (длинные трассировки стека, неотслеживаемые зависимости). Наш клиент также заявил, что время производства немного больше, чем ожидалось, а наш профилировщик показывает замедление отражающих вызовов.
Альтернативой может быть применение шаблона Service Locator. Это просто, не использует отражение и может быть достаточным для нашей небольшой кодовой базы. Другая альтернатива — оставить классы в покое и написать вокруг них код расположения зависимостей.
Оценив множество альтернатив, мы решили реализовать его в виде иерархии интерфейсов провайдеров. Каждая зависимость будет иметь связанного поставщика, который будет нести исключительную ответственность за обнаружение зависимостей класса и создание внедренного экземпляра. Мы также сделаем провайдера внутренним интерфейсом для простоты использования. Мы будем называть это Mixin Injection, потому что каждый провайдер смешивается с другими провайдерами, чтобы найти свои зависимости.
Подробности того, почему я остановился на этой структуре, подробно описаны в разделе «Подробности и обоснование», но вот краткая версия:
- Он разделяет поведение расположения зависимостей.
- Расширение интерфейсов не относится к алмазной проблеме.
- Интерфейсы имеют реализации по умолчанию.
- Отсутствующие зависимости препятствуют компиляции (бонусные баллы!).
На следующей диаграмме показано, как взаимодействуют зависимости и поставщики, а реализация проиллюстрирована ниже. Мы также добавляем метод main, чтобы продемонстрировать, как мы можем составить наши зависимости и создать объект TV. Более длинную версию этого примера также можно найти на этом GitHub.
public interface TvSource { void tuneChannel(int channel); interface Provider { TvSource tvSource(); } } public class TV { private final TvSource source; public TV(TvSource source) { this.source = source; } public void turnOn() { System.out.println("Turning on the TV"); this.source.tuneChannel(42); } interface Provider extends TvSource.Provider { default TV tv() { return new TV(tvSource()); } } } public class Terrestrial implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Adjusting dish frequency to channel %d\n", channel); } interface Provider extends TvSource.Provider { @Override default TvSource tvSource() { return new Terrestrial(); } } } public class Cable implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Changing digital signal to channel %d\n", channel); } interface Provider extends TvSource.Provider { @Override default TvSource tvSource() { return new Cable(); } } } // Here compose the code above to instantiate a TV with a Cable TvSource public class Main { public static void main(String[] args) { new MainContext().tv().turnOn(); } static class MainContext implements TV.Provider, Cable.Provider { } }
Несколько замечаний по этому примеру:
- Класс TV зависит от TvSource, но не знает никакой реализации.
- TV.Provider расширяет TvSource.Provider, потому что ему нужен метод tvSource() для создания TvSource, и он может использовать его, даже если он там не реализован.
- Наземные и кабельные источники могут использоваться телевизором взаимозаменяемо.
- Интерфейсы Terrestrial.Provider и Cable.Provider предоставляют конкретные реализации TvSource.
- Основной метод имеет конкретную реализацию MainContext для TV.Provider, которая используется для получения экземпляра TV.
- Программе требуется реализация TvSource.Provider во время компиляции для создания экземпляра телевизора, поэтому мы включаем Cable.Provider в качестве примера.
Детали и обоснование
Мы видели паттерн в действии и некоторые причины, лежащие в его основе. Возможно, вы не уверены, что должны использовать его сейчас, и будете правы; это не совсем серебряная пуля. Лично я считаю, что в большинстве аспектов он превосходит шаблон локатора сервисов. Однако по сравнению с платформами DI необходимо оценить, перевешивают ли преимущества затраты на добавление стандартного кода.
Провайдеры расширяют возможности других провайдеров для обнаружения их зависимостей
Когда поставщик расширяет другой, зависимости связываются вместе. Это обеспечивает базовую основу для статической проверки, которая предотвращает создание недопустимых контекстов.
Одна из основных проблем шаблона локатора сервисов заключается в том, что вам нужно вызвать общий GetService<T>()
, который каким-то образом разрешит вашу зависимость. Во время компиляции у вас нет гарантий, что зависимость когда-либо будет зарегистрирована в локаторе, и ваша программа может дать сбой во время выполнения.
Шаблон DI также не решает эту проблему. Разрешение зависимостей обычно выполняется посредством отражения с помощью внешнего инструмента, который в основном скрыт от пользователя, который также дает сбой во время выполнения, если зависимости не выполняются. Такие инструменты, как IntelliJ CDI (доступен только в платной версии), обеспечивают некоторый уровень статической проверки, но только Dagger с его препроцессором аннотаций решает эту проблему по своей задумке.
Классы поддерживают типичную инъекцию конструктора шаблона внедрения зависимостей
Это не обязательно, но определенно желательно для сообщества разработчиков. С одной стороны, вы можете просто посмотреть на конструктор и сразу увидеть зависимости класса. С другой стороны, он позволяет выполнять тот тип модульного тестирования, которого придерживаются многие люди, который заключается в создании тестируемого объекта с помощью макетов его зависимостей.

Это не означает, что другие шаблоны не поддерживаются. На самом деле, можно даже обнаружить, что Mixin Injection упрощает построение сложных графов зависимостей для тестирования, потому что вам нужно всего лишь реализовать класс контекста, который расширяет поставщика вашего субъекта. Приведенный выше MainContext
— прекрасный пример, когда все интерфейсы имеют реализации по умолчанию, поэтому у него может быть пустая реализация. Замена зависимости требует только переопределения ее метода провайдера.
Давайте посмотрим на следующий тест для телевизионного класса. Ему нужно создать экземпляр TV, но вместо вызова конструктора класса он использует интерфейс TV.Provider. У TvSource.Provider нет реализации по умолчанию, поэтому нам нужно написать ее самостоятельно.
public class TVTest { @Test public void testWithProvider() { TvSource source = Mockito.mock(TvSource.class); TV.Provider provider = () -> source; // lambdas FTW provider.tv().turnOn(); Mockito.verify(source, times(1)).tuneChannel(42); } }
Теперь давайте добавим еще одну зависимость к классу TV. Зависимость CathodeRayTube творит чудеса, заставляя изображение появляться на экране телевизора. Он не связан с реализацией телевизора, потому что в будущем мы можем захотеть переключиться на LCD или LED.
public class TV { public TV(TvSource source, CathodeRayTube cathodeRayTube) { ... } public interface Provider extends TvSource.Provider, CathodeRayTube.Provider { default TV tv() { return new TV(tvSource(), cathodeRayTube()); } } } public class CathodeRayTube { public void beam() { System.out.println("Beaming electrons to produce the TV image"); } public interface Provider { default CathodeRayTube cathodeRayTube() { return new CathodeRayTube(); } } }
Если вы сделаете это, вы заметите, что тест, который мы только что написали, по-прежнему компилируется и проходит, как и ожидалось. Мы добавили новую зависимость в TV, но также предоставили реализацию по умолчанию. Это означает, что нам не нужно имитировать его, если мы просто хотим использовать реальную реализацию, и наши тесты могут создавать сложные объекты с любым уровнем фиктивной детализации, который мы хотим.
Это удобно, когда вы хотите имитировать что-то конкретное в сложной иерархии классов (например, только уровень доступа к базе данных). Этот паттерн позволяет легко настроить тесты на общительность, которые иногда предпочтительнее одиночных тестов.
Независимо от ваших предпочтений, вы можете быть уверены, что можете обратиться к любой форме тестирования, которая лучше соответствует вашим потребностям в каждой ситуации.
Избегайте внешних зависимостей
Как видите, нет никаких ссылок или упоминаний о внешних компонентах. Это ключевой момент для многих проектов, которые имеют ограничения по размеру или даже безопасности. Это также помогает с функциональной совместимостью, поскольку фреймворкам не нужно фиксировать конкретный DI-фреймворк. В Java были предприняты усилия, такие как JSR-330 Dependency Injection for Java Standard, которые смягчают проблемы совместимости.
Избегайте отражения
Реализации локатора сервисов обычно не полагаются на отражение, но реализации DI делают это (за заметным исключением Dagger 2). Это имеет основные недостатки, заключающиеся в замедлении запуска приложения, поскольку фреймворку необходимо сканировать ваши модули, разрешать граф зависимостей, рефлексивно создавать ваши объекты и т. д.
Mixin Injection требует, чтобы вы написали код для создания экземпляров ваших сервисов, аналогично шагу регистрации в шаблоне локатора сервисов. Эта небольшая дополнительная работа полностью устраняет рефлексивные вызовы, делая ваш код более быстрым и простым.
Два проекта, которые недавно привлекли мое внимание и извлекли выгоду из отказа от размышлений, — это Graal’s Substrate VM и Kotlin/Native. Оба компилируются в собственный байт-код, и это требует, чтобы компилятор заранее знал о любых рефлексивных вызовах, которые вы будете делать. В случае с Graal он указан в файле JSON, который сложно написать, нельзя проверить статически, нельзя легко реорганизовать с помощью ваших любимых инструментов. Использование Mixin Injection, чтобы избежать отражения, в первую очередь, — отличный способ получить преимущества нативной компиляции.
Сведите к минимуму поведение во время выполнения
Реализуя и расширяя необходимые интерфейсы, вы строите граф зависимостей по частям. Каждый провайдер находится рядом с конкретной реализацией, которая вносит порядок и логику в вашу программу. Такое наслоение будет вам знакомо, если вы уже использовали шаблон Mixin или шаблон Cake.
На этом этапе, возможно, стоит поговорить о классе MainContext. Это корень графа зависимостей, и он знает общую картину. Этот класс включает все интерфейсы провайдеров и является ключом к включению статических проверок. Если мы вернемся к примеру и удалим Cable.Provider из его списка реализаций, мы ясно увидим это:
static class MainContext implements TV.Provider { } // ^^^ // MainContext is not abstract and does not override abstract method tvSource() in TvSource.Provider
Здесь произошло то, что приложение не указало конкретный TvSource для использования, и компилятор обнаружил ошибку. С локатором сервисов и DI на основе отражения эта ошибка могла остаться незамеченной до тех пор, пока программа не рухнула во время выполнения, даже если бы все модульные тесты были пройдены! Я считаю, что эти и другие преимущества, которые мы продемонстрировали, перевешивают недостатки написания шаблона, необходимого для того, чтобы шаблон работал.
Поймать циклические зависимости
Вернемся к примеру с CathodeRayTube и добавим циклическую зависимость. Допустим, мы хотим, чтобы он был внедрен в экземпляр TV, поэтому мы расширяем TV.Provider:
public class CathodeRayTube { public interface Provider extends TV.Provider { // ^^^ // cyclic inheritance involving CathodeRayTube.Provider default CathodeRayTube cathodeRayTube() { return new CathodeRayTube(); } } }
Компилятор не допускает циклического наследования, и мы не можем определить такие отношения. Большинство фреймворков терпят неудачу во время выполнения, когда это происходит, и разработчики, как правило, обходят это, просто чтобы заставить программу работать. Несмотря на то, что этот анти-шаблон можно найти в реальном мире, обычно это признак плохого дизайна. Когда код не компилируется, нас следует поощрять к поиску лучших решений, пока не стало слишком поздно что-то менять.
Сохраняйте простоту в построении объектов
Один из аргументов в пользу SL по сравнению с DI заключается в том, что его просто и легко отлаживать. Из примеров видно, что создание экземпляра зависимости будет просто цепочкой вызовов методов провайдера. Отследить источник зависимости так же просто, как войти в вызов метода и посмотреть, где вы в конечном итоге. Отладка проще, чем обе альтернативы, потому что вы можете перемещаться именно там, где создаются экземпляры зависимостей, прямо из поставщика.
Срок службы
Внимательный читатель мог заметить, что эта реализация не решает проблему времени жизни службы. Все вызовы методов провайдера будут создавать экземпляры новых объектов, что делает это похожим на область действия Spring Prototype.
Это и другие соображения немного выходят за рамки данной статьи, так как я просто хотел представить суть паттерна, не отвлекаясь на детали. Однако полное использование и реализация продукта должны учитывать полное решение с пожизненной поддержкой.
Заключение
Независимо от того, привыкли ли вы к инфраструктурам внедрения зависимостей или к написанию собственных локаторов сервисов, возможно, вы захотите изучить эту альтернативу. Рассмотрите возможность использования шаблона миксина, который мы только что видели, и посмотрите, сможете ли вы сделать свой код более безопасным и простым для понимания.