Продолжаю рассказ о том как безработный Java - разработчик ищет себе занятия...))

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


Вступление

Для проверки работы наших proxy, создам такой статический метод, который в будущем можно применять везде:

SpringProxyInspector
import org.springframework.aop.Advisor;
import org.springframework.aop.framework.Advised;
import org.springframework.aop.support.AopUtils;
import org.springframework.core.Ordered;

public class SpringProxyInspector {

    public static void inspect(Object bean, String beanName) {
        System.out.println("\n=======================================================");
        System.out.println("ПРОВЕРКА ПРОКСИ: " + beanName);
        System.out.println("=======================================================");
        System.out.println("Текущий класс: " + bean.getClass().getName());
    
        if (!(bean instanceof Advised advised)) {
            System.out.println(" Это обычный объект, не Spring AOP прокси.");
            System.out.println("=======================================================\n");
            return;
        }

        String proxyType = AopUtils.isCglibProxy(bean) ? "CGLIB (через наследование)" 
                         : AopUtils.isJdkDynamicProxy(bean) ? "JDK Dynamic Proxy (через интерфейс)" 
                         : "Неизвестный тип прокси";
        
        System.out.println(" ├── Тип прокси: " + proxyType);
        System.out.println(" ├── Целевой класс (Target): " + advised.getTargetSource().getTargetClass().getName());

        Advisor[] advisors = advised.getAdvisors();
        System.out.println(" └── Найдено Advisor'ов: " + advisors.length);

        for (int i = 0; i < advisors.length; i++) {
            Advisor advisor = advisors[i];
            
            String orderInfo = "Не задан";
            if (advisor instanceof Ordered ordered) {
                int order = ordered.getOrder();
                orderInfo = (order == Ordered.HIGHEST_PRECEDENCE) ? "HIGHEST_PRECEDENCE" 
                          : (order == Ordered.LOWEST_PRECEDENCE) ? "LOWEST_PRECEDENCE" 
                          : String.valueOf(order);
            }

            System.out.printf("     [%d] %s%n", i + 1, advisor.getClass().getSimpleName());
            System.out.printf("         ├── Order: %s%n", orderInfo);

            if (advisor.getAdvice() != null) {
                System.out.printf("         └── Advice: %s%n", advisor.getAdvice().getClass().getName());
            } else {
                System.out.println("         └── Advice: Отсутствует");
            }
        }
        System.out.println("=======================================================\n");
    }
}

Благодаря ему можно немного приоткрыть завесу - как же там все устроено?

Создание тестового стенда

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

Определим интерфейс:

public interface OrderService {
    void processOrder();
    void outerMethod();
}

И реализацию с тремя аннотациями:

@Service
public class OrderServiceImpl implements OrderService {

    @Cacheable("orders")
    @Transactional
    @LogExecutionTime
    public void processOrder() {
        System.out.println("    [Target] Выполняется реальная бизнес-логика метода processOrder()!");
    }
}
  • @Cacheable("orders") — включает кеширование, требует перехвата метода для проверки кеша до вызова и сохранения результата после.

  • @Transactional — управление транзакциями, перехват для открытия/закрытия транзакции.

  • @LogExecutionTime — кастомная аннотация, которую мы обрабатываем через AspectJ-аспект.

Три аннотации на одном методе это типичная картина в современном Spring-приложении. Мы уже привыкли, что @Cacheable магическим образом кеширует результат, @Transactional открывает транзакцию, а кастомный @LogExecutionTime замеряет время выполнения.

Вот кастомная аннотация:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LogExecutionTime {}

И аспект, который её обрабатывает:

@Aspect
@Component
public class LogExecutionTimeAspect {

    @Around("@annotation(LogExecutionTime)")
    public Object logTime(ProceedingJoinPoint joinPoint) throws Throwable {
        System.out.println("    [Aspect] Старт замера времени...");
        Object result = joinPoint.proceed();
        System.out.println("    [Aspect] Конец замера времени.");
        return result;
    }
}

Не забудем включить кеширование через @EnableCaching на основном классе приложения и всё готово.

Теперь в main классе добавим в CommandLineRunner и можно запускать:

@SpringBootApplication
@EnableCaching
public class SpringProxyApplication {

    public static void main(String[] args) {
        SpringApplication.run(SpringProxyDemoApplication.class, args);
    }

