Внедрение и поиск зависимостей

Статья на сайте

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

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

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

  1. Внедрение зависимостей
  2. Контекстный поиск
  3. Шаблон проектирования "Фабрика"
  4. Шаблон проектирования "Локатор служб"

Кроме этого инверсия управления может применяться для предоставления фреймворкам возможности использования нашего кода при помощи шаблонов проектирования "Шаблонный метод" или "Стратегия". Примеров такого поведения в контексте Spring Framework достаточно много: аннотированные контроллеры, функциональные обработчики HTTP-запросов, слушатели очередей сообщений JMS и т.д.

В этой статье я хочу подробно остановиться на внедрении и поиске зависимостей.

Внедрение зависимостей

Давайте вернёмся к интерфейсу FindTaskByIdSpi и его реализации FindTaskByIdMappingSqlQuery, которые были описаны в статье о шаблоне проектирования "Адаптер".

interface FindTaskByIdSpi {

    Optional<TaskData> findTaskById(UUID id);
}

class FindTaskByIdSpiMappingSqlQuery extends MappingSqlQuery<TaskData>
        implements FindTaskByIdSpi {

    FindTaskByIdSpiMappingSqlQuery(DataSource dataSource) {
        super(dataSource, """
                select * from t_task where id = :id
                """);
        this.declareParameter(new SqlParameter("id", Types.OTHER));
        this.compile();
    }

    @Override
    Optional<TaskData> findTaskById(UUID id) {
        return Optional.ofNullable(
                this.findObjectByNamedParam(Map.of("id", id)));
    }
}

Допустим, в проекте появляется класс FindTaskUseCase, который использует FindTaskByIdSpi и FindTaskByIdMappingSqlQuery:

class FindTaskUseCase {

    FindTaskByIdSpi findTaskByIdSpi;

    FindTaskUseCase() {
        var dataSource = new HikariDataSource();
        dataSource.setJdbcUrl("...");
        dataSource.setUsername("...");
        dataSource.setPassword("...");

        this.findTaskByIdSpi = new FindTaskByIdSpiMappingSqlQuery(dataSource);
    }
}

Конструктор класса FindTaskUseCase демонстрирует стандартный для процедурного программирования подход, когда все используемые зависимости определяются явно. Это создаёт жёсткую связь между FindTaskUseCase и FindTaskByIdSpiMappingSqlQuery, несмотря на то, что свойство findTaskByIdSpi объявлено с типом FindTaskByIdSpi - класс FindTaskUseCase всё равно знает и о существовании FindTaskByIdSpiMappingSqlQuery и о способе его создания, то же самое касается и HikariDataSource.

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

А что если в проекте интерфейс FindTaskByIdSpi используется в нескольких местах? Тогда вам придётся вносить изменения во все зависимые классы! Хорошей практикой это назвать сложно, конечно же. На помощь в данной ситуации приходит внедрение зависимостей.

Внедрение зависимостей через конструктор

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

class FindTaskUseCase {

    FindTaskByIdSpi findTaskByIdSpi;

    FindTaskUseCase(FindTaskByIdSpi findTaskByIdSpi) {
        this.findTaskByIdSpi = findTaskByIdSpi;
    }
}

А создан экземпляр класса может быть следующим образом:

class FindTaskUseCaseCreator {

    // Некоторый фабричный метод для поиска и создания зависимостей по типу
    abstract <T> T getInstanceOf(Class<T> type);

    FindTaskUseCase createWithConstructorInjection() {
        // Получаем экземпляр FindTaskByIdSpi
        var findTaskByIdSpi = this.getInstanceOf(FindTaskByIdSpi.class);
        // Создаём и возвращаем экземпляр FindTaskUseCase
        return new FindTaskUseCase(findTaskByIdSpi);
    }
}

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

У внедрения зависимостей через конструктор есть стилистическое ограничение: желательно, чтобы количество аргументов метода не превышало семи, в противном случае придётся обратиться к другим способам внедрения зависимостей. Альтернативным вариантом вполне может стать внедрение зависимостей через методы.

Внедрение зависимостей через set-метод

В этом варианте используются стандартные для спецификации Java Bean set-методы:

class FindTaskUseCase {

    FindTaskByIdSpi findTaskByIdSpi;

    void setFindTaskByIdSpi(FindTaskByIdSpi findTaskByIdSpi) {
        this.findTaskByIdSpi = findTaskByIdSpi;
    }
}

Процесс создания экземпляра FindTaskUseCase будет выглядеть следующим образом:

class FindTaskUseCaseCreator {

    // Некоторый фабричный метод для поиска и создания зависимостей по типу
    abstract <T> T getInstanceOf(Class<T> type);

    FindTaskUseCase createWithSetterInjection() {
        // Создаём экземпляр FindTaskUseCase
        var useCase = new FindTaskUseCase();
        // Получаем экземпляр FindTaskByIdSpi
        var findTaskByIdSpi = this.getInstanceOf(FindTaskByIdSpi.class);
        // Внедряем зависимость
        useCase.setFindTaskByIdSpi(findTaskByIdSpi);
        return useCase;
    }
}

Методы, определённые в "интерфейсе внедрения"

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

interface FindTaskByIdSpiAware {

    void findTaskByIdSpi(FindTaskByIdSpi findTaskByIdSpi);
}

