1. Co się psuje: aspekt się nie wyzwolił
Self-invocation zazwyczaj objawia się w najbardziej nieprzyjemnym momencie: właśnie pokonałeś AOP, wszystko ładnie się mierzy, i oczekujesz zobaczyć dwa pomiary czasu — dla zewnętrznej metody i dla wewnętrznego kroku. Ale log drukuje tylko jeden. Mózg automatycznie wyciąga wniosek „AOP się zepsuło", choć w rzeczywistości błędnie wyobraziliśmy sobie, co tu jest przechwytywane.
Opakowanie u nas pozostaje to samo: jeden ServiceTimingAspect, @Around wokół warstwy serwisowej i wywołanie beana przez kontener. Nie psuje się tu aspekt ani reguła execution(...), lecz ścieżka wywołania: część wywołań po prostu nie dociera do proxy.
Wyobraźmy sobie typowy kawałek warstwy serwisowej. Dodaliśmy timing-aspekt, który mierzy wszystkie metody w pakiecie com.example.contextflow.application.service..*. W serwisie jest metoda placeOrder(), która wywołuje calculateTotal() wewnątrz tej samej klasy.
import org.springframework.stereotype.Service;
@Service
public class OrderPlacementService {
public void placeOrder() {
// Ważne: to wewnętrzne wywołanie „wewnątrz obiektu" (self-invocation),
// NIE przejdzie przez proxy Spring, nawet jeśli pointcut pasuje.
calculateTotal();
// To po prostu krok biznesowy (lub zaślepka kroku biznesowego) w scenariuszu składania zamówienia
System.out.println("placing order"); // placing order
}
public void calculateTotal() {
// Wewnętrzny krok obliczenia, który nowicjusz spodziewa się zobaczyć w logach aspektu
System.out.println("pricing order"); // pricing order
}
}
Jeśli patrzeć na to oczami „zwykłego programisty Javy", wydaje się logiczne: pointcut pasuje i do placeOrder(), i do calculateTotal(), więc advice powinien zadziałać dwa razy. Zwłaszcza jeśli już w wykładzie 4 upewniłeś się, że execution(* ..OrderPlacementService.*(..)) dokładnie wybiera obie metody.
Ale w rzeczywistości zobaczysz mniej więcej taki efekt: pomiar czasu jest dla placeOrder(), a dla calculateTotal() — cisza. I nie dlatego, że Spring „zmienił zdanie", lecz dlatego, że drugie wywołanie nie przechodzi przez proxy. Czyli dla Spring AOP jakby „wydarzyło się w innym świecie", poza strefą, gdzie advice może interweniować.
2. Mechanika wywołań przez proxy
Żeby self-invocation przestało być mistyką, trzeba na chwilę przełączyć się z „metody pasują" na „jak dokładnie wywołanie przechodzi w runtime". W Spring AOP opartym na proxy jest prosta reguła, od której wynika wszystko: przechwytywane są wywołania, które wchodzą do beana przez obiekt proxy. Jeśli wywołanie nie weszło przez proxy — pointcut niech pasuje trzy razy, advice nie dostanie szansy zadziałać.
Najbardziej poglądowy sposób — narysować łańcuch wywołań. Gdy inny bean (np. ScenarioRunner) wywołuje orderPlacementService.placeOrder(), trzyma w rękach referencję do Spring beana. A Spring bean, gdy AOP jest włączony, często jest proxy, a nie prawdziwym 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
Kluczowa linijka tu — T->T: calculateTotal() (self-invocation). To właśnie self-invocation: metoda tego samego obiektu wywołuje inną metodę na tym samym obiekcie. Spring AOP nie ma oddzielnego „drugiego proxy" wewnątrz target, który przechwyci to wewnętrzne wywołanie. Wewnętrzne wywołanie — to zwykłe wywołanie Javy, które omija „punkt kontrolny" (proxy).
Dla utrwalenia porównajmy dwa typy wywołań w formie tabeli. Tabela jest prosta, ale bardzo pomaga nowicjuszowi:
| Sytuacja wywołania | Przez proxy? | Advice może zadziałać? | Przykład |
|---|---|---|---|
| Jeden bean wywołuje metodę innego beana | Tak | Tak | runner -> orderPlacementService.placeOrder() |
| Metoda wewnątrz klasy wywołuje inną metodę przez this | Nie | Nie | placeOrder() -> calculateTotal() |
W tym momencie zazwyczaj pojawia się logiczne pytanie: „Ale przecież mamy pointcut, to jest reguła wyboru metod". I tu jest ważna subtelność: pointcut w Spring AOP wybiera join pointy, a join point w modelu opartym na proxy — to nie „dowolna metoda", lecz „wykonanie metody, do którego możemy się wtrącić przez proxy". Wewnętrzne wywołanie z pominięciem proxy po prostu nie staje się takim join pointem.
3. Mini-demo na ContextFlow
Teraz zróbmy krótkie demo, żebyś nie tylko uwierzył na słowo, lecz „zobaczył" self-invocation oczami terminalu. Weźmiemy uproszczony timing-aspekt i serwis z wewnętrznym wywołaniem. Ważne: wywołanie serwisu musi iść z kontekstu, inaczej w ogóle nie dostaniemy proxy i AOP nie będzie miało znaczenia.
Teraz warto trzymać w głowie to samo robocze powiązanie: timing-aspekt jest już podłączony i pointcut warstwy serwisowej pasuje. Zmienia się tylko sposób, w jaki jedna metoda dociera do drugiej.
Najpierw minimalny around-advice, który drukuje nazwę metody i czas. Tu nie są ważne nano-dokładne liczby; ważna jest liczba wyzwoleń.
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 {
// Start pomiaru PRZED wywołaniem docelowej metody
long start = System.nanoTime();
try {
// Przekazujemy sterowanie dalej w łańcuchu (do metody target)
return pjp.proceed();
} finally {
// Logujemy w finally, żeby zobaczyć pomiar nawet przy wyjątku
System.out.println(pjp.getSignature().getName()
+ " took " + (System.nanoTime() - start) + " ns");
}
}
Teraz serwis (ten sam co wyżej), gdzie placeOrder() wywołuje calculateTotal() wewnątrz klasy.
import org.springframework.stereotype.Service;
@Service
public class OrderPlacementService {
public void placeOrder() {
// Oczekiwane „nieoczywiste" miejsce: to wywołanie nie idzie przez proxy
calculateTotal();
// Zwykły wskaźnik wykonania kroku
System.out.println("placing order"); // placing order
}
public void calculateTotal() {
// Zwykły wskaźnik wykonania kroku
System.out.println("pricing order"); // pricing order
}
}
I wreszcie — uruchomienie przez AnnotationConfigApplicationContext. Jeśli spróbujesz stworzyć new OrderPlacementService() i wywołać metodę, AOP będzie urazony i milczeć: proxy tworzy kontener, a nie operator new.
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
try (var ctx = new AnnotationConfigApplicationContext(AppConfig.class)) {
// Pobieramy bean z kontekstu, żeby dostać właśnie obiekt zarządzany przez Spring (zazwyczaj proxy)
var service = ctx.getBean(OrderPlacementService.class);
// To wywołanie wejdzie do beana „z zewnątrz", przez proxy
service.placeOrder();
}
Oczekiwanie nowicjusza jest zazwyczaj takie: „Zobaczę timing dla placeOrder i dla calculateTotal". A rzeczywistość będzie bliższa temu:
pricing order
placing order
placeOrder took 123456 ns
Zwróć uwagę na „dziurę": metody calculateTotal nie ma w logu timingowym, choć naprawdę się wykonała (widzimy pricing order). To właśnie self-invocation bypass: wewnętrzne wywołanie przeszło bezpośrednio wewnątrz obiektu target, nie wchodząc przez proxy, a więc advice tam nie trafił.
4. Pointcut nie leczy self-invocation
Po pierwszym zderzeniu z self-invocation pojawia się chęć „dociśnięcia" sytuacji techniką: przepisać pointcut, zrobić wyrażenie precyzyjniejszym, dodać jeszcze jeden execution, może nawet obmazać się ..* tak, żeby „na pewno zadziałało". To absolutnie naturalna reakcja: przyzwyczailiśmy się, że jeśli filtr nie działa — trzeba poprawić filtr. Ale tu problem nie leży w filtrze, lecz w tym, że zdarzenie nie dotarło do miejsca, gdzie filtr jest stosowany.
Pointcut — to reguła wyboru join pointów. Ale join pointy w Spring AOP powstają tylko na granicy proxy. Jeśli wywołanie przeszło obok proxy, Spring po prostu nie ma „punktu przechwytywania", gdzie mógłby zapytać: „O, czy to pasuje do pointcut?". To jak postawić najuważniejszego ochroniarza przy wejściu do biura i potem dziwić się, że ludzie chodzą po biurze z pokoju do pokoju bez sprawdzania dokumentów. Ochroniarz nie jest głupi — stoi po prostu nie tam, gdzie próbujesz kontrolować przemieszczanie.
Spróbujmy mentalnie „wzmocnić" pointcut do absurdu. Na przykład zrobić go super-precyzyjnym i pokryć dokładnie wewnętrzną metodę:
@Around("execution(* com.example.contextflow.application.service.OrderPlacementService.calculateTotal(..))")
public Object onlyCalculateTotal(ProceedingJoinPoint pjp) throws Throwable {
// Nawet jeśli wybraliśmy metodę maksymalnie precyzyjnie, to nie stworzy „przechwytywania"
// dla wewnętrznego wywołania, które omija proxy.
return pjp.proceed();
}
Logika wydaje się żelazna: jawnie wybraliśmy calculateTotal(). Ale wynik się nie zmieni, bo wywołanie calculateTotal() i tak wykonuje się wewnątrz target, bezpośrednio. Spring AOP stosuje advice, gdy proxy przechwytuje wywołanie. A w self-invocation proxy nie przechwytuje niczego: wywołanie jest już „wewnątrz".
Ten moment warto zapamiętać nie tylko dla naszego timing-aspektu. To samo ograniczenie pojawia się w innych mechanizmach Spring opartych na proxy, np. z @Transactional: deweloper oczekuje dodatkowego zachowania na wewnętrznej metodzie i dziwi się, dlaczego go nie ma. Przyczyna jest jedna i ta sama — jeśli wywołanie nie przeszło przez proxy, żadne zewnętrzne opakowanie się nie włącza.
5. Wynosimy logikę do osobnego beana
Najbardziej właściwa pierwsza odpowiedź na self-invocation — nie spryt, lecz design. I brzmi nudno jak „jedz brokuły": wynieś wewnętrzną odpowiedzialność do osobnego beana i wywołuj go przez zależność. Ale to rozwiązanie jednocześnie leczy problem AOP i sprawia, że kod jest bardziej czytelny, bo w serwisie pozostaje orkiestracja scenariusza, a szczegóły przechodzą do wyspecjalizowanego „kolaboratora".
Zacznijmy od „złego" wariantu (wszystko w jednym serwisie). Może pojawić się całkowicie naturalnie: „no co tam liczyć cenę, dwa println — teraz nabazgram".
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
}
}
Teraz refaktoryzacja w duchu naszego projektu ContextFlow, gdzie pricing — to osobna odpowiedzialność. Tworzymy OrderPricingService i przenosimy tam obliczenie.
import org.springframework.stereotype.Service;
@Service
public class OrderPricingService {
public void calculateTotal() {
// Osobna odpowiedzialność: obliczenie ceny (w przykładzie — tylko zaślepka)
System.out.println("pricing order"); // pricing order
}
}
A w OrderPlacementService zostawiamy scenariusz i wstrzykujemy serwis pricing przez konstruktor (nasz ulubiony constructor injection).
import org.springframework.stereotype.Service;
@Service
public class OrderPlacementService {
private final OrderPricingService pricingService;
public OrderPlacementService(OrderPricingService pricingService) {
// Wstrzyknięcie przez konstruktor: Spring przekaże tu inny bean (zazwyczaj też proksowany)
this.pricingService = pricingService;
}
public void placeOrder() {
// Teraz to wywołanie INNEGO beana: pójdzie przez referencję zarządzaną przez Spring i proxy
pricingService.calculateTotal();
// Orkiestracja scenariusza pozostaje tu
System.out.println("placing order"); // placing order
}
}
Co zyskaliśmy właśnie z punktu widzenia AOP? Teraz wywołanie pricingService.calculateTotal() — to wywołanie innego beana, a więc przechodzi przez referencję zarządzaną przez Spring. Jeśli nasz pointcut pokrywa warstwę serwisową (np. cały pakiet application.service), to oba beany będą proksowane, i wywołanie calculateTotal() też trafi pod advice.
Uruchamiamy ten sam runner — i teraz zazwyczaj zobaczymy dwa pomiary. Zagnieżdżone wywołania dadzą zagnieżdżone logi, często w takiej kolejności: najpierw zmierzy się wewnętrzna metoda, potem zewnętrzna.
pricing order
calculateTotal took 34567 ns
placing order
placeOrder took 123456 ns
Piękno polega na tym, że w ogóle „nie leczyliśmy AOP". Leczyliśmy architekturę: OrderPlacementService teraz naprawdę „składa zamówienie", a pricing — to osobny krok, który można testować, rozwijać, zamieniać lub używać ponownie. AOP po prostu przestał „potykać się", bo wywołanie znowu przechodzi przez granicę między beanami.
Obejścia zamiast refaktoryzacji
Po refaktoryzacji do osobnego beana zazwyczaj przychodzi ulga, ale gdzieś w środku nadal żyje mały demon: „a czy można jednak zmusić self-invocation do działania bez refaktoryzacji?". Technicznie tak, istnieją obejścia. Praktycznie — prawie zawsze lepiej sprawić, żeby nie były potrzebne, zwłaszcza w projekcie edukacyjnym i na poziomie junior.
Jedna klasa trików związana jest z tym, żeby „dostać proxy z wewnątrz" i wywoływać metodę przez niego. W Spring są mechanizmy w stylu „expose proxy" i dostęp do bieżącego proxy, ale to od razu wnosi do kodu twardą zależność od infrastruktury AOP. Wygląda efektownie, ale brzydko pachnie: serwis biznesowy zaczyna wiedzieć, że w rzeczywistości nie jest do końca sobą, a wokół niego jest proxy. Taki kod gorzej testowalny, gorzej czytelny i łamie się przy najmniejszej przebudowie konfiguracji.
Inna klasa trików — self-injection, gdy serwis wstrzykuje sam siebie (często jeszcze z @Lazy) i wywołuje metodę przez „siebie-pod-proxy". Omawialiśmy już podobne rzeczy wcześniej, gdy mówiliśmy o wiring i dlaczego self injection jest przydatny tylko do rozumienia mechaniki, ale szkodliwy jako codzienny nawyk. Dla AOP wygląda to jak „hack, który niby działa", ale ceną jest jeszcze bardziej splątana architektura i ryzyko cykli, dziwnych inicjalizacji i bardzo nieoczywistego zachowania.
Dlatego zdrowa strategia dla ContextFlow i dla prawdziwej praktyki jest taka: trzymaj AOP jako zewnętrzną warstwę. Jeśli napotkałeś self-invocation, traktuj to nie jako „Spring się kłóci", lecz jako podpowiedź: możliwe, że wewnątrz klasy pomieszały się odpowiedzialności i czas je rozdzielić. W większości przypadków właśnie to sprawia, że kod jest lepszy, nawet jeśli jutro w ogóle wyłączysz AOP.
6. Typowe błędy przy self-invocation
Self-invocation jest nieprzyjemne tym, że tworzy poczucie „losowej magii": wczoraj advice działał, dziś — nie, choć kod prawie taki sam. W rzeczywistości to bardzo deterministyczna historia, tylko nowicjusz patrzy na nią zazwyczaj jak na „dopasowanie pointcut", a nie jak na „ścieżkę wywołania przez proxy". Poniżej — błędy, które zdarzają się najczęściej.
Błąd nr 1: oczekiwać, że this.someMethod() przejdzie przez advice.
Wewnętrzne wywołanie wewnątrz tej samej klasy — to bezpośrednie wywołanie Javy wewnątrz obiektu target. Nie wchodzi przez proxy, więc advice nie dostaje szansy zadziałać. Jeśli chcesz przechwytywać wewnętrzny krok, wynieś go do osobnego beana i wywołuj przez zależność.
Błąd nr 2: próbować naprawić self-invocation „sprytniejszym pointcut".
Pointcut wybiera join pointy, ale join point pojawia się tylko tam, gdzie proxy przechwytuje wywołanie. Jeśli wywołanie idzie obok proxy, żadne wyrażenie execution(...) nie pomoże, bo „miejsca przechwytywania" po prostu nie ma. To nie zadanie filtra, to zadanie ścieżki wywołania.
Błąd nr 3: chowanie kroku w metodę private i oczekiwanie AOP.
W Spring AOP na naszym poziomie docelowy punkt — to wykonanie metod beana. Metoda private wewnątrz klasy w ogóle nie jest dobrym kandydatem na granicę AOP: jest i tak „wewnętrzna", i jej wywołanie prawie zawsze będzie self-invocation. Jeśli krok jest ważny jako osobna operacja, zrób go metodą osobnego beana.
Błąd nr 4: tworzyć serwis ręcznie przez new i dziwić się, że aspekt „nie działa".
Proxy tworzy kontener. Jeśli napisałeś new OrderPlacementService(...), ominąłeś kontener, więc dostajesz zwykły obiekt bez wrappera AOP. Wywołania takiego obiektu nigdy nie trafią pod Spring AOP, nawet jeśli masz idealnie napisany aspekt.
Błąd nr 5: przenosić logikę biznesową do aspektu „skoro już tu owijamy".
Around-advice daje poczucie absolutnej władzy: można przed/po/zamiast/warunkowo, można zmieniać argumenty i wynik. Ale jeśli zaczniesz wkładać do aspektu decyzje domenowe, dostaniesz projekt, gdzie scenariusz zamówienia jest rozmazany między serwisami a aspektami. Trzymaj aspekt jako techniczne opakowanie, a biznes — w serwisach i obiektach domenowych.
GO TO FULL VERSION