    @Bean
    public CommandLineRunner runInspector(OrderService orderService) {
        return args -> {
          System.out.println(orderService.getClass());
          orderService.processOrder();
    };
}

Я вызываю OrderService. Но почему это не мой класс?

Запускаем приложение и видим в консоли:

class oleborn.spring.OrderServiceImpl$$SpringCGLIB$$0
    [Aspect] Старт замера времени...
    [Target] Выполняется реальная бизнес-логика метода processOrder()!
    [Aspect] Конец замера времени.

Первая строка будет явной неожиданностью для новичка. Ведь мы ожидали увидеть class OrderServiceImpl, а видим длинное имя с $$SpringCGLIB$$. И это совсем не наш класс.

Если Spring действительно подменил наш объект, значит где-то должен существовать и настоящий OrderServiceImpl. Но кто его создал? Где он сейчас находится? И почему вместо него мы получили совершенно другой объект?

Давайте разбираться...

Важный момент: если бы мы убрали все аннотации, требующие перехвата, Spring вернул бы нам обычный OrderServiceImpl, и getClass() показал бы именно его. Но в реальных проектах вы почти всегда встретите хотя бы @Transactional, поэтому прокси будет создаваться незаметно.

Кто подменил мой бин?

Жизнь нашего OrderServiceImpl начинается совершенно обычно. Spring считывает конфигурацию, создаёт BeanDefinition, вызывает конструктор - и получает чистый экземпляр класса. Никаких транзакций, никаких кэшей, никаких аспектов. Просто голый объект в памяти.

Но где-то между этим моментом и тем, когда бин попадает в ApplicationContext, происходит подмена. Давайте включим дебаггер и проследим этот путь по шагам.

В Spring есть четкое разделение на две фазы: фаза инициализации (при старте приложения) и фаза выполнения (когда вызывается метод). Сейчас мы исследуем первую.

Шаг 1. Перехват инициализации

Чтобы понять, где именно происходит подмена, поставим брейкпоинт в методе postProcessAfterInitialization() класса AbstractAutoProxyCreator. Это один из ключевых BeanPostProcessor'ов, отвечающих за автоматическое создание прокси. Именно сюда контейнер приносит наш свежесозданный объект перед тем, как отдать его наружу.

Схематично это выглядит так:

BeanDefinition → создание экземпляра OrderServiceImpl (голый объект)
                    ↓
    все BeanPostProcessor'ы отрабатывают
                    ↓
    AbstractAutoProxyCreator.postProcessAfterInitialization()
                    ↓
    wrapIfNecessary() — нужно ли создавать прокси?
                    ↓
    если да → сбор через getAdvicesAndAdvisorsForBean() Advisor'ов
                    ↓
    чеоез createProxy() и ProxyFactory начинается создание прокси
                    ↓
    если нет → возвращается оригинальный бин

Заходим в дебаггер. Видим, что наш OrderServiceImpl уже создан, но пока без всяких обёрток. AbstractAutoProxyCreator проверяет: есть ли для этого бина подходящие Advisor'ы? Если есть то бин отправляется на хирургический стол wrapIfNecessary(). Если нет - возвращается как есть.

Конкретная реализация, которая работает в большинстве Spring-приложений, это AnnotationAwareAspectJAutoProxyCreator. Класс сканирует все зарегистрированные аспекты и Advisor'ы и решает, какие бины нужно обернуть.

Шаг 2. Откуда берутся Advisor'ы?

Отпускаем дебаггер на шаг вперед, и выполнение проваливается в метод getAdvicesAndAdvisorsForBean(). Здесь Spring собирает все Advisor'ы, подходящие для нашего бина.

Когда я впервые посмотрел в отладчике на собранный массив, у меня возник логичный вопрос: 

А откуда вообще взялся объект BeanFactoryTransactionAttributeSourceAdvisor? Ведь я вроде нигде не писал new BeanFactoryTransactionAttributeSourceAdvisor()?

Они не появляются из воздуха. Если проследить их происхождение, всё встает на свои места:

  • Мы написали @EnableCaching на основном классе приложения и под капотом создался инфраструктурный бин BeanFactoryCacheOperationSourceAdvisor.

  • Мы добавили @Transactional и Spring загрузил BeanFactoryTransactionAttributeSourceAdvisor (через @EnableTransactionManagement, даже если мы не ставили её явно, Spring Boot автоматически включает транзакции при наличии spring-tx на classpath).

