DI из ада

в 5:33, , рубрики: dependency injection, java, java ee, spring, ооп

Все мы любим Spring. Или не любим. Но по крайней мере знаем. Если вы Java-программист, то вероятно используете этот фреймворк каждый день в своей работе. Spring — это огромная платформа, которая предоставляет большой функционал. Тем не менее во главе угла стоят две вещи — это DI (Dependency Injection) и IoC (Inversion of Control). Концепции, которые были призваны, чтобы сделать наш код более читаемым и поддерживаемым. Но к несчастью, все оказалось не так радужно. Именно это мы сегодня и обсудим.

DI из ада - 1

Дальнейшие обсуждения применимы не только к Spring, но и к стеку Jakarta EE.

Триггером к написанию этой статьи послужил вот этот вопрос на StackOverflow. Суть заключалась в том, чтобы понять порядок вызовов @PostConstruct и @Autowired, если один компонент зависит от другого. Исходя из этого нужно было определить, что и в каком порядке будет выведено на экран. Мы вернемся к этой задаче в конце статьи, а пока давайте посмотрим на код. Для наглядности я его немного видоизменил, но смысл вопроса сохранен.

@Service
public class Parent {
    @Autowired
    private Child child;

    public int getNine() {
        return child.sum(6, 3);
    }

    @PostConstruct
    private void init() {
        System.out.println("Parent is called");
    }
}

@Service
public class Child {
    @Autowired
    private Parent parent;

    public int sum(int a, int b) {
        return a + b;
    }

    @PostConstruct
    private void init(){
        System.out.println("Child is called");
    }
}

Люди оставляли довольно подробные ответы с пояснениями. Мол, сначала Spring сделает это, потом то и так далее. Однако я считаю, что здесь стоило несколько сместить акцент. На мой взгляд в этом коде присутствует ряд архитектурных ошибок, после исправления которых вопросы о порядке инстанцирования и вызовов @PostConstruct отпали бы сами собой. Что конкретно здесь плохо? Это мы разберем чуть далее.

Истоки зла

В начале было слово, и слово было @Autowired. Благодаря этой аннотации совершается магия внедрения зависимостей в наш код. Давайте взглянем на ее декларацию.

@Target({
    ElementType.CONSTRUCTOR, ElementType.METHOD, ElementType.PARAMETER, 
    ElementType.FIELD, ElementType.ANNOTATION_TYPE
})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Autowired {
    boolean required() default true;
}

@Target определяет возможные варианты размещения аннотации. Мы не будет брать в расчет ANNOTATION_TYPE и PARAMETER. Так как первый применяется для хитрого механизма наследования аннотаций в Spring, а второй – для тестовых сред (по крайней мере, так написано в JavaDoc, но лично я ни разу не видел реального примера).
Отсюда можно сделать вывод, что нас интересует три способа применения @Autowired:

  • Поля класса
  • Сеттеры
  • Конструкторы

Рассмотрим каждый из них подробнее.

DI через поля

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

Во-первых, на такой класс невозможно написать хороший unit-тест. Чтобы убедиться, давайте попробуем сделать это. Напишем тест на вышеприведенный Parent.

class ParentTest {
    @Test
    void testGetNine() {
        Parent parent = new Parent();
        assertEquals(9, parent.getNine());
    }
}

Здесь мы ожидаемо получим NullPointerException, так как зависимость на Child отсутствует. Но мы не сдаемся. Воспользуемся упомянутым Reflection API.

class ParentATest {
    @Test
    void testGetNine() throws NoSuchFieldException, IllegalAccessException {
        Parent parent = new Parent();
        Class<? extends Parent> clazz = parent.getClass();
        Field field = clazz.getDeclaredField("child");
        field.setAccessible(true);
        field.set(parent, new Child());
        assertEquals(9, parent.getNine());
        field.setAccessible(false);
    }
}

Теперь тест работает так, как ожидается. Насколько плохим является такой подход? Я бы сказал, что на 10 хрустальных ваз из 10. Если мы поменяем имя поля или, не дай бог, тип переменной, тест тут же упадет. Но мы не узнаем об этом до тех пор, пока не запустим его. Особенно неприятно, когда подобные ошибки дают о себе знать после пятнадцатиминутного ожидания сборки на Jenkins.

Но тесты — это не единственная проблема (да и будем честны, не всегда мы их пишем). Предположим, что теперь необходимо производить инициализацию через BeanFactory.

@Configuration
public class ParentFactory {
    @Bean
    @Autowired
    public Parent parent(Child child) {
        // дополнительные проверки
        ...
        return new Parent();
    }
}

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

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

DI через сеттеры

Давайте перепишем компоненты, используя внедрение зависимостей через сеттеры.

@Service
public class Parent {
    private Child child;

    @Autowired
    public void setChild(Child child) {
        this.child = child;
    }

    public int getNine() {
        return child.sum(6, 3);
    }

    @PostConstruct
    private void init() {
        System.out.println("Parent is called");
        System.out.println(child.sum(6, 3));
    }
}

@Service
public class Child {
    private Parent parent;

    @Autowired
    public void setParent(Parent parent) {
        this.parent = parent;
    }

