Все мы любим Spring. Или не любим. Но по крайней мере знаем. Если вы Java-программист, то вероятно используете этот фреймворк каждый день в своей работе. Spring — это огромная платформа, которая предоставляет большой функционал. Тем не менее во главе угла стоят две вещи — это DI (Dependency Injection) и IoC (Inversion of Control). Концепции, которые были призваны, чтобы сделать наш код более читаемым и поддерживаемым. Но к несчастью, все оказалось не так радужно. Именно это мы сегодня и обсудим.
Дальнейшие обсуждения применимы не только к 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 через сеттеры и поля есть общая проблема – циклические зависимости. Я бы разделил их на два типа: явные и неявные.
Такое невозможно провернуть через конструктор, так как в процессе инициализации бинов мы получим исключение. В нашем случае имеет место быть явная циклическая зависимость.
Что в этом плохого? На мой взгляд они нарушают уровни взаимодействия абстракций. По заветам Дядюшки Боба мы знаем, что потоки данных должны распространятся от более высоких к более низким слоям абстракций. При этом нижние уровни ничего не знают о вышестоящих. Звучит разумно. В конце концов, было бы странно, если драйвер для принтера мог позвонить кому-нибудь в 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
. Если вы у вас есть вопросы или замечания, прошу оставить комментарии. Спасибо за чтение!
sshikov
Пардон, проблема-то вроде понятна, только при чем тут спринг? Если у вас есть в классе обязательная зависимость, ее можно задать через конструктор, или через сеттер. Понятно что через сеттер ее можно и не задать. Это точно так же актуально, если у вас вообще спринга в проекте нет.
kirekov Автор
Фреймворк скрывает от нас детали. Да, это применимо не только к Spring, потому я и добавил комментарий в начало статьи, что это актуально и для Jakarta EE. Понятно, что мы можем все написать руками и задать все зависимости через сеттеры или конструкторы. Однако тот факт, что все магия находится под капотом, провоцирует нас на решения, которые дадут сиюминутную выгоду, но в перспективе могут принести проблемы.
Кстати, DI через поля без фреймворка будет сделать проблематично. Конечно, можно использовать рефлексию, но я не думаю, что это хороший вариант.
sshikov
>Фреймворк скрывает от нас детали.
Ну да, но в целом вы можете сделать все тоже самое в рамках любой фабрики объектов — либо задать все через конструктор, если он есть, либо через сеттеры — и что-то забыть. И корень именно тут, а не в спринге.