← назад к разделу

Эта тема пугает названием, но идея за ней простая. Разберём с нуля: какую боль решает AOP, как Spring незаметно подменяет ваши объекты, и почему из-за этого @Transactional и @Cacheable иногда «не работают», хотя написаны правильно.

контейнер отдаёт не ваш объект, а обёртку вокруг него другой бин прокси OrderService advice: BEGIN, лог, права настоящий OrderService batch() this.processOne() processOne() @Transactional вызов снаружи снаружи → через прокси → транзакция открыта вызов изнутри — мимо прокси advice не сработал: прокси остался в стороне изнутри → мимо прокси → транзакции нет

Вызов снаружи попадает в обёртку: advice открывает транзакцию и только потом передаёт управление настоящему методу. Вызов this.processOne() происходит уже внутри объекта — прокси в нём не участвует, и @Transactional молчит.

Зачем вообще нужен AOP

Представьте, что вы решили логировать каждый вызов важных методов: имя метода, аргументы, сколько он выполнялся. Раньше это делали так — в каждый метод дописывали один и тот же кусок:

public Order create(CreateOrderCommand cmd) {
    log.info("→ create, args={}", cmd);   // одно и то же
    long start = System.currentTimeMillis();
    // ... настоящая работа ...
    log.info("← create, took={}ms", System.currentTimeMillis() - start);
    return order;
}

Боль очевидна: этот код повторяется в десятках методов, мешается с бизнес-логикой, и если захочется поменять формат лога — придётся править везде. То же самое с измерением метрик, проверкой прав, обёрткой в транзакцию.

Такие вещи — логирование, метрики, безопасность, транзакции — называют сквозной логикой (по-английски cross-cutting concerns): они «прошивают» весь код насквозь, нужны во многих местах, но к самой сути методов отношения не имеют.

AOP (Aspect-Oriented Programming, аспектно-ориентированное программирование) — это способ вынести такую сквозную логику в одно место и применять её к множеству методов автоматически, не трогая сами методы. Метод остаётся чистым, а «обвязка» живёт отдельно.

Главное, что стоит понять сразу: вы уже пользуетесь AOP, даже если никогда не писали аспекты руками. @Transactional, @Async, @Cacheable, @Scheduled, проверки прав в Spring Security — всё это работает именно через AOP. Поэтому понимать механику полезно: без неё непонятно, почему эти аннотации иногда молчат.

Четыре слова, которые надо знать

В AOP всего несколько терминов. Объясним на аналогии с охранником в здании.

  • Аспект (aspect) — сам модуль со сквозной логикой. Это как должность «охранник»: набор правил, которые применяются в разных местах.
  • Advice (совет, действие) — что именно делать. «Записать в журнал, кто вошёл». Это код, который выполнится вокруг вашего метода.
  • Join point (точка вызова) — место, где advice в принципе может сработать. В Spring это всегда вызов метода — ни поля, ни конструкторы не перехватываются.
  • Pointcut (срез) — правило выбора: к каким именно методам применять. «Проверять всех, кто входит через главный вход», а не вообще каждого человека в городе.

Если коротко: аспект = «pointcut (где) + advice (что делать)».

Как Spring это делает: прокси

Вот ключевая идея, без которой ничего не понятно. Когда вы помечаете метод чем-то вроде @Transactional, Spring не меняет ваш класс. Вместо вашего объекта он подсовывает другой объект-обёртку — прокси (proxy).

Аналогия: вы звоните в компанию, а трубку берёт секретарь. Секретарь записывает звонок в журнал, проверяет, что вам можно соединиться, и только потом передаёт звонок нужному сотруднику. Снаружи кажется, что вы говорите напрямую с сотрудником — но между вами всегда есть посредник.

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

Простой пример: аспект-логгер

Соберём аспект, который логирует все методы handle(...) в пакете usecase:

@Aspect
@Component
@Slf4j
public class MethodLoggingAspect {

    @Pointcut("execution(* com.example.app.usecase..*.handle(..))")
    public void useCaseMethods() {}

    @Around("useCaseMethods()")
    public Object logAround(ProceedingJoinPoint pjp) throws Throwable {
        String name = pjp.getSignature().toShortString();
        long start = System.currentTimeMillis();
        try {
            Object result = pjp.proceed();   // вызываем настоящий метод
            log.info("{} took {}ms", name, System.currentTimeMillis() - start);
            return result;
        } catch (Throwable t) {
            log.error("{} threw {}", name, t.getClass().getSimpleName());
            throw t;
        }
    }
}