  • Мы создали кастомный аспект с @Aspect и @Around("@annotation(LogExecutionTime)") и Spring создал InstantiationModelAwarePointcutAdvisorImpl на основе нашего аспекта.

Теперь, когда наш голый OrderServiceImpl попадает в getAdvicesAndAdvisorsForBean(), Spring сканирует его методы и сверяет их с правилами каждого Advisor'а.

Совпало? Кладём в список.

Напомню, что в нашем случае метод processOrder() помечен тремя аннотациями:

@Cacheable("orders")
@Transactional
@LogExecutionTime
public void processOrder() { ... }

Поэтому в список попадают три Advisor'а: для кеша, для транзакций и для кастомного аспекта. Плюс системный ExposeInvocationInterceptor добавляется автоматически.

Важнейший момент для понимания:

До этого момента многие думали что аннотации @Transactional@Cacheable@LogExecutionTime и дальше неявно как-то продолжают работу. Но оказывается, что после того как Spring собрал Advisor'ы, аннотации сделали свою работу и навсегда уходят со сцены. В памяти остаются только конкретные Java-объекты: TransactionInterceptorCacheInterceptor и ваш LogExecutionTimeAspect (обёрнутый в AspectJAroundAdvice).

Аннотации нужны только на этапе bootstrap. После создания Advisor'ов они больше не участвуют. Это важно понять: если вы меняете аннотацию в рантайме (что вряд ли возможно), это не повлияет на уже созданный прокси.

Шаг 3. Жесткая сортировка

Итак, у нас на руках список из перехватчиков. Следующий вопрос логичен: в каком порядке они будут выполняться? Ведь если кэш отработает внутри транзакции БД, мы получим проблемы, или наоборот транзакция откроется, но результат не закэшируется.

Список отправляется в метод sortAdvisors(). Здесь работает AnnotationAwareOrderComparator. Алгоритм прагматичен и проверяет интерфейсы в строгом порядке:

  1. PriorityOrdered однозначно высший приоритет. Здесь системный ExposeInvocationInterceptor гордо забирает себе индекс 0.

  2. Ordered имеет базовый приоритет. Инфраструктурные перехватчики (транзакции, кэш) по умолчанию возвращают LOWEST_PRECEDENCE и отправляются в конец.

  3. @Order если есть, то читаются пользовательские приоритеты на кастомных аспектах, чтобы расставить их между системными.

Для наглядности того что получилось давайте запустим наш SpringProxyInspector и посмотрим что он вернет в консоль:

=======================================================
ПРОВЕРКА ПРОКСИ: OrderService
=======================================================
Текущий класс: oleborn.spring.OrderServiceImpl$$SpringCGLIB$$0
 ├── Тип прокси: CGLIB (через наследование)
 ├── Целевой класс (Target): oleborn.taskswithspring.Issue.OrderServiceImpl
 └── Найдено Advisor'ов: 4
     [1] ExposeInvocationInterceptor
         ├── Order: HIGHEST_PRECEDENCE
         └── Advice: org.springframework.aop.interceptor.ExposeInvocationInterceptor
     [2] BeanFactoryCacheOperationSourceAdvisor
         ├── Order: LOWEST_PRECEDENCE
         └── Advice: org.springframework.cache.interceptor.CacheInterceptor
     [3] BeanFactoryTransactionAttributeSourceAdvisor
         ├── Order: LOWEST_PRECEDENCE
         └── Advice: org.springframework.transaction.interceptor.TransactionInterceptor
     [4] InstantiationModelAwarePointcutAdvisorImpl
         ├── Order: LOWEST_PRECEDENCE
         └── Advice: org.springframework.aop.aspectj.AspectJAroundAdvice
=======================================================

Обратите внимание: первый Advisor имеет Order: HIGHEST_PRECEDENCE (значение -2147483647). Все остальные — LOWEST_PRECEDENCE (2147483647). Маршрут выполнения предрешен. Массив отсортирован. Теперь он готов к передаче в ProxyFactory.

Шаг 4. Фабрика и заморозка

Идем дальше. У нас есть оригинальный объект и отсортированный массив перехватчиков. Кто их склеит?

В дело вступает ProxyFactory. Запуск происходит через метод createProxy(). Заглянем в код (взят оригинальный, осмелился добавить моих комментариев):

createProxy()
protected Object createProxy(Class<?> beanClass, @Nullable String beanName,
			@Nullable Object[] specificInterceptors, TargetSource targetSource) {

		return buildProxy(beanClass, beanName, specificInterceptors, targetSource, false);
	}

