Эта тема пугает названием, но идея за ней простая. Разберём с нуля: какую боль решает AOP, как Spring незаметно подменяет ваши объекты, и почему из-за этого @Transactional и @Cacheable иногда «не работают», хотя написаны правильно.
Вызов снаружи попадает в обёртку: 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 останется просто словом: приложение поднимется, аспект не применится, и никакой ошибки вы не увидите. Это первое, что стоит проверить, если совет не работает.
Пять видов 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-прокси стоит сбоку и реализует интерфейс, 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(прокси ещё нет), транзакционный советник по умолчанию стоит внутри ваших аспектов, а тестируют аспект только с контекстом.
Что почитать дальше
@Transactionalподробно — главный пример AOP со всеми тонкостями.- Жизненный цикл Spring-бина — в какой момент сборки бина появляется прокси.
- Scheduled, Async и виртуальные потоки —
@Asyncживёт на том же механизме и ломается так же. - Spring Security — проверка прав через AOP.