Разберём по частям:

  • @Aspect — говорит Spring «это аспект»; @Component — чтобы Spring нашёл его при старте.
  • @Pointcut(...) — описывает, к каким методам применять. Выражение execution(...) читается так: любой метод с именем handle, любым типом возврата и любыми аргументами, в пакете usecase и вложенных.
  • @Around — это advice: код выполнится вокруг метода. pjp.proceed() — момент, когда управление уходит в ваш настоящий метод.

Сам Handler про этот аспект ничего не знает — он чистый. Включается всё это автоматически — но только если в проекте есть зависимость spring-boot-starter-aop. Без неё аннотация @Aspect останется просто словом: приложение поднимется, аспект не применится, и никакой ошибки вы не увидите. Это первое, что стоит проверить, если совет не работает.

Виды advice — что можно сделать вокруг метода

Advice бывает пяти видов, отличаются моментом срабатывания:

  • @Before — до метода. Например, проверить права. Изменить аргументы или результат нельзя.
  • @AfterReturning — после успешного завершения. Виден результат, который вернул метод.
  • @AfterThrowing — если метод бросил ошибку. Можно её залогировать.
  • @After — после завершения в любом случае, успех или ошибка (как finally).
  • @Around — оборачивает вызов целиком: можно подменить аргументы, поймать ошибку, заменить результат, измерить время. Самый мощный и самый частый для нетривиальных задач.

Чтобы увидеть эти моменты своими глазами, Spring не нужен: обёртку по интерфейсу умеет делать сама Java — java.lang.reflect.Proxy. Код обработчика внутри и есть тот самый @Around, а остальные виды advice — просто места в нём:

живой пример

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Proxy;

public class AdviceDemo {

    interface Orders { String pay(String id); }

    static class RealOrders implements Orders {
        public String pay(String id) {
            if (id.startsWith("BAD")) throw new IllegalStateException("нет денег");
            return "оплачен " + id;
        }
    }

    static Orders advised(Orders target) {
        return (Orders) Proxy.newProxyInstance(
                Orders.class.getClassLoader(), new Class<?>[]{Orders.class},
                (proxy, method, args) -> {
                    System.out.println("  @Before          " + method.getName() + " " + args[0]);
                    try {
                        Object result = method.invoke(target, args);
                        System.out.println("  @AfterReturning  " + result);
                        return result;
                    } catch (InvocationTargetException e) {
                        System.out.println("  @AfterThrowing   " + e.getCause().getMessage());
                        throw e.getCause();
                    } finally {
                        System.out.println("  @After           отработал в любом случае");
                    }
                });
    }

