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

Эта тема пугает названием, но идея за ней простая. Разберём с нуля: какую боль решает 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 у каждой есть слово. Что делать, «записать в журнал, кто вошёл», это advice (совет): код, который выполнится вокруг вашего метода. Где это в принципе может случиться, это join point (точка вызова); в Spring это всегда вызов метода, ни поля, ни конструкторы не перехватываются. Каких именно входов это касается, «главный вход, а не каждая дверь в городе», это pointcut (срез): правило выбора методов. А правило вместе с действием образуют аспект (aspect), саму должность «охранник».

Если коротко: аспект = «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 останется просто словом: приложение поднимется, аспект не применится, и никакой ошибки вы не увидите. Это первое, что стоит проверить, если совет не работает.

@Around обёртка началась @Before до вызова тело метода ваш код @AfterReturning или @AfterThrowing @After как finally, всегда @Around обёртка закрылась

Пять видов advice по порядку срабатывания: @AfterReturning и @AfterThrowing это две ветки одного исхода, @After отрабатывает в обоих случаях, а @Around охватывает всю цепочку целиком.

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

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

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

Чтобы увидеть эти моменты своими глазами, 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.

Одна деталь в срезе выглядит опечаткой, но ею не является. @annotation(audited) написано с маленькой буквы — это не имя типа, а имя параметра метода record(...). Такая запись делает две вещи сразу: выбирает методы с @Audited и передаёт аспекту саму аннотацию, из которой берётся audited.action(). Если содержимое аннотации не нужно, параметр не заводят, а в срезе пишут имя типа: @Around("@annotation(Audited)"). Обе формы рабочие, и путать их не стоит: во второй параметра в сигнатуре быть не должно.

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

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

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

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

JDK Proxy вызов прокси по интерфейсу ваш объект CGLIB вызов наследник класса super.метод

Один и тот же вызов двумя путями: JDK-прокси стоит сбоку и реализует интерфейс, CGLIB встаёт сверху наследником и зовёт super, поэтому final-метод и final-класс ему не достаются.

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

  • 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 текущий прокси прямо в коде. Работает он только при @EnableAspectJAutoProxy(exposeProxy = true), который кладёт прокси в ThreadLocal на время вызова:
@Configuration
@EnableAspectJAutoProxy(exposeProxy = true)
class AopConfig { }

@Service
public class OrderService {
    public void batch() {
        ((OrderService) AopContext.currentProxy()).processOne();   // теперь через прокси
    }

    @Transactional
    public void processOne() { }
}

Выглядит это не лучше предыдущего варианта и привязывает код к Spring, зато не требует лишнего бина. Оба обхода — про случай, когда разнести методы нельзя; если можно, разносят.

Есть и третье место, где аспект молчит по той же причине: вызовы из конструктора и из @PostConstruct. В этот момент бин ещё не обёрнут — прокси создаётся позже, на следующем шаге сборки, — поэтому @Transactional или @Cacheable на методе, вызванном оттуда, не сработают, даже если вызов идёт через другой бин своего же класса. Подробности порядка сборки — в статье про жизненный цикл бина; практический вывод тот же, что там: работу, которой нужны аннотации, переносят в ApplicationRunner или в слушателя ApplicationReadyEvent.

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

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

  • Только бины Spring. Аспект не сработает на объекте, созданном через new вашими руками. Spring оборачивает прокси только те объекты, которыми управляет сам.
  • Это ограничения прокси, а не AOP вообще. Существует полноценный AspectJ: он вплетает советы прямо в байт-код — либо при сборке (compile-time weaving, плагин к Maven или Gradle), либо при загрузке класса (load-time weaving, агент JVM и @EnableLoadTimeWeaving). Тогда исчезают разом все ограничения этого раздела: перехватываются вызовы через this, private- и final-методы, конструкторы, обращения к полям и даже объекты, созданные через new. Платят за это отдельным шагом сборки или агентом при запуске, более сложной отладкой (в коде одно, в байт-коде другое) и тем, что вся команда должна понимать, что вплетено. Поэтому в обычных сервисах остаются на прокси-варианте Spring AOP и живут с self-invocation, а AspectJ берут точечно — например, когда сквозная логика нужна в доменных объектах, которые контейнером не управляются.
  • Только вызовы методов. Поля, конструкторы и статические методы Spring AOP не перехватывает. Перехватывается именно момент вызова обычного метода.
  • Порядок нескольких аспектов. Если на один метод навешано несколько аспектов (например, аудит и измерение времени), их порядок задаётся аннотацией @Order — меньшее число означает «снаружи, ближе к вызывающему».
@Aspect @Component @Order(1)
public class AuditAspect { ... }    // выполнится первым (снаружи)

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

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

Один порядок задан за вас, и его стоит знать. Советник, который включает @Transactional, по умолчанию стоит с наименьшим приоритетом (Ordered.LOWEST_PRECEDENCE), то есть внутри всех ваших аспектов без явного @Order. Практическое следствие для аспекта аудита: он оборачивает транзакцию снаружи, значит его @After выполняется уже после коммита и запись аудита пойдёт своей транзакцией, а не общей. Нужно наоборот — писать аудит в ту же транзакцию, что и бизнес-операцию, — аспекту задают @Order больше, чем у транзакционного советника, либо явно двигают сам советник через @EnableTransactionManagement(order = ...). Пока порядок не задан, «увидит ли аспект закоммиченные данные» — вопрос без ответа, и это не тот вопрос, на который стоит полагаться случайно.

