Эта тема пугает названием, но идея за ней простая. Разберём с нуля: какую боль решает 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 и любым аспектом — это поведение прокси, а не баг конкретной аннотации.
Так выглядит вызов «снаружи» и «изнутри»:
Как лечить:
- Разнести методы по разным бинам — пусть
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.