Класс, в который требуется внедрять зависимость, должен реализовывать этот интерфейс:

class FindTaskUseCase implements FindTaskByIdSpiAware {

    FindTaskByIdSpi findTaskByIdSpi;

    @Override
    void findTaskByIdSpi(FindTaskByIdSpi findTaskByIdSpi) {
        this.findTaskByIdSpi = findTaskByIdSpi;
    }
}

Процесс создания экземпляра FindTaskUseCase будет выглядеть следующим образом:

class FindTaskUseCaseCreator {

    // Некоторый фабричный метод для поиска и создания зависимостей по типу
    abstract <T> T getInstanceOf(Class<T> type);

    FindTaskUseCase createWithInterfaceInjection() {
        // Создаём экземпляр FindTaskUseCase
        var useCase = new FindTaskUseCase();
        if (useCase instanceof FindTaskByIdSpiAware findTaskByIdSpiAware) {
            // Получаем экземпляр FindTaskByIdSpi
            var findTaskByIdSpi = this.getInstanceOf(FindTaskByIdSpi.class);
            // Внедряем зависимость
            findTaskByIdSpiAware.findTaskByIdSpi(findTaskByIdSpi);
        }

        return useCase;
    }
}

Остальные методы

В целом внедрение зависимостей может быть реализовано при помощи любых методов:

class FindTaskUseCase {

    FindTaskByIdSpi findTaskByIdSpi;

    FindTaskUseCase withFindTaskByIdSpi(FindTaskByIdSpi findTaskByIdSpi) {
        this.findTaskByIdSpi = findTaskByIdSpi;
        return this;
    }
}

Процесс создания экземпляра FindTaskUseCase будет выглядеть следующим образом:

class FindTaskUseCaseCreator {

    // Некоторый фабричный метод для поиска и создания зависимостей по типу
    abstract <T> T getInstanceOf(Class<T> type);

    FindTaskUseCase createWithMethodInjection() {
        // Создаём экземпляр FindTaskUseCase
        var useCase = new FindTaskUseCase();
        // Получаем экземпляр FindTaskByIdSpi
        var findTaskByIdSpi = this.getInstanceOf(FindTaskByIdSpi.class);
        // Внедряем зависимость и возвращаем объект
        return useCase.withFindTaskByIdSpi(findTaskByIdSpi);
    }
}

Внедрение зависимостей через свойства

Последний способ внедрения зависимостей - при помощи прямых обращений к свойствам класса:

class FindTaskUseCaseCreator {

    // Некоторый фабричный метод для поиска и создания зависимостей по типу
    abstract <T> T getInstanceOf(Class<T> type);

    FindTaskUseCase createWithParameterInjection() {
        // Создаём экземпляр FindTaskUseCase
        var useCase = new FindTaskUseCase();
        // Получаем экземпляр FindTaskByIdSpi и внедряем через свойство
        useCase.findTaskByIdSpi = this.getInstanceOf(FindTaskByIdSpi.class);

        return useCase;
    }
}

Этого способа внедрения лучше избегать, так как в этом случае вы открываете внутреннее устройство вашего класса для внешнего мира, как минимум в рамках пакета. Кстати, когда вы видите свойства классов, отмеченные аннотациями @Autowired в случае со Spring Framework или @Inject в случае с Java EE или Jakarta EE, знайте, что фактическое внедрение зависимостей происходит как раз через отмеченные свойства. Просто это делается при помощи рефлексии, и если вы выберете такой способ, то в модульных тестах вам придётся конфигурировать экземпляр тестируемого класса тоже при помощи рефлексии.

Поиск зависимостей

Ещё одним способом реализации инверсии управления является поиск зависимостей или контекстуализированный поиск (Contextualized Lookup). В этом случае в конфигурируемый объект может внедряться экземпляр класса, который предоставляет возможность поиска необходимых зависимостей. Примером такого класса может быть BeanFactory из Spring Framework.

Допустим, в нашем проекте для этого существует interface DependencyFactory:

interface DependencyFactory {

    <T> T getInstanceOf(Class<T> type);
}

Тогда создание экземпляра класса FindTaskUseCase будет выглядеть следующим образом, при условии, что DependencyFactory будет внедряться через конструктор:

class FindTaskUseCase {

    FindTaskByIdSpi findTaskByIdSpi;

    FindTaskUseCase(DependencyFactory dependencyFactory) {
        this.findTaskByIdSpi = dependencyFactory.getInstanceOf(FindTaskByIdSpi.class);
    }
}
class FindTaskUseCaseCreator {

    DependencyFactory dependencyFactory;

    FindTaskUseCase createWithParameterInjection() {
        // Создаём и возвращаем экземпляр FindTaskUseCase
        return new FindTaskUseCase(this.dependencyFactory);
    }
}

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

Контейнеры инверсии управления

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

На помощь приходят контейнеры инверсии управления, которые могут порождать и предоставлять зависимости по мере необходимости, а так же хранить их для дальнейшего использования. Все фреймворки, реализующие внедрение зависимостей, такие как Spring Framework, Guice или Weld предоставляют собственные реализации таких контейнеров.

Среди классов, продемонстрированных в рамках этой статьи, DependencyFactory можно назвать минимальной заготовкой для контейнера инверсии управления.

26 views·2 shares