Цена прокси

Аспект выглядит бесплатным: код метода не меняется. Платить всё равно приходится, и в трёх местах.

Стек-трейс. Между вызывающим кодом и вашим методом появляются кадры прокси и советников: OrderService$$SpringCGLIB$$0.create, CglibAopProxy$DynamicAdvisedInterceptor.intercept, ReflectiveMethodInvocation.proceed и так далее — обычно полтора десятка строк на каждый слой. В логе ошибки их приходится пролистывать, чтобы дойти до своей строки, а при нескольких аспектах на один метод стек вырастает заметно.

Отладка. Шаг «войти в метод» в отладчике приводит не в ваш код, а внутрь прокси, и до тела метода нужно нажать «дальше» несколько раз. По той же причине точка останова в аспекте срабатывает на каждом вызове подходящего метода, а не только на интересном.

Время выполнения. Один вызов через прокси это несколько лишних вызовов и переход по цепочке советников — десятки-сотни наносекунд. На фоне запроса в базу это ничто, и для сервисного слоя цена нулевая. Заметной она становится там, где метод сам по себе дешёвый и вызывается миллионы раз: аспект на геттере или на методе внутри цикла обработки — плохая идея, и это единственный случай, когда стоит смотреть на производительность AOP.

Поэтому аспекты вешают на крупнозернистые границы (обработчик операции, вызов внешнего сервиса, метод контроллера), а не на мелкие вспомогательные методы.

Как тестировать аспект

Из ограничения «только бины Spring» следует неприятное: в юнит-тесте с new OrderService(mock) аспекта нет вовсе. Тест проходит, а поведение, которое вы написали, не проверено ни разу. Проверять приходится с контекстом, и достаточно самого маленького:

@SpringBootTest(classes = {AuditAspect.class, OrderService.class, AopTestConfig.class})
@EnableAspectJAutoProxy
class AuditAspectTest {

    @Autowired OrderService service;
    @MockitoBean AuditService audit;

    @Test
    void writesAuditOnSuccess() {
        service.create(new CreateOrderCommand("A-1"));
        verify(audit).log("create-order", "ok");
    }
}

Три вещи, которые такой тест ловит и которые не ловит юнит-тест: что срез вообще совпал с вашим методом (самая частая ошибка — опечатка в execution(...)), что порядок нескольких аспектов тот, что задумывался, и что аспект не съел исключение. Проверить, обёрнут ли бин вообще, можно и прямо: AopUtils.isAopProxy(service) возвращает true только для прокси.

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

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

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

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

Дополнительно: при первом чтении можно пропустить

Глубже: кэш как прокси: @EnableCaching и @Cacheableрасширенное

@Cacheable несколько раз мелькал выше как пример аспекта, и это ровно он: @EnableCaching на конфигурации включает прокси, который перед вызовом метода ищет результат в кэше по ключу и, если нашёл, метод не вызывает.

@Service
public class RateService {
    @Cacheable(cacheNames = "rates", key = "#currency")
    public BigDecimal rate(String currency) { return client.fetchRate(currency); }   // дорогой вызов

    @CachePut(cacheNames = "rates", key = "#currency")
    public BigDecimal refresh(String currency) { return client.fetchRate(currency); } // обновить запись

    @CacheEvict(cacheNames = "rates", allEntries = true)
    public void reset() { }                                                          // выбросить всё
}

Ключ по умолчанию собирается из всех аргументов метода; key через SpEL делает его явным, и это стоит делать всегда. Хранилище по умолчанию, ConcurrentMapCacheManager, не умеет ни срока жизни, ни ограничения размера, оно для тестов. В приложении подключают Caffeine (spring.cache.caffeine.spec=maximumSize=10000,expireAfterWrite=10m) или Redis, когда кэш должен быть общим для нескольких экземпляров.

Две ловушки те же, что у любого прокси. Вызов this.rate(...) изнутри того же класса идёт мимо прокси, и кэш не работает, симптом тот же, что у @Transactional через this. И кэш это состояние в памяти: возвращённый из него объект общий для всех, кто его получил, менять его нельзя. Что закэшировать и на сколько, решает не аннотация, а ответ на вопрос «что будет, если отдать значение десятиминутной давности».

Коротко

  • 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 уже покрывают типовые задачи.
  • @Cacheable это тот же прокси: явный key, Caffeine или Redis вместо карты по умолчанию, вызов через this кэш обходит.
  • Ограничения перечислены для прокси, а не для AOP: AspectJ со вплетением при сборке или загрузке снимает их все, ценой шага сборки и агента. Self-invocation обходят AopContext.currentProxy(), аспект не работает из конструктора и @PostConstruct (прокси ещё нет), транзакционный советник по умолчанию стоит внутри ваших аспектов, а тестируют аспект только с контекстом.

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