    public static void main(String[] args) {
        Orders orders = advised(new RealOrders());
        System.out.println("успешный вызов:");
        orders.pay("A-1");
        System.out.println("вызов с ошибкой:");
        try {
            orders.pay("BAD-2");
        } catch (RuntimeException e) {
            System.out.println("  наружу вышло: " + e.getMessage());
        }
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Неделя бесплатно →

В выводе видно главное: @AfterReturning и @AfterThrowing — это две ветки одного try, а @After стоит в finally и печатается в обоих случаях. Исключение при этом не пропадает: обёртка пробрасывает его дальше, наружу.

Своя аннотация + аспект — частый приём

Удобный паттерн: завести собственную аннотацию-метку и один аспект, который реагирует на неё. Тогда подключить поведение к методу — это просто повесить аннотацию.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Audited {
    String action();
}

@Aspect
@Component
@RequiredArgsConstructor
public class AuditAspect {

    private final AuditService audit;

    @Around("@annotation(audited)")            // срабатывает на методах с @Audited
    public Object record(ProceedingJoinPoint pjp, Audited audited) throws Throwable {
        try {
            Object result = pjp.proceed();
            audit.log(audited.action(), "ok");
            return result;
        } catch (Throwable t) {
            audit.log(audited.action(), "failed");
            throw t;
        }
    }
}

@Service
public class OrderService {
    @Audited(action = "create-order")
    public OrderId create(CreateOrderCommand cmd) { ... }
}

Теперь любой метод, помеченный @Audited, автоматически попадёт в журнал. Это ровно тот же механизм, по которому работает встроенный @Transactional.

Два вида прокси: JDK Proxy и CGLIB

Прокси (тот самый «секретарь») Spring создаёт двумя способами. Знать различие важно, потому что из него растут ограничения.

  • JDK Dynamic Proxy — работает, только если ваш класс реализует интерфейс. Прокси прикидывается этим интерфейсом и держит внутри настоящий объект. Ровно это делает пример выше.
  • CGLIB — создаёт на лету класс-наследник вашего класса и переопределяет в нём методы. Интерфейс при этом не нужен.

Начиная со Spring Boot 2.0, по умолчанию используется CGLIB для всего. Так удобнее: не нужно гадать, есть интерфейс или нет.

Но раз прокси — это наследник, который переопределяет методы, появляются ограничения. Нельзя переопределить то, что нельзя переопределить:

  • final-методы не проксируются — наследник не может их переопределить.
  • final-классы не проксируются совсем — от них нельзя унаследоваться.
  • private-методы прокси не видит — они не наследуются.

Отсюда простое правило: @Transactional, @Async, @Cacheable вешайте на public методы, не помеченные final, в классах без final. Со Spring 6 прокси-наследник перехватывает ещё и protected, и пакетно-видимые методы, но помнить исключения сложнее, чем держать такие методы public. Иначе аннотация молча не сработает.

Почему @Transactional иногда не срабатывает (self-invocation)

Это самая частая и самая коварная ловушка AOP. Вернёмся к аналогии с секретарём: перехватывает звонки только тот, кто звонит снаружи. Если сотрудник внутри здания звонит коллеге по внутренней линии — секретарь об этом не знает и ничего не записывает.

С прокси то же самое. Прокси перехватывает только вызовы, которые приходят извне объекта. А вызов метода через this идёт мимо прокси — напрямую внутри настоящего объекта:

@Service
public class OrderService {

    public void batch() {
        this.processOne();   // вызов через this — мимо прокси!
    }

    @Transactional
    public void processOne() { ... }   // транзакция НЕ откроется
}

Когда batch() вызывает this.processOne(), обёртка-прокси в этот момент уже не участвует — мы внутри настоящего объекта. Поэтому @Transactional на processOne() просто игнорируется. То же случится с @Cacheable, @Async и любым аспектом — это поведение прокси, а не баг конкретной аннотации. Ровно эти две половины и показывает картинка в начале статьи.

Как лечить:

  • Разнести методы по разным бинам — пусть batch() вызывает processOne() у другого сервиса. Тогда вызов снова идёт снаружи, через прокси. Это самый честный путь.
  • Если очень нужно оставить в одном классе — внедрить ссылку на самого себя через ObjectProvider<OrderService> и вызывать provider.getObject().processOne(). Это уже вызов через прокси, но выглядит костыльно, поэтому чаще выбирают первый вариант.

Остальные ограничения Spring AOP

Кроме self-invocation, держите в голове ещё три границы:

  • Только бины Spring. Аспект не сработает на объекте, созданном через new вашими руками. Spring оборачивает прокси только те объекты, которыми управляет сам.
  • Только вызовы методов. Поля, конструкторы и статические методы Spring AOP не перехватывает. Перехватывается именно момент вызова обычного метода.
  • Порядок нескольких аспектов. Если на один метод навешано несколько аспектов (например, аудит и измерение времени), их порядок задаётся аннотацией @Order — меньшее число означает «снаружи, ближе к вызывающему».
@Aspect @Component @Order(1)
public class AuditAspect { ... }    // выполнится первым (снаружи)

@Aspect @Component @Order(2)
public class TimedAspect { ... }    // внутри аудита

Если порядок важен — задавайте @Order явно, не полагайтесь на случай.

Когда писать свой аспект, а когда нет

Свои аспекты руками пишут реже, чем кажется. Чаще достаточно встроенного:

  • транзакция — @Transactional;
  • кэш — @Cacheable;
  • проверка прав на методе — @PreAuthorize (Spring Security сам использует AOP);
  • метрики времени — обычно проще взять готовые средства Micrometer, чем писать аспект.

Свой аспект — хороший выбор, когда нужно единое поведение для многих методов, помеченных вашей меткой (как пример с @Audited). Если же поведение нужно ровно в одном-двух местах — проще написать его прямо в коде, без аспекта: меньше магии, легче читать.

Коротко

  • AOP выносит сквозную логику (логи, метрики, права, транзакции) в одно место и применяет к множеству методов, не трогая сами методы.
  • Термины: аспект = pointcut (где применять) + advice (что делать); точка применения в Spring — всегда вызов метода.
  • Spring работает через прокси — подменяет ваш объект обёрткой-«секретарём», которая перехватывает вызовы.
  • Видов advice пять; самый мощный — @Around, оборачивает вызов целиком: @AfterReturning и @AfterThrowing — две ветки try, @After — его finally.
  • Прокси по умолчанию делает CGLIB (наследник класса); поэтому @Transactional и подобное не работают на private-методах, а также в final-классах и на final-методах.
  • Self-invocation: вызов через this идёт мимо прокси, поэтому @Transactional/@Cacheable на таком методе молчат — выносите метод в другой бин.
  • Spring AOP работает только с бинами Spring и только на вызовах методов; порядок нескольких аспектов задаёт @Order.
  • Часто свой аспект не нужен — встроенные @Transactional, @Cacheable, @PreAuthorize уже покрывают типовые задачи.

Что почитать дальше