Доброго времени, Хабр!
Совсем недавно я уволился из компании, в которой хорошей практикой считалось проведение обучающих/развлекающих презентаций для коллег во время ланчей (уволился не из-за этой практики, если что), и так как нынче я живу и работаю не в России, и все мои коллеги ничего не слышали ни про какие Joker‑ы и JPoint‑ы, то я решил несколько считерить, взять какую‑нибудь классную презентацию из этих конференций, перевести и показать коллегам (с указанием ссылки на первоисточники, разумеется, профессиональная этика мне не чужда). Выбор пал на видео Евгения Борисова и Баруха Садогурского «Приключения Сеньора Холмса и Джуниора Ватсона» (кто не смотрел - бегите и смотрите)
Таким образом я перевел два детективных расследования из того видео, и мне захотелось придумать свое собственное, а не только переводить чужие. Оффтопик: это офигеть как непросто. Надо перелопатить кучу информации, зацепиться за какие‑нибудь прикольные баги/неочевидные моменты в работе java/фреймворков/etc., выцепить из них те, которые можно объединить в одно общее дело, затем еще антураж придумать, чтобы это был не просто код, а стилизованный код. Long story short: я сделяль. Я очень горжусь этой презентацией, я показал ее коллегам, я показал ее всем друзьям, и теперь вот решил выложить этот материал и тут
Хах, сначала я переводил русскоязычные материалы на английский, чтобы показать коллегам не из России, затем я перевожу английский материал на русский, чтобы показать русскоязычному коммьюнити. "It's a treadmill" (c)
Ввиду несколько разного формата подачи материалов между презентацией и статьей на Хабре, тут мне придется сосредоточиться в основном на тексте, и, к сожалению, все то прикольное оформление, а также шутеечки и мемы, которые я вставил в презентацию, пропадут. Это делает меня быть грустным (ну не зря же я все это придумывал, верно?), но если вдруг вам захочется посмотреть на презентацию в оригинале, ссылка на нее будет в самом конце статьи
Описание дела
В последние несколько дней в городе было многолюдно. Открылся летний фестиваль, который привлекал всеобщее внимание. Влюбленные парочки, семьи с детьми, туристы, случайно заглянувшие сюда - казалось, каждый мог найти для себя что-то интересное. Я пришел специально, чтобы увидеть Великого Springhoff-a. С детства я люблю все, что связано с загадками и тайнами, и выступление знаменитого мага было чем-то, что я просто не мог пропустить. На афишах обещали нечто интригующее: маг собирался забраться в ящик, исчезнуть из него прямо на глазах у всех зрителей, а затем снова появиться. Мне было интересно, смогу ли я понять, как он выполняет свой фокус
Осмотр сцены
Когда я вошел в зал, где должно было проходить представление, я сразу же посмотрел на сцену, чтобы увидеть, как все организовано. Там стоял ящик, и только работники фестиваля имели доступ к нему. С другой стороны, сама сцена была полностью открыта для обзора со всех сторон, и все посетители могли наблюдать за происходящим
@Component
@RequiredArgsConstructor
public class Stage implements Watchable {
private final Box box;
@PostConstruct
@PreDestroy
@Attention
public final void watchVeryCarefully() {
System.out.println("Looking in the box: " + box);
}
}
@Component
@ToString
public class Box {
private final String magician = "Springhoff";
}
public interface Watchable {
void watchVeryCarefully();
}
Особое внимание во время выступления
Итак, я решил уделить особое внимание всем открываниям ящика. В чем бы ни заключался фокус, это должно было быть как-то связано с этими открытиями (скрытый механизм, зеркала или что-то подобное). Как только я увижу что-то подозрительное, я сразу пойму, как выполняется фокус
public @interface Attention {
}
@Aspect
@Component
public class AttentionAspect {
@After("@annotation(detectivecases.case3.aspect.Attention)")
public void sawSomethingSuspicious() {
System.out.println("Now I understand how it works!");
}
}
Афиша и мои ожидания
@SpringBootApplication
public class Main {
public static void main(String[] args) {
System.out.println("Ladies and Gents, the main event of this evening is about to happen");
// 1) magician enters the box
System.out.println("1st: Let's verify that The Great Springhoff is in the box");
ConfigurableApplicationContext context = SpringApplication.run(Main.class, args);
// 2) dramatic pause
System.out.println("2nd: Now, after just few seconds, let's open the box again");
Watchable stage = context.getBean(Watchable.class);
stage.watchVeryCarefully();
// 3) dramatic pause
System.out.println("3rd: And now, just after few seconds again, let's open the box for the last time");
context.close();
// ovations
System.out.println("That's all folks!");
}
}
Итак, фокус должен был происходить так: маг залезет в ящик, исчезнет из него, а потом снова появится. Мы сможем увидеть, что находится внутри ящика, трижды:
Как только Великий Sprighoff залезет в ящик (секция 1, потому что
@PostConstruct
)В середине фокуса (секция 2, во время вызова метода
watchVeryCarefully()
)В самом конце (секция 3, потому что
@PreDestroy
)
И во все эти моменты я буду наблюдать за происходящим очень внимательно (потому что @AttentionAspect
)
Шоу начинается
Я был уверен, что Великий Springhoff не сможет сделать ничего, чего я не замечу. Когда фокус начался, я пристально следил за сценой:
Ladies and Gents, the main event of this evening is about to happen
1st: Let's see that The Great Springhoff is already in the box
Looking in the box: Box(magician=Springhoff)
2nd: Now, after just few seconds, let's open the box again
Looking in the box: null
3rd: And now, just after few seconds again, let's open the box for the last time
Looking in the box: Box(magician=Springhoff)
That's all folks
Я был действительно удивлен! Во-первых, фокусник действительно исчез в середине трюка (null в консоли), а во-вторых, сколь внимательно я ни наблюдал за фокусом, я совершенно ничего не заметил (после каждого открытия должно было выводиться сообщение "Now I understand how it works!")
У меня не было ни малейшего понимания, как у него получилось
Расследование начинается
Итак, в этой задаче у нас есть 4 проблемы: исчезающий в середине фокуса волшебник, а также 3 отсутствующих сообщения в консоли, которые должны были быть добавлены аспектом (небольшой спойлер: у каждого из трех отсутствующих сообщениях разные причины)
Перед тем, как продолжить далее, я предлагаю вам самим ненадолго стать детективами, выкачать проект из гитхаба, и попробовать разгадать этот трюк самостоятельно: https://github.com/WieRuindl/detective-cases/tree/master/case3
Тем, кто это сделал, и хочет проверить себя, а также тем, кто просто хочет посмотреть на объяснение, продолжайте чтение :-)
Объяснение. Часть 1: дизайн паттерн Proxy
Для начала давайте представим маленького мальчика Джонни, который просто обожает играть в свою игрушечную машинку:
public class Jonny {
public void play() {
System.out.println("Zoom zoom! The car is speeding ahead!");
}
}
public static void main(String[] args) {
Jonny jonny = new Jonny();
jonny.play();
}
Если мы запустим этот код, то увидим в консоли:
Zoom zoom! The car is speeding ahead!
Но мама Джонни не разрешает ему только играть, она требует, чтобы он сначала сделал домашку, а потом убрался в комнате после своих игр:
public class Jonny {
public void play() {
doHomework(); // Джонни не хочет делать это
System.out.println("Zoom zoom! The car is speeding ahead!");
cleanRoom(); // и это тоже
}
private void doHomework() {
System.out.println("Doing homework...");
}
private void cleanRoom() {
System.out.println("Cleaning room...");
}
}
И это тот самый момент, когда паттерн Proxy приходит на помощь. Как он работает:
Сначала Джонни находит себе друга, а затем объясняет ему, как заниматься теми вещами, которыми сам Джонни заниматься не хочет:
@RequiredArgsConstructor
public class Friend {
private final Jonny jonny;
public void helpJonny() {
doHomework(); // мы объясняем другу, как выполнять домашнюю работу
jonny.play();
cleanRoom(); // а также как правильно убираться в комнате
}
private void doHomework() {
System.out.println("Doing homework...");
}
private void cleanRoom() {
System.out.println("Cleaning room...");
}
Сам Джонни теперь может сосредоточиться лишь на той части, которая ему интересна:
public class Jonny {
public void play() {
System.out.println("Zoom zoom! The car is speeding ahead!"); // никакой домашки и уборки
}
}
А для того, чтобы мама не ругалась, Джонни может просто попросить своего друга о помощи:
public static void main(String[] args) {
Jonny jonny = new Jonny();
Friend friend = new Friend(jonny);
friend.helpJonny(); // друг в беде не бросит
}
Запустим этот код и посмотрим, что выведется в консоль:
Doing homework...
Zoom zoom! The car is speeding ahead!
Cleaning room...
Итак, в чем суть паттерна Proxy:

У нас есть оригинальный объект, который выполняет только ту логику, за которую он отвечает. Мы оборачиваем его в прокси-объект, который может содержать любые дополнительные функции (кэширование, авторизацию и т.д.). Далее мы используем прокси-объект и получаем всю функциональность вместе.
Профит: принцип единственной ответственности, чистый код и другие хорошие слова.
Объяснение. Часть 2: Inversion of Control
Теперь Джонни счастлив. Он может играть со своей классной красной машинкой, когда захочет, а всю скучную работу выполняет его друг. Но одно остается неидеальным: каждый раз, когда Джонни хочет поиграть, он вынужден просить друга о помощи:
public static void main(String[] args) {
Jonny jonny = new Jonny();
Friend friend = new Friend(jonny);
friend.helpJonny(); // мы должны явно вызывать друга и просить его помочь
friend.helpJonny(); // и опять
friend.helpJonny(); // и опять...
}
Давайте изменим это. Для начала мы изменим логику создания прокси-объекта. Пусть у класса Friend будет статичный метод, который будет принимать любой класс в качестве аргумента, и будет возвращать прокси для этого класса:
public class Friend {
@SneakyThrows
public static <T> T provideConstantHelp(Class<T> clazz) {
ProxyFactory factory = new ProxyFactory();
factory.setSuperclass(clazz);
T proxy = (T) factory.createClass().getDeclaredConstructor().newInstance();
// ...
return proxy;
}
}
Теперь мы обернем вызовы методов передаваемого класса дополнительной логикой, которую мы хотим делегировать нашему другу:
public class Friend {
@SneakyThrows
public static <T> T provideConstantHelp(Class<T> clazz) {
ProxyFactory factory = new ProxyFactory();
factory.setSuperclass(clazz);
T proxy = (T) factory.createClass().getDeclaredConstructor().newInstance();
((Proxy) proxy).setHandler((self, thisMethod, proceed, args) -> {
doHomework();
proceed.invoke(self, args);
cleanRoom();
return self;
});
return proxy;
}
private void doHomework() {
System.out.println("Doing homework...");
}
private void cleanRoom() {
System.out.println("Cleaning room...");
}
}
Готово! Теперь нам осталось только один раз "познакомить" Джонни и его нового друга, и магия будет происходить сама собой:
public static void main(String[] args) {
Jonny jonny = Friend.provideConstantHelp(Jonny.class);
jonny.play(); // теперь Джонни может просто играть в свою машинку
jonny.play(); // и ему более не нужно просить его друга о помощи
jonny.play(); // тот уже в курсе своих обязанностей и выполняет их автоматически
}
Doing homework...
Zoom zoom! The car is speeding ahead!
Cleaning room...
Doing homework...
Zoom zoom! The car is speeding ahead!
Cleaning room...
Doing homework...
Zoom zoom! The car is speeding ahead!
Cleaning room...
Здесь надо сделать небольшую паузу и обратить внимание на основную суть данной модификации. Ранее нам приходилось вручную управлять созданием объектов, да и по итогу мы пользовались не самим классом Jonny, который нам, собственно, и нужен, а его прокси-другом, чтобы получить полную функциональность:
Jonny jonny = new Jonny();
Friend friend = new Friend(jonny);
friend.helpJonny();
После же изменений мы получаем прокси-объект того же самого класса, который мы передали аргументом, и пользуемся именно им, а вся дополнительная логика, которая нам нужна, навешивается на него автомагически под капотом:
Jonny jonny = Friend.provideConstantHelp(Jonny.class); // "никто не заметил подмены"
Объяснение. Часть 3: Аннотации
Теперь Джонни счастлив. Он может играть со своей классной красной машинкой, когда хочет, а всю скучную работу выполняет его друг. Но вот однажды он решает не играть с машинкой, а пойти на прогулку:
public class Jonny {
public void play() {
System.out.println("Zoom zoom! The car is speeding ahead!");
}
public void goForAWalk() {
System.out.println("Such a sunny day!"); // вероятно, мама выгнала подышать воздухом
}
}
public static void main(String[] args) {
Jonny jonny = Friend.provideConstantHelp(Jonny.class);
jonny.goForAWalk();
}
И неожиданно его друг появляется, чтобы предоставить свою помощь:
Doing homework...
Such a sunny day!
Cleaning room...
Это получилось из-за того, каким именно образом мы создаем наш прокси объект, а именно та часть логики, где мы оборачиваем вызов оригинального метода дополнительным функционалом:
((Proxy) proxy).setHandler((self, thisMethod, proceed, args) -> {
doHomework();
proceed.invoke(self, args);
cleanRoom();
return self;
});
В текущей реализации мы берём запрашиваемый класс и оборачиваем все его методы дополнительной логикой. Очевидно, это не совсем то, что нам необходимо. Нам нужен способ указать нашему другу, какие методы должны быть обёрнуты дополнительной логикой, а какие - нет. И вот здесь на помощь приходят аннотации.
Аннотации в Java - это специальные метки, которые можно прикреплять к различным элементам вашего кода (классы, методы и т.д.). Они не изменяют работу кода, но предоставляют дополнительную информацию компилятору, системе выполнения или другим инструментам.
Давайте создадим нашу собственную аннотацию и явно укажем, какие методы мы хотим обернуть дополнительной логикой:
@Retention(RetentionPolicy.RUNTIME)
public @interface FriendsHelp { }
public class Jonny {
@FriendsHelp // сама по себе аннотация не добавляет логику, это всего лишь маркер
public void play() {
System.out.println("Zoom zoom! The car is speeding ahead!");
}
public void goingForAWalk() {
System.out.println("Such a sunny day!");
}
}
Теперь надо добавить код, который будет ее как-то обрабатывать, в нашего друга:
if (thisMethod.isAnnotationPresent(FriendsHelp.class)) {
// если метод помечен нашей аннотацией, то оборачиваем дополнительной логикой
doHomework();
proceed.invoke(self, args);
cleanRoom();
return self;
} else {
// иначе вызываем метод как есть
proceed.invoke(self, args);
return self;
}
Готово! Теперь у нас есть своя кастомная аннотация как явный способ указать, какие именно методы должны содержать дополнительную логику, и есть класс-обработчик этой аннотации ("annotation processor", если по-взрослому), который эту логику, собственно, и навешивает, если видит, что метод помечен аннотацией.
Здесь надо сделать еще одну паузу и проговорить некоторые вещи, которые могут быть неочевидны:
Аннотации можно навешивать на методы класса, на поля класса, на сам класс, на аргументы методов - практически на что угодно
Аннотации и их обработчики - это никакая не магия само по себе, а способ разделить код на своего рода "слои", каждый из которых отвечает за что-то свое, а не миксовать все подряд в одном месте
Мы можем создать любое количество аннотаций, которое нам нужно, практически на все случаи жизни. В текущем примере мы играемся с тривиальной логикой, чтобы пример оставался простым, но так-то мы можем не только текст в консоль выводить, а делать вообще любые вещи, которые мы можем просто делать в коде: валидации, авторизации, etc.
На один класс можно навесить любое количество аннотаций, и обернуть его в любое количество прокси (тут могут случиться проблемы, если дополнительная логика обработчиков зависит от порядка выполнения, и в таком случае надо внимательно следить за тем, в каком порядке мы создаем прокси-объекты)
Не обязательно, чтобы один обработчик отвечал только за одну аннотацию. Например, за
@PostConstruct
и@PreDestory
отвечает один обработчик
Дальнейшие улучшения
Рано или поздно мы захотим, чтобы наши обработчики аннотаций работали автоматически, самостоятельно находя все классы, которые они должны модифицировать, и добавим рефлексию для сканирования всех классов в нашем коде, чтобы отслеживать наличие соответствующих аннотаций.
Затем мы поймем, что сканирование всех классов может быть медленным, и придумаем отдельные аннотации, которые будут помечать, какие классы должны быть отсканированы.
Спустя некоторое количество таких шагов с улучшениями, мы так или иначе изобретем наш собственный...
Объяснение. Часть 4: Spring Framework

На данный момент Spring представляет собой нечто монструозное, способное практически на что угодно.
Но где-то глубоко внутри это всего лишь IoC-контейнер, который умеет находить и обрабатывать аннотации.
Надеюсь, что никто не закидает меня тапками, если с некоторой долей обобщения я скажу, что основная аннотация Spring-а - это @Component
. Это тот самый маркер, который сообщает фреймворку, какие классы следует взять "на карандаш" и детально их просмотреть на наличие каких-либо других аннотаций, которые уже реально должны добавить какой-то дополнительный функционал.
Классы, которые таким образом Spring загружает в свой контекст, называются бинами - beans - и я почему-то всегда думал, что это название должно переводиться как бобы (типа фасоль), а относительно недавно я узнал, что это кофейные зерна. Ну, Java же имеет символ в виде кружки кофе, а тут beans, то есть кофейные зерна. Вот так-то, такой вот забавный факт.
Давайте посмотрим, какой жизненный путь проходят эти бины внутри Spring-а:
Загрузка bean definition-ов - Spring загружает bean definition-ы из xml-файла (не используйте xml) / классы, помеченные
@Component
/@Bean
методы из@Configuration
классовБины создаются - вызываются конструкторы для каждого бина. Если бин зависит от других бинов, сначала создаются они. Внедряет зависимости через конструкторы, сеттеры или с помощью
@Autowired
Бины инициализируются - вызываются методы инициализации бинов: init-method из xml-файла (не используйте xml) / методы, помеченные
@PostConstruct
Создание прокси - Spring производит второй проход по всем бинам и оборачивает их в прокси, если это необходимо
Бин существует в ApplicationContext - он управляется Spring контейнером и доступен для использования в рамках райнтайма приложения
Бин уничтожается - destroy-method из XML файла (не используйте XML) / методы, помеченные
@PreDestroy
Итак, с этими всеми знаниями, мы теперь можем начать разматывать клубок тайны фокуса Springhoff-а.
Объяснение. Часть 5: Разгадка тайны
Сначала давайте разберемся, почему в консоли не было сообщения:
Now I understand how it works!
в начале фокуса и в конце, когда ящик открыли в первый и третий разы (то есть когда отработали аннотации @PostConstruct
и @PreDestory
).
Собственно, а почему оно должно было выводиться? Мы повесили на метод открытия ящика нашу кастомную аннотацию и создали аспект, который выводит сообщение, если видит эту аннотацию:
// просто аннотация, никакой логики само по себе
public @interface Attention {
}
@Aspect
@Component
public class AttentionAspect {
// тут мы добавляем новую логику после вызова метода, помеченного этой аннотацией
@After("@annotation(detectivecases.case3.aspect.Attention)")
public void sawSomethingSuspicious() {
System.out.println("Now I understand how it works!");
}
}
@PostConstruct
@PreDestroy
@Attention
public final void watchVeryCarefully() { // вот этого самого метода
System.out.println("Looking in the box: " + box);
}
Но что такое аспект и как вообще работает AOP (Аспектно-Ориентированное Программирование)?
По большому счету, аспект - это просто способ сказать Spring-у, чтобы он создал прокси. Если Spring видит, что какой-либо метод/поле/класс/etc. класса помечены аннотацией, к которой привязан аспект, он оборачивает этот класс в прокси, и добавляет логику аспекта в нужное место. Ну и, очевидно, для того, чтобы эта логика вообще могла выполниться, вызов этого метода должен произойти на прокси, а не на оригинальном классе.
А теперь давайте вспомним жизненный цикл бинов. В описанном чуть выше списке создание прокси происходит на этапе 4, в то время, как init-методы (@PostConstruct
) вызываются на этапе 3. Прокси с дополнительной логикой еще просто не создан, и нет никакой возможности заставить ее отработать на этом этапе.
Хорошо, но почему тогда эта логика не отрабатывает, когда бин уничтожается и вызывается destroy-метод (@PreDestroy
), ведь в этот момент прокси уже создан? Все потому, что Spring вызывает destroy-методы на оригинальных объектах, а не на их прокси. Почему так сделано? Это архитектурное решение создателей Spring-а, суть которого можно описать фразой:
Прокси нужны для внешних вызовов, но жизненный цикл бинов - это внутренний процесс IoC-контейнера, и это ответственность контейнера. Контейнер должен работать с реальными объектами для корректного управления ресурсами и не должен зависеть от наличия или отсутствия прокси
Окей, с этими двумя вызовами разобрались, но почему аспект не сработал, когда мы вручную вызывали этот метод, а также почему в ящике было null и куда пропал Springhoff? На самом деле, у обоих этих явлениях одна причина и она связана с тем, как именно Spring создает прокси.
Объяснение. Часть 6: Механизмы создания proxy
В Spring есть два возможных способа создать прокси, и то, от чего зависит выбор между ними - это имплементирует ли класс какой-либо интерфейс или нет.
JDK Proxy

JDK Proxy - это механизм динамического создания прокси-объектов в Java, который используется для создания прокси для классов, которые реализуют хотя бы один интерфейс. JDK Proxy является частью стандартной библиотеки Java.
public interface ISomething {
void method();
}
public class SomethingImpl implements ISomething {
@Override
public void method() {
// реальная логика
}
}
public class SomethingJdkProxyImpl implements ISomething {
ISomething realObject;
@Override
public void method() {
// место куда мы можем вставить дополнительную логику
realObject.method(); // вызов метода на оригинальном объекте
// место куда мы можем вставить дополнительную логику
}
}
Преимущества:
Является частью самой JDK
Легковесный
Ограничения:
Умеет проксировать только те методы, что определны в интерфейсе
Интерфейс должен определять хотя бы один метод
CGLIB

CGLIB Proxy - это библиотека для создания прокси-объектов на основе байткода. В отличие от JDK Proxy, CGLIB может создавать прокси-объекты для классов, а не только для интерфейсов. CGLIB использует генерацию подклассов целевого класса, переопределяя его методы.
public class SomethingImpl {
public void method() {
// реальная логика
}
}
public class SomethingCglibProxyImpl extends SomethingImpl {
SomethingImpl realObject;
@Override
public void method() {
// место куда мы можем вставить дополнительную логику
realObject.method(); // вызов метода на оригинальном объекте
// место куда мы можем вставить дополнительную логику
}
}
Преимущества:
Может создавать прокси для любых классов, даже если они не реализуют интерфейсы
Может проксировать все методы класса (в том числе методы, которые не определены в интерфейсе)
Ограничения:
Не может проксировать
final
классы и методыИспользует больше ресурсов, так как нужно генерировать новый класс в рантайме
Ну ладно, с этим со всем разобрались, давайте применять новые знания к нашему случаю:
@Component
@RequiredArgsConstructor
public class Stage implements Watchable {
private final Box box;
@PostConstruct
@PreDestroy
@Attention
public final void watchVeryCarefully() {
System.out.println("Looking in the box: " + box);
}
}
public interface Watchable {
void watchVeryCarefully();
}
Вот наш класс, он реализует интерфейс, в интерфейсе определен один метод, значит, для создания прокси будет использоваться JDK Proxy, верно?
А вот и нет, а все потому что...
Объяснение. Часть 7: Spring Boot
Рассуждения выше были бы полностью правдивы, будь у нас приложение на чистом Spring Framework, но оно использует Spring Boot:
@SpringBootApplication // вот этот парень
public class Main {
// ...
}
Spring Boot - это расширение для Spring Framework, предназначенное для упрощения процесса разработки и развертывания приложений. Он предоставляет готовую инфраструктуру, конфигурации по умолчанию и утилиты, серьезно помогающих в разработке, и вообще он большой молодец, но для нашего случая сейчас важно то, что Spring Boot AOP работает несколько иначе, чем Spring AOP, и механизмом создания прокси по умолчанию является CGLIB вне зависимости от наличия или отсутствия интерфейсов. Если мы залезем глубоко во внутренности Spring Boot-а, то мы найдем класс AopAutoConfiguration
, который содержит в себе следующее:
@Configuration(proxyBeanMethods = false)
@EnableAspectJAutoProxy(proxyTargetClass = true)
@ConditionalOnProperty(prefix = "spring.aop", name = "proxy-target-class", havingValue = "true",
matchIfMissing = true)
static class CglibAutoProxyConfiguration {}
И вот это поле: matchIfMissing = true
как раз и задает поведение по умолчанию. Конфигурация Spring Boot-а ожидает увидеть проперти spring.aop.proxy-target-class
, и если оно false
, то механизмом по умолчанию будет JDK Proxy, если же оно true
или отсутствует вообще, то будет использоваться CGLIB.
Можно добавить, что ранее этой разницы в поведении не было, и AOP в Spring Boot работал так же, как и в обычном Spring, но начиная с версии Spring Boot 2.0 произошел переход от JDK Proxy к CGLIB Proxy по умолчанию. Говорят, это было сделано для улучшения гибкости и совместимости с классами, не имеющими интерфейсов. О нарушении обратной совместимости и всех возможных проблемах, которые апргейд на новую версию, скорее всего, принес для множества команд и проектов, мы, пожалуй, задумываться не будем.
Итак, каким же именно образом это сказывается в нашем случае? Перед тем, как начать обсуждение механизмом проксирования в Spring-е, мы остановились на проблеме отсутствия сообщения в консоли, которое наш аспект должен был добавить, а также что в переменной box
было null
. С этого места и продолжим.
Взглянем еще раз на наш код:
@Component
@RequiredArgsConstructor
public class Stage implements Watchable {
private final Box box;
@PostConstruct
@PreDestroy
@Attention
public final void watchVeryCarefully() {
System.out.println("Looking in the box: " + box);
}
}
Обе эти проблемы связаны с одним единственным словом final
:
public final void watchVeryCarefully() // вот этим
Поскольку наш прокси - это класс-наследник, а метод в оригинальном классе помечен как final
, CGLIB не может переопределить этот метод. Код остаётся неизменным, поэтому логика аспекта просто не может быть добавлена - вот почему в консоли нет сообщения от аспекта.
Но final
приводит не только к отсутствию сообщения. Поскольку наш прокси не может переопределить этот метод, он также не может внутри него сослаться на реальный объект, и взять значение box
из него. Вместо этого он использует свое собственное поле внутри метода. Несмотря на то, что CGLIB наследует все поля родительского класса, он не копирует все их значения:
Поля примитивных типов и String будут содержать значения
Поля же ссылочных типов будут содержать null
Поправить эту ситуацию можно двумя способами:
можно просто убрать
final
из определения метода, и тогда сообщение от аспекта будет добавлено, и сам Springhoff никуда из ящика не пропадетлибо можно добавить проперти в
spring.aop.proxy-target-class=false
вapplication.yml
. В этом случае механизм проксирования переключится с CGLIB на JDK Proxy, дял которогоfinal
методы не являются проблемой, лишь бы сам метод наследовался от интерфейса
Объяснение. Часть 8: Все вместе
Итак, мы прошлись по всем проблемам и проговорили все причины возникновения той или иной ошибки. Давайте теперь соберем все в одном месте и подытожим:
Во время первого сканирования классов и настройки бинов, Spring выполняет логику
@PostConstruct
, но на этом этапе прокси с логикой аспектов еще не создан, поэтому сообщение не выводитсяИз-за аннотации
@Attention
Spring понимает, что здесь нужен прокси-класс для добавления логики аспектаПоскольку мы используем Spring Boot, а также нигде не указали явно, что хотим изменить его поведение по умолчанию, механизмом проксирования будет CGLIB, который создаст класс-наследник в рантайме
Ключевое слово
final
не позволяет прокси переопределить оригинальный метод и добавить логику аспекта, поэтому сообщение не будет выведеноОпять же из-за
final
метод ссылается на полеbox
этого же класса (т.е. прокси-класса), а не на реальный объект, а так какbox
- это ссылочный тип, в нем будет лежатьnull
Ну и наконец, когда контекст приложения закрывается и выполняется логика
@PreDestroy
, вызывается метод оригинального объекта, а не метода прокси, так что сообщение из аспекта снова не выводится
Вот и все. Дело раскрыто, тайна фокуса Великого Springhoff-а разгадана
Заключение
Надеюсь, вам понравилось это детективное приключение. Если вдруг вам захочется посмотреть на этот материал именно в виде оригинальной презентации со всем оформлением и мемами, вот ссылка.
Я старался сделать эту презентацию максимально простой, чтобы она могла послужить обучающим материалом для людей, только изучающих Java и Spring Framework. Какие‑то вещи, которые можно было бы рассказать и обсудить, я упустил намеренно, чтобы не переусложнять ее, какие‑то, возможно, упустил неосознанно. В любом случае, если у вас есть советы, критика, предложения, советы, etc. — давайте обсуждать в комментариях, мне будет интересно почитать мнения.
Также, если захотите использовать эту презентацию для каких угодно ваших целей, feel free это делать. Укажите только ссылку на первоисточник :-)