1. Что ломается: аспект не сработал
Self-invocation обычно проявляется в самый неприятный момент: вы только что победили AOP, всё красиво измеряется, и вы ожидаете увидеть два замера времени — для внешнего метода и для внутреннего шага. Но лог печатает только один. Мозг автоматически делает вывод “AOP сломалось”, хотя на самом деле мы неверно представили себе, что именно здесь перехватывается.
Обвязка у нас остаётся той же: один ServiceTimingAspect, @Around вокруг сервисного слоя и вызов бина через контейнер. Ломается здесь не aspect и не правило execution(...), а маршрут вызова: часть вызовов просто не доходит до proxy.
Представим типичный кусок сервисного слоя. Мы добавили timing-aspect, который измеряет все методы в пакете com.example.contextflow.application.service..*. В сервисе есть метод placeOrder(), который вызывает calculateTotal() внутри того же класса.
import org.springframework.stereotype.Service;
@Service
public class OrderPlacementService {
public void placeOrder() {
// Важно: это внутренний вызов "внутри объекта" (self-invocation),
// он НЕ пройдёт через Spring-прокси, даже если pointcut подходит.
calculateTotal();
// Это просто бизнес-шаг (или заглушка бизнес-шага) в сценарии размещения заказа
System.out.println("placing order"); // placing order
}
public void calculateTotal() {
// Внутренний шаг расчёта, который новичок ожидает увидеть в логах аспекта
System.out.println("pricing order"); // pricing order
}
}
Если смотреть на это глазами “обычного Java-разработчика”, кажется логичным следующее: pointcut совпадает и с placeOrder(), и с calculateTotal(), значит advice должен сработать дважды. Особенно если вы уже в лекции 4 убедились, что execution(* ..OrderPlacementService.*(..)) точно выбирает оба метода.
Но в реальности вы увидите примерно такой эффект: замер времени есть для placeOrder(), а для calculateTotal() — тишина. И это не потому что Spring “передумал”, а потому что второй вызов не проходит через прокси. То есть для Spring AOP он как бы “произошёл в другом мире”, вне зоны, где совет (advice) может вмешаться.
2. Механика вызовов через proxy
Чтобы self-invocation перестал быть мистикой, нужно на минуту переключиться с “методы совпадают” на “как именно проходит вызов в рантайме”. В proxy-based Spring AOP есть простое правило, от которого пляшет всё остальное: перехватываются вызовы, которые заходят в бин через прокси-объект. Если вызов не зашёл в прокси — pointcut хоть трижды совпадай, advice не получит шанс сработать.
Самый наглядный способ — нарисовать цепочку вызовов. Когда другой бин (например, ScenarioRunner) вызывает orderPlacementService.placeOrder(), он держит в руках ссылку на Spring bean. А Spring bean, когда включён AOP, часто является прокси, а не реальным OrderPlacementService.
sequenceDiagram
participant R as "ScenarioRunner (caller)"
participant P as "Proxy (OrderPlacementService bean)"
participant A as "ServiceTimingAspect (@Around)"
participant T as "Target (OrderPlacementService object)"
R->>P: placeOrder()
P->>A: advice before
A->>T: invoke target.placeOrder()
T->>T: "calculateTotal() (self-invocation)"
T-->>A: return from placeOrder
A-->>P: advice after
P-->>R: return
Ключевая строчка здесь — T->T: calculateTotal() (self-invocation). Это и есть self-invocation: метод одного и того же объекта вызывает другой метод на том же объекте. У Spring AOP нет отдельного “второго прокси” внутри target, который перехватит этот внутренний вызов. Внутренний вызов — это обычный Java-вызов, который обходит “контрольный пункт” (proxy).
Чтобы закрепить, давайте сравним два типа вызовов в виде таблицы. Она грубая, но очень помогает новичку:
| Ситуация вызова | Через proxy? | Advice может сработать? | Пример |
|---|---|---|---|
| Один бин вызывает метод другого бина | Да | Да | runner -> orderPlacementService.placeOrder() |
| Метод внутри класса вызывает другой метод через this | Нет | Нет | placeOrder() -> calculateTotal() |
В этот момент обычно возникает логичный вопрос: “Но ведь у нас же есть pointcut, он же правило выбора методов”. И вот тут важна тонкость: pointcut в Spring AOP выбирает join point, а join point в proxy-based модели — это не “любой метод”, а “выполнение метода, в которое мы можем вклиниться через прокси”. Внутренний вызов в обход прокси просто не становится таким join point.
3. Мини-демо на ContextFlow
Сейчас сделаем короткое демо, чтобы вы не просто поверили на слово, а “увидели” self-invocation глазами терминала. Мы возьмём упрощённый timing-aspect и сервис с внутренним вызовом. Важно: вызов сервиса должен идти из контекста, иначе мы вообще не получим прокси и AOP будет ни при чём.
Сейчас полезно держать в голове ту же рабочую связку: timing-aspect уже подключён и service-layer pointcut совпадает. Меняется только способ, которым один метод добирается до другого.
Сначала минимальный around-advice, который печатает имя метода и время. Здесь не важны наноточные числа; нам важно количество срабатываний.
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
@Around("execution(* com.example.contextflow.application.service..*.*(..))")
public Object measure(ProceedingJoinPoint pjp) throws Throwable {
// Старт измерения ДО вызова целевого метода
long start = System.nanoTime();
try {
// Передаём управление дальше по цепочке (в target-метод)
return pjp.proceed();
} finally {
// Логируем в finally, чтобы увидеть замер даже при исключении
System.out.println(pjp.getSignature().getName()
+ " took " + (System.nanoTime() - start) + " ns");
}
}
Теперь сервис (тот же, что выше), где placeOrder() вызывает calculateTotal() внутри класса.
import org.springframework.stereotype.Service;
@Service
public class OrderPlacementService {
public void placeOrder() {
// Ожидаемо "неочевидное" место: этот вызов не идёт через прокси
calculateTotal();
// Просто индикатор выполнения шага
System.out.println("placing order"); // placing order
}
public void calculateTotal() {
// Просто индикатор выполнения шага
System.out.println("pricing order"); // pricing order
}
}
И наконец — запуск через AnnotationConfigApplicationContext. Если вы попытаетесь создать new OrderPlacementService() и вызвать метод, AOP будет обиженно молчать: прокси создаёт контейнер, а не оператор new.
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
try (var ctx = new AnnotationConfigApplicationContext(AppConfig.class)) {
// Берём бин из контекста, чтобы получить именно Spring-managed объект (обычно прокси)
var service = ctx.getBean(OrderPlacementService.class);
// Этот вызов зайдёт в бин "снаружи", через прокси
service.placeOrder();
}
Ожидание новичка обычно такое: “Я увижу timing для placeOrder и для calculateTotal”. А реальность будет ближе к этому:
pricing order
placing order
placeOrder took 123456 ns
Обратите внимание на “дырку”: метода calculateTotal в timing-логе нет, хотя он реально выполнился (мы видим pricing order). Это и есть self-invocation bypass: внутренний вызов прошёл напрямую внутри target-объекта, не заходя в прокси, а значит advice туда не попал.
4. Pointcut не лечит self-invocation
После первого столкновения с self-invocation появляется желание “дожать” ситуацию техникой: переписать pointcut, сделать выражение точнее, добавить ещё один execution, может быть, даже обмазаться ..* так, чтобы “точно сработало”. И это абсолютно естественная реакция: мы привыкли, что если фильтр не срабатывает — нужно поправить фильтр. Но тут проблема не в фильтре, а в том, что событие не дошло до места, где фильтр применяется.
Pointcut — это правило выбора join points. Но join points в Spring AOP возникают только на границе прокси. Если вызов прошёл мимо прокси, у Spring просто нет “точки перехвата”, где он мог бы спросить: “О, а совпадает ли это с pointcut?”. Это как поставить самого внимательного охранника на вход в офис и потом удивляться, что люди ходят по офису из кабинета в кабинет без проверки документов. Охранник не глупый — он просто стоит не там, где вы пытаетесь контролировать перемещение.
Давайте попробуем мысленно “усилить” pointcut до абсурда. Например, сделать его суперточным и покрыть ровно внутренний метод:
@Around("execution(* com.example.contextflow.application.service.OrderPlacementService.calculateTotal(..))")
public Object onlyCalculateTotal(ProceedingJoinPoint pjp) throws Throwable {
// Даже если мы выбрали метод максимально точно, это не создаст "перехват"
// для внутреннего вызова, который обходит прокси.
return pjp.proceed();
}
Логика кажется железной: мы явно выбрали calculateTotal(). Но результат не изменится, потому что вызов calculateTotal() всё равно выполняется внутри target, напрямую. Spring AOP применяет advice, когда прокси перехватывает вызов. А в self-invocation прокси не перехватывает ничего: вызов уже “внутри”.
Этот момент стоит запомнить не только для нашего timing-aspect. То же ограничение всплывает и в других proxy-based механизмах Spring, например с @Transactional: разработчик ждёт дополнительное поведение на внутреннем методе и удивляется, почему его нет. Причина одна и та же — если вызов не прошёл через proxy, никакая внешняя обвязка не включается.
5. Выносим логику в отдельный bean
Самый правильный первый ответ на self-invocation — не хитрость, а дизайн. И звучит он скучно, как “ешь брокколи”: вынеси внутреннюю ответственность в отдельный bean и вызывай его через dependency. Зато это решение одновременно лечит AOP-проблему и делает код более читаемым, потому что в сервисе остаётся оркестрация сценария, а детали уходят в специализированного “коллаборатора”.
Начнём с “плохого” варианта (всё в одном сервисе). Он может появиться совершенно естественно: “да что там считать цену, два println — сейчас набросаю”.
import org.springframework.stereotype.Service;
@Service
public class OrderPlacementService {
public void placeOrder() {
calculateTotal();
System.out.println("placing order"); // placing order
}
public void calculateTotal() {
System.out.println("pricing order"); // pricing order
}
}
Теперь делаем рефакторинг в духе нашего проекта ContextFlow, где pricing — это отдельная ответственность. Создаём OrderPricingService и переносим туда расчёт.
import org.springframework.stereotype.Service;
@Service
public class OrderPricingService {
public void calculateTotal() {
// Отдельная ответственность: расчёт стоимости (в примере — просто заглушка)
System.out.println("pricing order"); // pricing order
}
}
А в OrderPlacementService оставляем сценарий и внедряем pricing-сервис через конструктор (наш любимый constructor injection).
import org.springframework.stereotype.Service;
@Service
public class OrderPlacementService {
private final OrderPricingService pricingService;
public OrderPlacementService(OrderPricingService pricingService) {
// Конструкторная инъекция: Spring передаст сюда другой бин (обычно тоже проксированный)
this.pricingService = pricingService;
}
public void placeOrder() {
// Теперь это вызов ДРУГОГО бина: он пойдёт через Spring-managed ссылку и прокси
pricingService.calculateTotal();
// Оркестрация сценария остаётся здесь
System.out.println("placing order"); // placing order
}
}
Что мы выиграли именно с точки зрения AOP? Теперь вызов pricingService.calculateTotal() — это вызов другого bean-а, а значит он проходит через Spring-managed ссылку. Если наш pointcut покрывает сервисный слой (например, весь пакет application.service), то оба бина будут проксированы, и вызов calculateTotal() тоже попадёт под advice.
Запускаем тот же runner — и теперь мы обычно увидим два замера. Вложенные вызовы дадут вложенные логи, часто в таком порядке: сначала замерится внутренний метод, потом внешний.
pricing order
calculateTotal took 34567 ns
placing order
placeOrder took 123456 ns
Красота здесь в том, что мы вообще не “лечили AOP”. Мы лечили архитектуру: OrderPlacementService теперь действительно “размещает заказ”, а pricing — это отдельный шаг, который можно тестировать, развивать, заменять или переиспользовать. AOP просто перестал “спотыкаться”, потому что вызов снова проходит через границу между бинами.
Обходные трюки вместо рефакторинга
После рефакторинга в отдельный бин обычно наступает облегчение, но где-то внутри всё равно живёт маленький демон: “а можно ли всё-таки заставить self-invocation работать без рефакторинга?”. Технически да, существуют обходные пути. Практически — почти всегда лучше сделать так, чтобы они вам не понадобились, особенно в учебном проекте и на junior-уровне.
Один класс трюков связан с тем, чтобы “достать прокси изнутри” и вызывать метод через него. В Spring есть механизмы вроде “expose proxy” и доступ к текущему прокси, но это сразу приносит в код жёсткую зависимость от AOP-инфраструктуры. Это выглядит эффектно, но плохо пахнет: бизнес-сервис начинает знать, что он на самом деле не совсем он, а вокруг него есть прокси. Такой код хуже тестируется, хуже читается и ломается от малейшей перестройки конфигурации.
Другой класс трюков — self-injection, когда сервис внедряет сам себя (часто ещё и с @Lazy) и вызывает метод через “себя-под-прокси”. Мы уже обсуждали похожие штуки раньше, когда говорили про wiring и почему self injection полезен только для понимания механики, но вреден как повседневная привычка. Для AOP это выглядит как “хак, который как будто работает”, но ценой становится ещё более запутанная архитектура и риск циклов, странных инициализаций и очень неочевидного поведения.
Поэтому здоровая стратегия для ContextFlow и для реальной практики такая: держите AOP как внешний слой. Если вы поймали self-invocation, воспринимайте это не как “Spring вредничает”, а как подсказку: возможно, у вас внутри класса смешались ответственности, и их пора разнести. В большинстве случаев именно это и делает код лучше, даже если вы завтра вообще выключите AOP.
6. Типичные ошибки при self-invocation
Self-invocation неприятен тем, что он создаёт ощущение “рандомной магии”: вчера advice срабатывал, сегодня — нет, хотя код почти такой же. На самом деле это очень детерминированная история, просто новичок обычно смотрит на неё как на “совпадение pointcut”, а не как на “путь вызова через прокси”. Ниже — ошибки, которые встречаются чаще всего.
Ошибка №1: ожидать, что this.someMethod() пройдёт через advice.
Внутренний вызов внутри одного и того же класса — это прямой Java-вызов внутри target-объекта. Он не заходит в прокси, поэтому advice не получает шанс сработать. Если вам нужно перехватывать внутренний шаг, вынесите его в отдельный bean и вызывайте через зависимость.
Ошибка №2: пытаться починить self-invocation “более хитрым pointcut”.
Pointcut выбирает join points, но join point появляется только там, где прокси перехватывает вызов. Если вызов мимо прокси, никакое выражение execution(...) не поможет, потому что “места перехвата” просто нет. Это не задача фильтра, это задача маршрута вызова.
Ошибка №3: прятать шаг во private метод и всё равно ждать AOP.
В Spring AOP на нашем уровне целевая точка — это выполнение методов бина. private метод внутри класса вообще не является хорошим кандидатом на AOP-границу: он и так “внутренний”, и его вызов почти всегда будет self-invocation. Если шаг важен как отдельная операция, сделайте его методом отдельного бина.
Ошибка №4: создавать сервис руками через new и удивляться, что аспект “не работает”.
Прокси создаёт контейнер. Если вы написали new OrderPlacementService(...), то вы обходите контейнер, значит получаете обычный объект без AOP-обёртки. Вызовы такого объекта никогда не попадут под Spring AOP, даже если у вас идеально написан aspect.
Ошибка №5: переносить бизнес-логику в aspect “раз уж мы тут оборачиваем”.
Around-advice даёт ощущение абсолютной власти: можно до/после/вместо/условно, можно менять аргументы и результат. Но если вы начнёте складывать в аспект доменные решения, у вас получится проект, где сценарий заказа размазан между сервисами и аспектами. Держите аспект как техническую обвязку, а бизнес — в сервисах и доменных объектах.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