	private Class<?> createProxyClass(Class<?> beanClass, @Nullable String beanName,
			@Nullable Object[] specificInterceptors, TargetSource targetSource) {

		return (Class<?>) buildProxy(beanClass, beanName, specificInterceptors, targetSource, true);
	}

	private Object buildProxy(Class<?> beanClass, @Nullable String beanName,
			@Nullable Object[] specificInterceptors, TargetSource targetSource, boolean classOnly) {

		if (this.beanFactory instanceof ConfigurableListableBeanFactory clbf) {
			AutoProxyUtils.exposeTargetClass(clbf, beanName, beanClass);
		}

		ProxyFactory proxyFactory = new ProxyFactory();
		proxyFactory.copyFrom(this);

		if (proxyFactory.isProxyTargetClass()) {
			// Explicit handling of JDK proxy targets and lambdas (for introduction advice scenarios)
			if (Proxy.isProxyClass(beanClass) || ClassUtils.isLambdaClass(beanClass)) {
				// Must allow for introductions; can't just set interfaces to the proxy's interfaces only.
				for (Class<?> ifc : beanClass.getInterfaces()) {
					proxyFactory.addInterface(ifc);
				}
			}
		}
		else {
			// No proxyTargetClass flag enforced, let's apply our default checks...
			if (shouldProxyTargetClass(beanClass, beanName)) {
				proxyFactory.setProxyTargetClass(true);
			}
			else {
				evaluateProxyInterfaces(beanClass, proxyFactory);
			}
		}
        //именно тут в buildAdvisors() идет определение advisors
		Advisor[] advisors = buildAdvisors(beanName, specificInterceptors);

        //Загружаем отсортированный массив Advisor'ов
		proxyFactory.addAdvisors(advisors);
      
        //Прячем оригинальный бин
		proxyFactory.setTargetSource(targetSource);
		customizeProxyFactory(proxyFactory);

        //Заморозка по умолчанию private boolean freezeProxy = false;
		proxyFactory.setFrozen(this.freezeProxy);
		if (advisorsPreFiltered()) {
			proxyFactory.setPreFiltered(true);
		}

		// Use original ClassLoader if bean class not locally loaded in overriding class loader
		ClassLoader classLoader = getProxyClassLoader();
		if (classLoader instanceof SmartClassLoader smartClassLoader && classLoader != beanClass.getClassLoader()) {
			classLoader = smartClassLoader.getOriginalClassLoader();
		}
		return (classOnly ? proxyFactory.getProxyClass(classLoader) : proxyFactory.getProxy(classLoader));
	}

И, наконец, финальный шаг — вызов proxyFactory.getProxy(). CGLIB генерирует байт-код для того самого OrderServiceImpl$$SpringCGLIB$$0.

Но как именно отсортированный массив перехватчиков попадает внутрь этого сгенерированного класса? Лежит ли он прямо в полях?

Если мы копнем исходники ObjenesisCglibAopProxy, мы увидим настоящую, многослойную архитектуру:

Метод setFrozen(true) часто упускают из вида. Зачем он нужен? Не будем гадать, откроем JavaDoc самих разработчиков Spring:

"Set whether the config is frozen. When config is frozen, no advice changes can be made."

Тут Spring может аппаратно запечатывать конфигурацию. Это значит, что если он это сделает, вы больше не сможете в рантайме привести этот прокси к интерфейсу Advised и динамически добавить туда новый Advisor. Массив зафиксирован. Это сделано для производительности и потокобезопасности. Поэтому после создания прокси его конфигурация становится неизменной.

А кто в итоге произведет сборку нашего прокси? Сейчас почти всегда Spring создаёт прокси через CGLIB.

Почему сейчас почти всегда CGLIB?

Ведь Java есть встроенный механизм динамического проксирования с первого дня java.lang.reflect.Proxy.

На самом деле Spring поддерживает оба механизма. Более того, в старых версиях Spring (до Boot 2.x) по умолчанию использовался JDK Dynamic Proxy. Но времена изменились.

Давайте разберёмся, в чём разница.

JDK Dynamic Proxy это встроенный механизм, который работает только с интерфейсами. Он создаёт прокси-объект, реализующий те же интерфейсы, что и целевой класс. При вызове метода управление передаётся в InvocationHandler.invoke(), где через рефлексию (Method.invoke()) вызывается оригинальный метод.

CGLIB (Code Generation Library) работает иначе. Вместо реализации интерфейсов он генерирует байт-код и создаёт подкласс целевого класса. Вызов метода перехватывается через MethodInterceptor.intercept(). Но ключевое отличие — внутри CGLIB использует FastClass вместо рефлексии. FastClass генерирует для каждого класса специальный вспомогательный класс, который вызывает методы по индексу, а не по имени через reflection. Это даёт прирост производительности при вызовах.

Правда, есть нюанс: CGLIB создаёт прокси медленнее, чем JDK-вариант. Но поскольку в Spring бины обычно создаются один раз при старте, это не критично. А вот скорость вызовов в рантайме важнее.

Почему Spring Boot предпочитает CGLIB

Начиная со Spring Boot 2.x, по умолчанию используется CGLIB. Этому есть несколько причин.

Первая и главная - не нужно создавать интерфейсы. JDK Dynamic Proxy требует, чтобы класс реализовывал хотя бы один интерфейс. В современной разработке часто пишут сервисы без интерфейсов и это нормально. CGLIB позволяет проксировать такие классы без лишних телодвижений.

Вторая удобство для разработчиков. Вы просто пишете класс, ставите @Service, добавляете @Transactional и всё работает. Никаких дополнительных интерфейсов, никаких настроек.

Третья производительность. CGLIB быстрее выполняет вызовы методов благодаря FastClass.

Шаг 5. Анатомия CGLIB-прокси

И, наконец-то, финальный шаг - вызов proxyFactory.getProxy().

CGLIB генерирует байт-код для того самого OrderServiceImpl$$SpringCGLIB$$0.

Но как именно отсортированный массив перехватчиков попадает внутрь этого сгенерированного класса? Лежит ли он прямо в полях?

Если мы копнем исходники ObjenesisCglibAopProxy, мы увидим настоящую, многослойную архитектуру(схематичное отражение структуры Proxy Spring):

Сгенерированный класс (OrderServiceImpl$$SpringCGLIB$$0)
    │
    ├── Callback[] (массив коллбеков)
    │       │
    │       └── [0] DynamicAdvisedInterceptor (главный обработчик)
    │               │
    │               └── AdvisedSupport
    │                       ├── Advisor[] (наш отсортированный массив)
    │                       └── TargetSource (ссылка на оригинальный бин)
    │
    └── методы-обёртки (переопределяют все публичные методы)
            │
            └── каждый метод вызывает DynamicAdvisedInterceptor.intercept()

Ключевой момент: массив Advisor[] не лежит прямо в полях прокси. Он спрятан глубоко внутри AdvisedSupport, который хранится в DynamicAdvisedInterceptor. Именно поэтому наш SpringProxyInspector использует интерфейс Advised, чтобы добраться до этой структуры.

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

А что произойдёт, когда из соседнего сервиса кто-то вызовет orderService.processOrder()?

Кто начнёт распаковывать эту матрёшку и выполнять наш массив?

ReflectiveMethodInvocation - вот кто запускает весь движ

Где-то в коде происходит вызов:

orderService.processOrder();

Мы знаем, что orderService это не наш OrderServiceImpl, а CGLIB-прокси с именем OrderServiceImpl$$SpringCGLIB$$0. Что же произойдетт внутри этого сгенерированного класса?

Шаг 1. Вход в прокси

Когда вы вызываете метод на CGLIB-прокси, на самом деле вызывается метод, который сгенерировал CGLIB. Каждый публичный метод прокси выглядит примерно так (упрощённо):

public void processOrder() {
    //внутри CGLIB хранит ссылку на массив Callback[]
    //вызывает метод intercept() у главного обработчика (DynamicAdvisedInterceptor)
    MethodInterceptor interceptor = ... //DynamicAdvisedInterceptor
    interceptor.intercept(this, method, args, methodProxy);
}

Этот код генерируется во время старта приложения, когда proxyFactory.getProxy() создаёт байт-код. Он не виден в вашем исходном коде, но именно он выполняется, когда вы вызываете метод.

Управление передаётся в DynamicAdvisedInterceptor.intercept().

Шаг 2. DynamicAdvisedInterceptor главный диспетчер

DynamicAdvisedInterceptor это реализация MethodInterceptor (из CGLIB), которая является центральным узлом. Именно здесь лежит AdvisedSupport с нашим отсортированным массивом перехватчиков и ссылкой на оригинальный бин.

В методе intercept() происходит следующее: создаётся объект ReflectiveMethodInvocation, который получает на вход все перехватчики в виде списка interceptorsAndDynamicMethodMatchers, а также ссылку на целевой объект и метод.

Затем вызывается invocation.proceed().

Это сердце системы. Именно здесь оживают наши Advisor'ы.

Шаг 3. Как работает ReflectiveMethodInvocation.proceed()

ReflectiveMethodInvocation сгенерированный в любой реализации содержит рекурсивный метод proceed().

Он увеличивает индекс и передаёт самого себя в следующий перехватчик как в паттерне Chain of Responsibility. Каждый перехватчик, в свою очередь, внутри своего invoke() делает что-то до и после вызова proceed().

Ключевая идея паттерна: цепочка не хранится в отдельной структуре. Она раскручивается динамически во время вызова.

Шаг 4. Реальный запуск

Запускаем наше приложение. В консоли видим:

--- Нормальный вызов (через прокси) ---
    [Aspect] Старт замера времени...
    [Target] Выполняется реальная бизнес-логика метода processOrder()!
    [Aspect] Конец замера времени.

Но что произошло в хронологическом порядке? Давайте разложим по шагам.

Шаг 5. Порядок выполнения

Помним, что у нас есть четыре перехватчика в отсортированном порядке:

