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

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

Зачем вообще нужен 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, а его прокси.

Из этого вытекает почти всё дальнейшее поведение AOP — запомните картинку с посредником.

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

Соберём аспект, который логирует все методы 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 AOP активен из коробки.

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

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

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

На практике, если задача сложнее простого лога, берут @Around — он умеет всё, что умеют остальные.

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

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

@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. Иначе аннотация молча не сработает.

Почему @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 и любым аспектом — это поведение прокси, а не баг конкретной аннотации.

Так выглядит вызов «снаружи» и «изнутри»:

diagram

Как лечить:

  • Разнести методы по разным бинам — пусть 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, оборачивает вызов целиком.
  • Прокси по умолчанию делает CGLIB (наследник класса); поэтому @Transactional и подобное — только на public не-final методах не-final классов.
  • Self-invocation: вызов через this идёт мимо прокси, поэтому @Transactional/@Cacheable на таком методе молчат — выносите метод в другой бин.
  • Spring AOP работает только с бинами Spring и только на вызовах методов; порядок нескольких аспектов задаёт @Order.
  • Часто свой аспект не нужен — встроенные @Transactional, @Cacheable, @PreAuthorize уже покрывают типовые задачи.

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

  • @Transactional подробно — главный пример AOP со всеми тонкостями.
  • DI/IoC, жизненный цикл бина, scopes — где в жизни бина создаётся прокси.
  • Spring Security — проверка прав через AOP.