    public int sum(int a, int b) {
        return a + b;
    }

    @PostConstruct
    private void init() {
        System.out.println("Child is called");
    }

}

Стало гораздо лучше. Более того, теперь и тесты можно писать без проблем, и при необходимости инстанцировать объект через BeanFactory.

class ParentTest {
    @Test
    void testGetNine() {
        Parent parent = new Parent();
        parent.setChild(new Child());
        assertEquals(9, parent.getNine());
    }
}

@Configuration
public class ParentFactory {
    @Bean
    @Autowired
    public Parent parent(Child child) {
        // дополнительные проверки
        ...
        Parent parent = new Parent();
        parent.setChild(child);
        return parent;
    }
}

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

@Service
public class Outsider {
    private Parent parent;

    @Autowired
    public void setParent(Parent parent) {
        this.parent = parent;
    }

    public void prank() {
        parent.setChild(null);
    }
}

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

Кто-то может заметить, что такого никогда не произойдет. Зачем так писать? В этом нет никакого смысла. Я думаю, что вы будете правы. Если бы такой код упал ко мне код-ревью, аппрувать его я бы точно не стал. Но вот пример, где это имело бы смысл.

Предположим, что Parent был объявлен со @Scope(SCOPE_PROTOTYPE). Это означает, что при каждом запросе к ApplicationContext.getBean(Parent.class) будет возвращаться новый инстанс класса. В одном из участков программы потребовалось поменять стандартное поведение компонента. Поэтому через parent.setChild была передана другая имплементация (в данном случае наследник). Все работало как часы. Ведь мы всего лишь поменяли поле у только что созданного объекта. Но в какой-то момент аннотация @Scope была убрана, что означает, что теперь ApplicationContext возвращает синглтон. А тот самый сеттер поменял поведения объекта во всей программе.

Кроме того, у DI через сеттеры и поля есть общая проблема – циклические зависимости. Я бы разделил их на два типа: явные и неявные.

image

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

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

Считаю ли я сеттеры абсолютным злом? Не совсем. Я думаю, что их нужно избегать, но в некоторых ситуациях без них не обойтись. Отличный пример привел Евгений Борисов в своем докладе «Spring-потрошитель, часть 1» (34:56). В рантайме с помощью JMX в Java-приложении переключался флаг профилирования функций. Очевидно, что без сеттера этого не сделать.

Я написал довольно подробную статью на Medium, в которой высказываю свою позицию насчет сеттеров. Желающие могут ознакомиться по этой ссылке.

DI через конструкторы

Давайте заменим сеттеры конструкторами.

@Service
public class Parent {
    private final Child child;

    @Autowired
    public Parent(Child child) {
        this.child = child;
    }

    public int getNine() {
        return child.sum(6, 3);
    }

    @PostConstruct
    private void init() {
        System.out.println("Parent is called");
        System.out.println(child.sum(6, 3));
    }

@Service
public class Child {
    public int sum(int a, int b) {
        return a + b;
    }

    @PostContruct
    private void init() {
        System.out.println("Child is called");
    }
}

Для того чтобы приложение корректно запустилось, необходимо было избавиться от циклической зависмости Parent → Child → Parent. Благо здесь это не составило труда.

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

Однако еще не все. Осталось последнее – @PostConstruct. Подробно свое мнение по поводу этой аннотации я высказал в этой статье, но вкратце скажу, что здесь в ней нет никакой необходимости и мы можем просто вызвать функцию init внутри конструктора. Приятным бонусом является то, что, начиная со Spring 4.3, @Autowired использовать необязательно, если в классе присутствует единственный конструктор, через который внедряются все зависимости.

Исходя из всех вышеописанных выводов, перепишем Parent и Child.

@Service
public class Parent {
    private final Child child;

    public Parent(Child child) {
        this.child = child;
        init();
    }

    public int getNine() {
        return child.sum(6, 3);
    }

    private void init() {
        System.out.println("Parent is called");
        System.out.println(child.sum(6, 3));
    }
}

@Service
public class Child {
    public Child() {
        init();
    }

    public int sum(int a, int b) {
        return a + b;
    }

    private void init() {
        System.out.println("Child is called");
    }
}

А теперь давайте вернемся к изначальному вопросу. В каком порядке будут инстанцированы объекты и выведены надписи на экран? Теперь решение прозрачно. Parent зависит от Child, поэтому он не может быть создан первым. Следовательно, порядок вызовов конструкторов – Child → Parent. Значит, сначала мы увидим "Child is called", потом "Parent is called", а потом 9. Это поведение детерменировано. Мы можем запускать код сколько угодно раз, но результат всегда будет один и тот же.

Выводы

Несмотря на то, что Spring позволяет нам внедрять зависимости как через сеттеры, так и через поля, следует этого избегать. Я думаю, многие со мной согласятся, что вариант классов Parent и Child с DI через конструкторы намного понятнее и чище. Скорее всего, это не вызовет у ваших коллег никаких вопросов, в отличии от запутанного flow с полями и @PostConstruct. Если вы у вас есть вопросы или замечания, прошу оставить комментарии. Спасибо за чтение!

Ссылки

Автор: Семен Киреков

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js