  1. ExposeInvocationInterceptor (HIGHEST_PRECEDENCE)

  2. CacheInterceptor (LOWEST_PRECEDENCE)

  3. TransactionInterceptor (LOWEST_PRECEDENCE)

  4. AspectJAroundAdvice (LOWEST_PRECEDENCE)

Теперь запускается proceed(). Вот как это выглядит на уровне стека:

Примерный вид
ReflectiveMethodInvocation.proceed()
    │
    ├── currentInterceptorIndex = 0 → берёт перехватчик [0]
    │   └── ExposeInvocationInterceptor.invoke()
    │       │
    │       ├── что-то делает до (выставляет текущий вызов в ThreadLocal)
    │       ├── вызывает invocation.proceed()  ← рекурсивный вызов!
    │       │
    │       │   ReflectiveMethodInvocation.proceed()
    │       │   │
    │       │   ├── currentInterceptorIndex = 1 → берёт [1]
    │       │   │   └── CacheInterceptor.invoke()
    │       │   │       │
    │       │   │       ├── проверяет кэш до вызова
    │       │   │       ├── вызывает invocation.proceed()
    │       │   │       │
    │       │   │       │   ReflectiveMethodInvocation.proceed()
    │       │   │       │   │
    │       │   │       │   ├── currentInterceptorIndex = 2 → берёт [2]
    │       │   │       │   │   └── TransactionInterceptor.invoke()
    │       │   │       │   │       │
    │       │   │       │   │       ├── открывает транзакцию
    │       │   │       │   │       ├── вызывает invocation.proceed()
    │       │   │       │   │       │
    │       │   │       │   │       │   ReflectiveMethodInvocation.proceed()
    │       │   │       │   │       │   │
    │       │   │       │   │       │   ├── currentInterceptorIndex = 3 → берёт [3]
    │       │   │       │   │       │   │   └── AspectJAroundAdvice.invoke()
    │       │   │       │   │       │   │       │
    │       │   │       │   │       │   │       ├── [Aspect] Старт замера времени...
    │       │   │       │   │       │   │       ├── вызывает invocation.proceed()
    │       │   │       │   │       │   │       │
    │       │   │       │   │       │   │       │   ReflectiveMethodInvocation.proceed()
    │       │   │       │   │       │   │       │   │
    │       │   │       │   │       │   │       │   └── currentInterceptorIndex = 4 (последний)
    │       │   │       │   │       │   │       │       └── invokeJoinpoint()
    │       │   │       │   │       │   │       │           └── [Target] Выполняется реальная бизнес-логика...
    │       │   │       │   │       │   │       │
    │       │   │       │   │       │   │       ├── [Aspect] Конец замера времени.
    │       │   │       │   │       │   │       └── возвращает результат
    │       │   │       │   │       │   │
    │       │   │       │   │       │   └── результат возвращается TransactionInterceptor
    │       │   │       │   │       │
    │       │   │       │   │       ├── коммит/rollback транзакции
    │       │   │       │   │       └── возвращает результат
    │       │   │       │   │
    │       │   │       │   └── результат возвращается CacheInterceptor
    │       │   │       │
    │       │   │       ├── сохраняет результат в кэш
    │       │   │       └── возвращает результат
    │       │   │
    │       │   └── результат возвращается ExposeInvocationInterceptor
    │       │
    │       └── очищает ThreadLocal
    │
    └── конечный результат

Шаг 6. Важные моменты

Вы видите, что цепочка выполняется строго в порядке сортировкиExposeInvocationInterceptor всегда первый, но он не влияет на бизнес-логику, его задача сделать текущий MethodInvocation доступным через ThreadLocal для других компонентов.

CacheInterceptor проверяет кэш до вызова целевого метода. Если данные есть в кэше, он может даже не вызывать proceed() и тогда транзакция не откроется, и аспект не сработает. Это важно понимать для проектирования.

TransactionInterceptor открывает транзакцию до вызова и закрывает (коммит/rollback) после.

AspectJAroundAdvice это наш кастомный аспект, который замеряет время до и после всего остального, потому что он последний в цепочке перед целевым методом. Если бы мы изменили его приоритет через @Order, он мог бы оказаться раньше транзакции или кэша.

Итог: proceed() это движок, который передаёт управление по цепочке перехватчиков. Каждый перехватчик может выполнить свою логику до и после вызова proceed(), тем самым оборачивая не только целевой метод, но и все последующие перехватчики.

Фактически это классический паттерн Chain of Responsibility, реализованный через рекурсию. Каждый перехватчик может:

  • Выполнить действия до proceed()

  • Решить не вызывать proceed() (например, вернуть значение из кэша)

  • Выполнить действия после proceed()

  • Обернуть исключение или изменить результат

Почему self-invocation ломает транзакции?

Разобравшись с механизмом ReflectiveMethodInvocation, мы легко объясним самую частую боль Spring-разработчиков. Если вы хоть раз сталкивались с ситуацией, когда @Transactional внутри класса не работает, то это оно.

Взглянем на наш OrderServiceImpl. У нас есть метод outerMethod(), который вызывает processOrder() внутренним образом:

@Service
public class OrderServiceImpl implements OrderService {

    @Cacheable("orders")
    @Transactional
    @LogExecutionTime
    public void processOrder() {
        System.out.println("    [Target] Выполняется реальная бизнес-логика метода processOrder()!");
    }

    public void outerMethod() {
        System.out.println("    [Target] Вызван outerMethod(). Сейчас вызову processOrder() через this...");
        processOrder();   //вызов через this
    }
}

Теперь вызываем outerMethod() через внедрённый сервис. Смотрим на вывод:

--- Вызов с проблемой Self-Invocation ---
[Target] Вызван outerMethod(). Сейчас вызову processOrder() через this...
[Target] Выполняется реальная бизнес-логика метода processOrder()!

Обратите внимание: нет ни одной строки от аспекта. Нет [Aspect] Старт замера времени..., нет [Aspect] Конец замера времени. Нет никаких следов транзакций или кэша.

Почему?

Вспомним, как выглядит архитектура прокси:

Прокси (OrderServiceImpl$$SpringCGLIB$$0)
    │
    └── DynamicAdvisedInterceptor
            └── AdvisedSupport
                    ├── Advisor[] (массив перехватчиков)
                    └── TargetSource → OrderServiceImpl (оригинальный бин)

Когда вы вызываете orderService.outerMethod() извне, вы обращаетесь к прокси. Прокси перехватывает вызов, запускает DynamicAdvisedInterceptor, и создаётся ReflectiveMethodInvocation, который раскручивает цепочку перехватчиков.

Но внутри outerMethod() происходит this.processOrder()this это ссылка на оригинальный объект OrderServiceImpl, а не на прокси. Вызов идёт напрямую к целевому объекту, минуя Callback[]DynamicAdvisedInterceptorAdvisedSupport и все Advisor'ы.

Цепочка перехватчиков просто не запускается. Поэтому @Transactional@Cacheable@LogExecutionTime и другие аннотации не срабатывают при внутренних вызовах.

Это фундаментальное ограничение Spring AOP, основанного на прокси. Прокси работает только на уровне внешних вызовов через внедрённый бин. Внутренние вызовы через this всегда идут напрямую к объекту.

Как это все в итоге работает? (Собираем пазл)

Давайте соберём всё в единую картину:

Картина

Фаза инициализации (при старте приложения)

┌────────────────────────────────────────────────────────────────────┐
│ BeanDefinition → OrderServiceImpl (голый объект)                  │
│                    ↓                                               │
│ AbstractAutoProxyCreator.postProcessAfterInitialization()         │
│                    ↓                                               │
│ getAdvicesAndAdvisorsForBean()                                    │
│     ↓                                                             │
│     Сбор Advisor'ов:                                              │
│     - @EnableCaching → BeanFactoryCacheOperationSourceAdvisor    │
│       → CacheInterceptor                                          │
│     - @Transactional → BeanFactoryTransactionAttributeSourceAdvisor│
│       → TransactionInterceptor                                    │
│     - @Aspect → InstantiationModelAwarePointcutAdvisorImpl        │
│       → AspectJAroundAdvice                                       │
│     - Системный → ExposeInvocationInterceptor (добавляется всегда)│
│                    ↓                                               │
│ sortAdvisors() (AnnotationAwareOrderComparator)                   │
│    → ExposeInvocationInterceptor (HIGHEST_PRECEDENCE)            │
│    → остальные (LOWEST_PRECEDENCE)                               │
│                    ↓                                               │
│ ProxyFactory                                                      │
│     ↓                                                             │
│ setTargetSource(new SingletonTargetSource(bean))                 │
│ addAdvisors(advisors)                                             │
│ setFrozen(true) — заморозка конфигурации                         │
│     ↓                                                             │
│ getProxy() → OrderServiceImpl$$SpringCGLIB$$0                    │
│                    ↓                                               │
│ Внутри сгенерированного прокси:                                   │
│     Callback[] (массив коллбеков)                                 │
│         → [0] DynamicAdvisedInterceptor                           │
│             → AdvisedSupport                                      │
│                 → Advisor[] (отсортированный, замороженный)      │
│                 → TargetSource (ссылка на оригинальный бин)      │
│     Методы-обёртки (переопределяют все публичные методы)          │
│         → каждый вызывает DynamicAdvisedInterceptor.intercept()  │
└────────────────────────────────────────────────────────────────────┘

Фаза выполнения (вызов метода)

┌────────────────────────────────────────────────────────────────────┐
│ orderService.processOrder()                                      │
│                    ↓                                               │
│ CGLIB-прокси (OrderServiceImpl$$SpringCGLIB$$0)                   │
│     → сгенерированный метод processOrder()                        │
│                    ↓                                               │
│ DynamicAdvisedInterceptor.intercept()                            │
│     → создаёт ReflectiveMethodInvocation                         │
│     → передаёт список interceptors (Advisor'ы преобразованы)      │
│                    ↓                                               │
│ ReflectiveMethodInvocation.proceed() (рекурсивно)                │
│     ↓                                                             │
│     Interceptor1: ExposeInvocationInterceptor                     │
│         → сохраняет вызов в ThreadLocal                           │
│         → вызывает proceed()                                      │
│     ↓                                                             │
│     Interceptor2: CacheInterceptor                                │
│         → проверяет кэш до вызова                                 │
│         → если нет в кэше — вызывает proceed()                   │
│         → сохраняет результат после вызова                        │
│     ↓                                                             │
│     Interceptor3: TransactionInterceptor                          │
│         → открывает транзакцию до вызова                          │
│         → вызывает proceed()                                      │
│         → коммитит или откатывает после вызова                    │
│     ↓                                                             │
│     Interceptor4: AspectJAroundAdvice (кастомный аспект)          │
│         → [Aspect] Старт замера времени...                        │
│         → вызывает proceed()                                      │
│         → [Aspect] Конец замера времени.                          │
│     ↓                                                             │
│     invokeJoinpoint() → OrderServiceImpl.processOrder()          │
│         → [Target] Выполняется реальная бизнес-логика!           │
│                    ↑                                               │
│     (обратный проход по цепочке: каждый перехватчик возвращает   │
│      управление и выполняет свои after-действия)                 │
└────────────────────────────────────────────────────────────────────┘

Теперь каждый фрагмент встал на своё место. Мы знаем, где рождаются Advisor'ы, как они сортируются, как попадают в прокси и как оживают при вызове метода.

Послесловие (если дочитали до этого места)

Я уверен, что после этого разбора, Spring AOP перестанет быть для Вас магией.

Её больше не будет в @Transactional как в особенной аннотации. Или @Async или в @Cacheble . Теперь вы видите обычные MethodInterceptor'ы в массиве, которые ждут своей очереди вызвать proceed().

Ведь это всё, что там есть. Список объектов, сортировка и рекурсивный обход. Никакой чёрной магии. Только байт-код, сгенерированный CGLIB, и хорошо продуманная архитектура вокруг паттерна Chain of Responsibility.

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


Материал подготовлен автором telegram-канала о изучении Java.


Использованные материалы (спасибо авторам):