Русский
Русский
English
Статистика
Реклама

DI из ада

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


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

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


@Servicepublic class Parent {    @Autowired    private Child child;    public int getNine() {        return child.sum(6, 3);    }    @PostConstruct    private void init() {        System.out.println("Parent is called");    }}@Servicepublic 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)@Documentedpublic @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.


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

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


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


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


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


@Servicepublic 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));    }}@Servicepublic 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());    }}@Configurationpublic class ParentFactory {    @Bean    @Autowired    public Parent parent(Child child) {        // дополнительные проверки        ...        Parent parent = new Parent();        parent.setChild(child);        return parent;    }}

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


@Servicepublic 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 через конструкторы


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


@Servicepublic 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));    }@Servicepublic 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.


@Servicepublic 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));    }}@Servicepublic 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. Если вы у вас есть вопросы или замечания, прошу оставить комментарии. Спасибо за чтение!


Ссылки


Источник: habr.com
К списку статей
Опубликовано: 20.06.2020 10:15:54
0

Сейчас читают

Комментариев (0)
Имя
Электронная почта

Java

Ооп

Spring

Java ee

Dependency injection

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru