Command Palette

Search for a command to run...

[Advanced Spring Boot] Spring AOP: proxy JDK và CGLIB, aspect và lỗi self-invocation

Bài trước kết thúc bằng một BeanPostProcessor tráo bean thành JDK dynamic proxy, và có nhắc rằng AnnotationAwareAspectJAutoProxyCreator của Spring làm đúng chuyện đó nhưng ở quy mô lớn hơn nhiều. Bài này mổ trọn bộ máy ấy: Spring dựng proxy loại nào, mỗi loại không làm được gì, một aspect chọn method để bọc ra sao, và vì sao annotation trên method mà bạn gọi từ bên trong cùng class lại hoàn toàn vô tác dụng.

Các ví dụ dùng Spring Boot 4.1.1 và Java 21. Không góc nào của Spring có nhiều lời truyền miệng từ thời trước Boot 3 như chỗ này, nên bài này đem từng mẩu truyền miệng ra đối chiếu với những gì Boot 4 thực sự làm.

Lời gọi từ bean khác đi qua proxy nên có advice, còn this.method() ở lại bên trong target

Phần đầu là một thay đổi đóng gói của Boot 4 mà bạn gặp trước cả khi viết dòng aspect đầu tiên. Từ phần hai trở đi là bản thân cái proxy.

Starter AOP không còn tồn tại

Mọi bài viết về AOP trước 2026 đều mở đầu giống nhau: thêm spring-boot-starter-aop. Trên Boot 4.1.1 artifact đó không nằm trong bill of materials, và Spring Initializr không biết từ này:

Bash
curl -s "https://start.spring.io/starter.zip?type=gradle-project&language=java&bootVersion=4.1.1&javaVersion=21&dependencies=aop"
Text
{"timestamp":"2026-09-18T03:01:58.679Z","status":400,"error":"Bad Request","message":"Unknown dependency 'aop' check project metadata","path":"/starter.zip"}

Không phải gõ nhầm. Metadata của Initializr cho Boot 4.1.1 liệt kê 204 dependency trong 23 nhóm, và tìm chuỗi aop hay aspect trong mọi field của cả 204 mục đó cho ra con số không. BOM cũng vậy — spring-boot-starter-aop không xuất hiện ở bất cứ đâu trong spring-boot-dependencies-4.1.1.pom, còn metadata trên Maven Central dừng ở 4.0.0-M2. Hỏi bản 4.1.1 thì nhận 404.

Bản thay thế nằm trong BOM dưới một cái tên khác, và bạn tự thêm bằng tay:

build.gradle
dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-aspectj'
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-webmvc'
	runtimeOnly 'com.h2database:h2'
	testImplementation 'org.springframework.boot:spring-boot-starter-aspectj-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
}

Không ghi version: BOM của Boot plugin lo phần đó. Nó kéo theo những gì, lấy từ ./gradlew dependencies --configuration compileClasspath trên một project mà dependency duy nhất còn lại là spring-boot-starter-webmvc:

Text
+--- org.springframework.boot:spring-boot-starter-aspectj -> 4.1.1
|    +--- org.springframework.boot:spring-boot-starter:4.1.1
|    +--- org.springframework:spring-aop:7.0.9 (*)
|    \--- org.aspectj:aspectjweaver:1.9.25.1

Ba thứ, và chỉ một thứ là mới với một ứng dụng thông thường. spring-aop vốn đã có trên classpath của mọi project Spring Boot, vì spring-context phụ thuộc vào nó. spring-boot-starter cũng đã có sẵn. Thứ mà starter thật sự bổ sung là org.aspectj:aspectjweaver, được ghim bởi property aspectj.version = 1.9.25.1 trong BOM.

Starter bật lên chính xác thứ gì

aspectjweaver không phải đồ trang trí. AopAutoConfiguration của 4.1.1 rẽ nhánh dựa trên một class trong đó, và javap -v đọc thẳng ra từ file jar:

Text
AopAutoConfiguration
  @ConditionalOnBooleanProperty(name = ["spring.aop.auto"], matchIfMissing = true)
 
AopAutoConfiguration$AspectJAutoProxyingConfiguration
  @ConditionalOnClass(org.aspectj.weaver.Advice.class)
 
AopAutoConfiguration$AspectJAutoProxyingConfiguration$CglibAutoProxyConfiguration
  @EnableAspectJAutoProxy(proxyTargetClass = true)
  @ConditionalOnBooleanProperty(name = ["spring.aop.proxy-target-class"], matchIfMissing = true)
 
AopAutoConfiguration$AspectJAutoProxyingConfiguration$JdkDynamicAutoProxyConfiguration
  @EnableAspectJAutoProxy(proxyTargetClass = false)
  @ConditionalOnBooleanProperty(name = ["spring.aop.proxy-target-class"], havingValue = false)
 
AopAutoConfiguration$ClassProxyingConfiguration
  @ConditionalOnMissingClass("org.aspectj.weaver.Advice")
  @ConditionalOnBooleanProperty(name = ["spring.aop.proxy-target-class"], matchIfMissing = true)

Không có org.aspectj.weaver.Advice trên classpath thì Boot đi nhánh ClassProxyingConfiguration: proxy vẫn được tạo — nhờ vậy @Transactional vẫn chạy — nhưng @EnableAspectJAutoProxy không bao giờ được áp dụng, nên không class @Aspect nào được đọc. Có class đó thì phần xử lý @Aspect được bật, và spring.aop.proxy-target-class quyết định loại proxy.

Hai property, và chỉ có bấy nhiêu. Lấy từ spring-configuration-metadata.json bên trong spring-boot-autoconfigure-4.1.1.jar:

PropertyTypeMặc địnhTác dụng
spring.aop.autoBooleantruethêm @EnableAspectJAutoProxy
spring.aop.proxy-target-classBooleantrueproxy kiểu CGLIB subclass (true) thay cho JDK proxy dựa trên interface (false)

Không hề có spring.aop.expose-proxy. Thứ đó chỉ với tới được qua annotation, và điều này quan trọng ở phần self-invocation bên dưới.

Có thật sự cần starter không?

Thường là không, và câu trả lời thành thật này đáng biết trước khi bạn thêm một dependency. spring-boot-starter-data-jpa kéo theo spring-aspects, mà spring-aspects kéo theo aspectjweaver:

Text
|    \--- org.springframework:spring-aspects:7.0.9
|         \--- org.aspectj:aspectjweaver:1.9.25 -> 1.9.25.1

Ứng dụng lab của bài này đã được build lại sau khi xoá hẳn dòng starter AspectJ, và condition report do --debug in ra không đổi:

Text
   AopAutoConfiguration matched:
      - @ConditionalOnBooleanProperty (spring.aop.auto=true) matched (OnPropertyCondition)
 
   AopAutoConfiguration.AspectJAutoProxyingConfiguration matched:
      - @ConditionalOnClass found required class 'org.aspectj.weaver.Advice' (OnClassCondition)
 
   AopAutoConfiguration.AspectJAutoProxyingConfiguration.CglibAutoProxyConfiguration matched:
      - @ConditionalOnBooleanProperty (spring.aop.proxy-target-class=true) matched (OnPropertyCondition)

Mọi aspect trong ứng dụng vẫn chạy. Còn trên một project không có JPA — chỉ spring-boot-starter-webmvc — thì chính aspect đó thậm chí không compile nổi:

Text
error: package org.aspectj.lang does not exist
import org.aspectj.lang.ProceedingJoinPoint;
                       ^
error: cannot find symbol
@Aspect
 ^
  symbol: class Aspect

Vậy nên: thêm spring-boot-starter-aspectj khi bạn viết aspect, vì nó khai báo đúng thứ mà file source của bạn đang phụ thuộc. Đừng tưởng nó là thứ làm cho AOP chạy được — trên một ứng dụng JPA, AspectJ đã có mặt từ lâu trước khi bạn yêu cầu.

Proxy do ai dựng, và dựng từ đâu

Object dựng mọi proxy là một BeanPostProcessor, chính cái mà bài trước đo được ở vị trí 9 của chuỗi. Nó được đăng ký dưới một bean name cố định, nên bạn hỏi thẳng context là ra:

src/main/java/com/example/demo/lab/AopLab.java
Trace.note("auto-proxy creator: "
        + context.getBean("org.springframework.aop.config.internalAutoProxyCreator").getClass().getName());
Text
    auto-proxy creator: org.springframework.aop.aspectj.annotation.AnnotationAwareAspectJAutoProxyCreator

Trong postProcessAfterInitialization nó hỏi từng advisor trong context xem có áp dụng cho bean này không, và nếu có thì trả về một proxy thay chỗ bean. Mọi thứ trong bài này đều là hệ quả của một phép tráo duy nhất đó.

Service sẽ bị proxy được cố ý viết thật bình thường — một interface, một implementation, và một aspect in ra mỗi khi nó chạy:

src/main/java/com/example/demo/pricing/PriceService.java
package com.example.demo.pricing;
 
import java.math.BigDecimal;
 
public interface PriceService {
 
    BigDecimal quote(String sku, int quantity);
 
    String name();
}
src/main/java/com/example/demo/pricing/ListPriceService.java
package com.example.demo.pricing;
 
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.Map;
import org.springframework.stereotype.Service;
 
@Service
public class ListPriceService implements PriceService {
 
    private final Map<String, BigDecimal> catalog = new HashMap<>();
 
    public ListPriceService() {
        catalog.put("SKU-1", new BigDecimal("19.90"));
        catalog.put("SKU-2", new BigDecimal("4.50"));
        System.out.println("    ListPriceService constructor ran on instance @"
                + Integer.toHexString(System.identityHashCode(this)));
    }
 
    @Override
    public BigDecimal quote(String sku, int quantity) {
        return catalog.getOrDefault(sku, BigDecimal.ZERO).multiply(BigDecimal.valueOf(quantity));
    }
 
    @Override
    public String name() {
        return "list";
    }
 
    /** final on purpose: CGLIB cannot override it. */
    public final String describe() {
        return "catalog holds " + catalog.size() + " prices";
    }
}

JDK dynamic proxy hay CGLIB subclass

Truyền miệng nói rằng Spring dùng JDK dynamic proxy mỗi khi target có implement interface. Trên Boot thì điều đó sai từ bản 2.0, và kết quả chạy nói rõ. ListPriceService implement PriceService, vậy mà với cấu hình mặc định bean lại là một CGLIB subclass:

Text
    bean class              com.example.demo.pricing.ListPriceService$$SpringCGLIB$$0
    AopUtils.isAopProxy     true
    isJdkDynamicProxy       false
    isCglibProxy            true
    ultimateTargetClass     com.example.demo.pricing.ListPriceService

Lý do nằm ở phần auto-configuration bên trên: Boot áp @EnableAspectJAutoProxy(proxyTargetClass = true)spring.aop.proxy-target-class mặc định là true. Spring Framework thuần, không có Boot, mặc định ngược lại. Đổi property thì đúng bean đó quay về dạng JDK proxy:

src/main/resources/application.properties
server.port=8203
spring.aop.proxy-target-class=false 
Text
    bean class              jdk.proxy2.$Proxy103
    AopUtils.isAopProxy     true
    isJdkDynamicProxy       true
    isCglibProxy            false
    ultimateTargetClass     com.example.demo.pricing.ListPriceService
    proxiedInterfaces       [PriceService]

jdk.proxy2 là module động mà JDK đặt các class proxy sinh ra vào; con số sau $Proxy là bộ đếm nên sẽ đổi khi bạn thêm bean. Bốn helper trả lời câu "tôi đang cầm cái gì", và nên nhớ chúng, vì getClass().getName() trên một proxy chẳng nói lên điều gì hữu ích:

Lời gọiTrả lời
AopUtils.isAopProxy(bean)object này có phải Spring AOP proxy không
AopUtils.isJdkDynamicProxy(bean)có phải java.lang.reflect.Proxy không
AopUtils.isCglibProxy(bean)có phải subclass sinh ra không
AopProxyUtils.ultimateTargetClass(bean)class của object nằm bên dưới, bóc hết các lớp proxy lồng nhau

Cùng một @Service bị proxy theo hai cách: tên class, cái gì có advice, cái gì mất advice và cái gì lỗi ngay

JDK proxy làm hỏng cái gì

JDK dynamic proxy implement các interface của target và không có gì khác. Nó không phải một ListPriceService, nên ép kiểu về implementation class sẽ ném:

Text
    cast to ListPriceService threw
      java.lang.ClassCastException: class jdk.proxy2.$Proxy103 cannot be cast to class com.example.demo.pricing.ListPriceService (jdk.proxy2.$Proxy103 is in module jdk.proxy2 of loader org.springframework.boot.loader.launch.LaunchedClassLoader @378bf509; com.example.demo.pricing.ListPriceService is in unnamed module of loader org.springframework.boot.loader.launch.LaunchedClassLoader @378bf509)

Thực tế bạn hiếm khi tự viết câu ép kiểu đó; container viết hộ bạn ngay khi một bean xin đúng implementation type thay vì interface:

src/main/java/com/example/demo/config/ImplInjectionBean.java
@Component
public class ImplInjectionBean {
 
    private final ListPriceService prices;
 
    ImplInjectionBean(ListPriceService prices) {
        this.prices = prices;
    }
}

Với spring.aop.proxy-target-class=false, ứng dụng đó không khởi động được, và failure analyzer của Boot giải thích rất tử tế:

Text
APPLICATION FAILED TO START
***************************
 
Description:
 
The bean 'listPriceService' could not be injected because it is a JDK dynamic proxy
 
The bean is of type 'jdk.proxy2.$Proxy103' and implements:
	com.example.demo.pricing.PriceService
	org.springframework.aop.SpringProxy
	org.springframework.aop.framework.Advised
	org.springframework.core.DecoratingProxy
 
Expected a bean of type 'com.example.demo.pricing.ListPriceService' which implements:
	com.example.demo.pricing.PriceService
 
 
Action:
 
Consider injecting the bean as one of its interfaces or forcing the use of CGLib-based proxies by setting proxyTargetClass=true on @EnableAsync and/or @EnableCaching.

Đó là lý do Boot đổi mặc định. CGLIB subclass chính là target type, nên inject theo implementation class vẫn chạy và không ai phải quan tâm proxy nào đã được dựng. Cứ để nguyên spring.aop.proxy-target-class trừ khi bạn có lý do cụ thể — lý do thường gặp là một target class không thể kế thừa được.

CGLIB proxy không làm được gì

CGLIB proxy là một subclass sinh ra để override các method của target. Cái gì nó không override được thì chỗ đó là một lỗ hổng advice. Năm kiểu method, trong một class, mỗi cái đều gọi qua bean với một aspect khớp execution(* com.example.demo.limits.LimitsService.*(..)):

src/main/java/com/example/demo/limits/LimitsService.java
package com.example.demo.limits;
 
import com.example.demo.lab.Trace;
import org.springframework.stereotype.Service;
 
@Service
public class LimitsService {
 
    public String publicMethod() {
        return "public";
    }
 
    public final String finalMethod() {
        return "final";
    }
 
    protected String protectedMethod() {
        return "protected";
    }
 
    String packagePrivateMethod() {
        return "package-private";
    }
 
    private String privateMethod() {
        return "private";
    }
 
    /** The only way to reach the private method: an advised public method calls it. */
    public String callsPrivateMethod() {
        Trace.note("callsPrivateMethod(): this = " + this.getClass().getSimpleName());
        return privateMethod();
    }
}

Lần chạy liệt kê những method mà subclass sinh ra có khai báo, rồi gọi từng cái:

Text
    bean class: com.example.demo.limits.LimitsService$$SpringCGLIB$$0
    protectedMethod        [protected]        overridden by the proxy class = true
    packagePrivateMethod   []                 overridden by the proxy class = true
    callsPrivateMethod     [public]           overridden by the proxy class = true
    publicMethod           [public]           overridden by the proxy class = true
    finalMethod            [public final]     overridden by the proxy class = false
    privateMethod          [private]          overridden by the proxy class = false
    --- calling each one through the bean ---
    [LimitsAspect] ADVISED publicMethod
    publicMethod()          -> public
    finalMethod()           -> final
    [LimitsAspect] ADVISED protectedMethod
    protectedMethod()       -> protected
    [LimitsAspect] ADVISED packagePrivateMethod
    packagePrivateMethod()  -> package-private
    [LimitsAspect] ADVISED callsPrivateMethod
    callsPrivateMethod(): this = LimitsService
    callsPrivateMethod()    -> private

Đoạn output đó chính là cái bảng mà phần này sinh ra để nói:

TargetProxy có overrideAdvice có chạyHỏng kiểu gì
method public
method protected
method package-private— (subclass sinh ra cùng package và cùng class loader)
method finalkhôngkhôngâm thầm, mà còn tệ hơn âm thầm — xem bên dưới
method privatekhôngkhôngâm thầm; bản chất nó đã là self-invocation
class finallỗi to: context không khởi động được

Hai dòng trong đó ngược hẳn với những gì phần lớn bài viết nói. "Chỉ method public mới được advise" là quy tắc của @Transactional với allowPublicMethodsOnly, không phải của Spring AOP: ở đây protected và package-private đều có advice. Còn method final thì không hề ném lỗi — nó bị bỏ qua lặng lẽ, và đó mới là nửa nguy hiểm.

Class final làm hỏng lúc khởi động

Đặt final lên một class @Service có pointcut khớp thì CGLIB không sinh ra được gì:

Text
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'finalPriceService' defined in URL […/demo-0.0.1-SNAPSHOT.jar/!BOOT-INF/classes/!/com/example/demo/limits/FinalPriceService.class]: Could not generate CGLIB subclass of class com.example.demo.limits.FinalPriceService: Common causes of this problem include using a final class or a non-visible class
Caused by: org.springframework.aop.framework.AopConfigException: Could not generate CGLIB subclass of class com.example.demo.limits.FinalPriceService: Common causes of this problem include using a final class or a non-visible class
	at org.springframework.aop.framework.CglibAopProxy.buildProxy(CglibAopProxy.java:236)
	at org.springframework.aop.framework.ProxyFactory.getProxy(ProxyFactory.java:110)
	at org.springframework.aop.framework.autoproxy.AbstractAutoProxyCreator.createProxy(AbstractAutoProxyCreator.java:431)
	at org.springframework.aop.framework.autoproxy.AbstractAutoProxyCreator.postProcessAfterInitialization(AbstractAutoProxyCreator.java:289)
Caused by: java.lang.IllegalArgumentException: Cannot subclass final class com.example.demo.limits.FinalPriceService
	at org.springframework.cglib.proxy.Enhancer.generateClass(Enhancer.java:653)
	at org.springframework.aop.framework.ObjenesisCglibAopProxy.createProxyClassAndInstance(ObjenesisCglibAopProxy.java:62)

Ca này dễ sống chung, vì nó nổ ngay lần khởi động đầu tiên sau khi bạn viết ra. Nếu class có interface thì spring.aop.proxy-target-class=false cho bạn một JDK proxy; không thì bỏ chữ final đi.

Proxy không bao giờ chạy constructor của bạn

Nhìn khung cuối của stack trace đó: ObjenesisCglibAopProxy. Spring khởi tạo subclass sinh ra bằng Objenesis, thứ cấp phát object mà không gọi bất kỳ constructor nào. Chính nhờ vậy mà một target có constructor nhận tham số, mở connection hay đọc config vẫn proxy được — nhưng đổi lại, các field của chính proxy instance không bao giờ được khởi tạo.

Hai instance, một cái được khởi tạo, và field đọc ra từ mỗi cái bằng reflection:

Text
== 2. does the proxy run the target's constructor
    proxy instance          @1e489957
    target instance         @63551c66
    target.catalog          {SKU-1=19.90, SKU-2=4.50}
    proxy.catalog           null

Dòng println trong constructor xuất hiện đúng một lần trong cả quá trình khởi động — trên target. Bình thường bạn không thấy chuyện này, vì mọi method đã override đều uỷ quyền xuống target và không đụng tới field của proxy. Method final là ngoại lệ: nó không được override, nên gọi nó trên proxy sẽ chạy bytecode của target với this trỏ vào cái proxy chưa khởi tạo.

Text
    cast to ListPriceService worked, describe() next
    describe() threw java.lang.NullPointerException: Cannot invoke "java.util.Map.size()" because "this.catalog" is null

describe() là ba dòng Java hoàn toàn đúng nhưng ném NullPointerException mỗi khi bean bị proxy. Spring có cảnh báo, ở mức WARN cho method implement interface và DEBUG cho các trường hợp còn lại, và thông điệp DEBUG gọi đúng tên vấn đề:

Text
WARN  CglibAopProxy: Public final method [public final java.lang.String com.example.demo.pricing.ListPriceService.describe()] cannot get proxied via CGLIB, consider removing the final marker or using interface-based JDK proxies.
DEBUG CglibAopProxy: Final method [public final java.lang.String com.example.demo.pricing.ListPriceService.describe()] cannot get proxied via CGLIB: Calls to this method will NOT be routed to the target instance and might lead to NPEs against uninitialized fields in the proxy instance.

⚠️ final trên method của một Spring bean không phải một biện pháp an toàn. Nó hoặc làm mất advice, hoặc — nếu method có đọc field — tạo ra NullPointerException ngay trong đoạn code nhìn rõ ràng là đúng.

Viết một aspect

Aspect là một @Component mang thêm @Aspect. Cần cả hai: @Component biến nó thành bean, @Aspect báo cho auto-proxy creator biết phải đọc advice từ nó. Biểu thức pointcut là cú pháp AspectJ, được Spring đánh giá lúc tạo proxy.

src/main/java/com/example/demo/ordering/AdviceKindAspect.java
package com.example.demo.ordering;
 
import com.example.demo.lab.Trace;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.After;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.AfterThrowing;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;
 
/** All five advice kinds on one named pointcut. */
@Aspect
@Component
public class AdviceKindAspect {
 
    @Pointcut("execution(* com.example.demo.ordering.OrderLabService.handle(..))")
    void handleCall() {
    }
 
    @Around("handleCall()")
    public Object around(ProceedingJoinPoint pjp) throws Throwable {
        Trace.step("@Around        before proceed()");
        try {
            Object result = pjp.proceed();
            Trace.step("@Around        after proceed(), result = " + result);
            return result;
        } catch (RuntimeException ex) {
            Trace.step("@Around        proceed() threw " + ex.getClass().getSimpleName());
            throw ex;
        }
    }
 
    @Before("handleCall()")
    public void before(JoinPoint jp) {
        Trace.step("@Before        args = " + java.util.Arrays.toString(jp.getArgs()));
    }
 
    @AfterReturning(pointcut = "handleCall()", returning = "result")
    public void afterReturning(Object result) {
        Trace.step("@AfterReturning result = " + result);
    }
 
    @AfterThrowing(pointcut = "handleCall()", throwing = "ex")
    public void afterThrowing(Throwable ex) {
        Trace.step("@AfterThrowing " + ex.getClass().getSimpleName() + ": " + ex.getMessage());
    }
 
    @After("handleCall()")
    public void after() {
        Trace.step("@After         runs either way");
    }
}

Method rỗng void handleCall() là một named pointcut: không có thân, không bao giờ được gọi, nó chỉ mang biểu thức. Năm advice tham chiếu tới nó bằng tên. Khi biểu thức đổi, bạn sửa một chuỗi thay vì năm chuỗi, và một named pointcut có thể được aspect khác dùng lại dưới dạng com.example.demo.ordering.AdviceKindAspect.handleCall().

Những pointcut designator đáng biết

AspectJ có cả tá designator; Spring AOP hỗ trợ một tập con, và năm cái dưới đây phủ gần hết nhu cầu. Một aspect khai báo một @Before cho mỗi designator, rồi một lần chạy gọi bốn method trên hai bean để bạn thấy rõ mỗi designator chọn cái nào:

src/main/java/com/example/demo/designator/DesignatorAspect.java
@Aspect
@Component
public class DesignatorAspect {
 
    @Before("execution(public String com.example.demo.designator.AlphaService.*(String))")
    public void byExecution(JoinPoint jp) {
        Trace.note("execution     -> " + signature(jp));
    }
 
    @Before("within(com.example.demo.designator..*)")
    public void byWithin(JoinPoint jp) {
        Trace.note("within        -> " + signature(jp));
    }
 
    @Before("@annotation(com.example.demo.designator.Audited)")
    public void byAnnotation(JoinPoint jp) {
        Trace.note("@annotation   -> " + signature(jp));
    }
 
    @Before("bean(betaService)")
    public void byBean(JoinPoint jp) {
        Trace.note("bean          -> " + signature(jp));
    }
 
    @Before("within(com.example.demo.designator..*) && args(sku, quantity)")
    public void byArgs(JoinPoint jp, String sku, int quantity) {
        Trace.note("args          -> " + signature(jp) + " sku=" + sku + " quantity=" + quantity);
    }
}
Text
== 4. pointcut designators
    --- alpha.plain("x") ---
    execution     -> AlphaService.plain
    within        -> AlphaService.plain
    --- alpha.audited("x") ---
    @annotation   -> AlphaService.audited
    execution     -> AlphaService.audited
    within        -> AlphaService.audited
    --- beta.plain("x") ---
    bean          -> BetaService.plain
    within        -> BetaService.plain
    --- beta.withTwoArgs("SKU-1", 3) ---
    args          -> BetaService.withTwoArgs sku=SKU-1 quantity=3
    bean          -> BetaService.withTwoArgs
    within        -> BetaService.withTwoArgs
DesignatorChọn cái gìGhi chú
execution(…)các lần chạy method theo signaturedesignator duy nhất lọc được theo modifier, return type và kiểu tham số; .. trong package nghĩa là "và mọi subpackage", (..) nghĩa là "tham số bất kỳ"
within(…)mọi thứ khai báo trong một type hoặc packagerẻ và thô; lựa chọn quen thuộc cho cả một tầng
@annotation(…)method mang một annotationcách bạn tự dựng @Audited, @Timed, @RateLimited của riêng mình
bean(…)method trên các bean có tên khớpchỉ Spring mới có, không phải AspectJ; hỗ trợ * nên bean(*Repository) chạy được
args(…)lời gọi có argument lúc chạy khớp kiểuđồng thời bind argument vào tham số của advice, như skuquantity ở trên

args là cái hay làm người ta bất ngờ: nó được đánh giá theo từng lời gọi chứ không phải theo từng method, nên đây là designator duy nhất trong danh sách tốn chi phí lúc chạy. Cái && within(…) đứng trước nó không phải để cho đẹp — thiếu một designator tĩnh thu hẹp phạm vi trước, args sẽ bị thử trên mọi method của mọi bean trong context.

Ghép các designator bằng &&, ||!. @annotation(Audited) && within(com.example.demo.service..*) là dạng hay gặp: annotation của bạn, nhưng chỉ ở đúng chỗ bạn muốn.

Năm loại advice và thứ tự chúng chạy

Thứ tự đo được cho một lời gọi trả về bình thường, từ aspect bên trên, mỗi dòng do chính advice in ra:

Text
== 5. advice order, normal return
 1  @Around        before proceed()
 2  @Before        args = [ok]
 3  target method  handle("ok")
 4  @AfterReturning result = OK
 5  @After         runs either way
 6  @Around        after proceed(), result = OK
    result = OK

và cho đúng lời gọi đó khi target ném exception:

Text
== 5b. advice order, the target throws
 1  @Around        before proceed()
 2  @Before        args = [boom]
 3  target method  handle("boom")
 4  @AfterThrowing IllegalStateException: handle failed on purpose
 5  @After         runs either way
 6  @Around        proceed() threw IllegalStateException
 7  caller caught  IllegalStateException: handle failed on purpose

Thứ tự đo được của năm loại advice quanh một lời gọi, nhánh trả về bình thường và nhánh ném exception

Bốn điều rút ra từ hai vết chạy đó.

@Around nằm ngoài cùng ở cả hai nhánh. Đây là advice duy nhất sở hữu lời gọi: mọi thứ khác diễn ra giữa proceed() của nó và lúc giá trị quay về.

@After chạy trước khi @Around chạy tiếp. Bước 5 đứng trước bước 6. Tài liệu tham chiếu của Spring Framework ghi thứ tự ưu tiên trong cùng một aspect là @Around, @Before, @After, @AfterReturning, @AfterThrowing, với @After được gọi sau advice returning và throwing, theo đúng ngữ nghĩa "after finally advice" của AspectJ. Rất nhiều tài liệu cũ vẫn vẽ @After nằm ngoài @Around. Nó là finally của target method, không phải của cả chuỗi advice.

@AfterReturning@AfterThrowing loại trừ nhau, và không cái nào đổi được kết cục. @AfterReturning thấy giá trị nhưng không thay được; @AfterThrowing thấy exception nhưng không nuốt được — bước 7 cho thấy caller nhận đúng IllegalStateException đó.

@Before không chặn được lời gọi, trừ khi nó ném exception. Nếu ném thì target không chạy và exception đi thẳng tới caller.

AdviceSignature thường dùngCó đổi được kết cục không
@AroundObject around(ProceedingJoinPoint pjp) throws Throwablecó — argument, giá trị trả về, exception, và cả việc target có chạy hay không
@Beforevoid before(JoinPoint jp)chỉ bằng cách ném exception
@AfterReturningvoid afterReturning(Object result)không
@AfterThrowingvoid afterThrowing(Throwable ex)không
@Aftervoid after()chỉ bằng cách ném exception

Hãy chọn loại advice yếu nhất đủ dùng. @Around là loại duy nhất có thể quên gọi proceed(), gọi nó hai lần, hoặc nuốt mất exception mà caller đang cần.

ProceedingJoinPoint: argument, giá trị trả về và exception

@Around nhận một ProceedingJoinPoint, và proceed() có bản nạp chồng nhận mảng argument thay thế. Tất cả những gì một around advice làm được đều nằm trong hai method này:

src/main/java/com/example/demo/rewrite/RewriteAspect.java
package com.example.demo.rewrite;
 
import com.example.demo.lab.Trace;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
 
@Aspect
@Component
public class RewriteAspect {
 
    @Around("execution(* com.example.demo.rewrite.RewriteService.greet(..))")
    public Object rewrite(ProceedingJoinPoint pjp) throws Throwable {
        Object[] args = pjp.getArgs();
        Trace.note("caller passed  " + java.util.Arrays.toString(args));
        args[0] = ((String) args[0]).toUpperCase(java.util.Locale.ROOT);
        Object result = pjp.proceed(args);
        Trace.note("target returned \"" + result + "\"");
        return result + " [rewritten]";
    }
 
    @Around("execution(* com.example.demo.rewrite.RewriteService.fail(..))")
    public Object swallow(ProceedingJoinPoint pjp) {
        try {
            return pjp.proceed();
        } catch (Throwable ex) {
            Trace.note("swallowed      " + ex.getClass().getSimpleName() + ": " + ex.getMessage());
            return "fallback";
        }
    }
}
Text
== 6. ProceedingJoinPoint
    caller passed  [ada, 2]
    target sees name="ADA" times=2
    target returned "ADA ADA"
    caller got     "ADA ADA [rewritten]"
    swallowed      IllegalArgumentException: the target threw
    fail() got     "fallback"

Caller truyền "ada", target nhận "ADA", và caller nhận về một chuỗi mà target chưa từng tạo ra. Advice thứ hai bắt IllegalArgumentException do target ném rồi trả về một giá trị, nên caller không hề biết có gì hỏng.

Cả hai đều chính đáng — retry, circuit breaker và cache đều dựng trên đúng cơ chế này — và cả hai cũng là lý do một giá trị lạ trong debugger thường là do aspect. Ba quy tắc giúp bạn sống sót:

  • Sửa pjp.getArgs() không thôi thì chẳng có tác dụng gì. Mảng đó là bản sao. proceed(args) mới là thứ làm target nhìn thấy.
  • Hãy rethrow, trừ khi nuốt exception chính là tính năng. catch (Throwable ex) { return null; } trong một around advice giấu lỗi production hàng năm trời.
  • Đừng đổi kiểu trả về. proceed() có kiểu Object; trả về thứ mà caller không ép kiểu được sẽ tạo ra ClassCastException tại một chỗ gọi trông rất vô tội.

Lưu ý JoinPoint cũng mang theo metadata: getSignature(), getTarget() (object thật), getThis() (proxy) và getArgs(). getSignature().getDeclaringType() là cách một logging aspect biết nó đang trang trí cho class nào.

Nhiều aspect trên cùng một method

Ba aspect cùng một pointcut, hai cái có order, một cái không:

src/main/java/com/example/demo/layered/FirstAspect.java
@Aspect
@Component
@Order(10)
public class FirstAspect {
 
    @Around("execution(* com.example.demo.layered.LayeredService.run(..))")
    public Object around(ProceedingJoinPoint pjp) throws Throwable {
        Trace.step("FirstAspect    @Order(10)  enter");
        try {
            return pjp.proceed();
        } finally {
            Trace.step("FirstAspect    @Order(10)  exit");
        }
    }
}

SecondAspect y hệt nhưng @Order(20), còn UnorderedAspect y hệt nhưng không có @Order nào. Mức lồng nhau đo được:

Text
== 7. three aspects on one method
 1  FirstAspect    @Order(10)  enter
 2  SecondAspect   @Order(20)  enter
 3  UnorderedAspect (no @Order)  enter
 4  target method  run()
 5  UnorderedAspect (no @Order)  exit
 6  SecondAspect   @Order(20)  exit
 7  FirstAspect    @Order(10)  exit

Giá trị @Order nhỏ nhất nằm ngoài cùng. Advisor được sắp tăng dần và cái đầu tiên trong chuỗi bọc tất cả phần còn lại, nên @Order(10) thấy lời gọi đầu tiên và trả về sau cùng. Khác với trên BeanPostProcessor — nơi bài trước đo được @Order bị bỏ qua hoàn toàn — ở đây @Order có tác dụng, vì advisor của AOP được sắp bằng AnnotationAwareOrderComparator, thứ có đọc annotation. Implement Ordered cho kết quả giống hệt.

Aspect không có order nằm trong cùng. Spring coi việc thiếu order là Ordered.LOWEST_PRECEDENCE, tức sắp cuối, tức gần target nhất. Đó là mặc định chấp nhận được cho một logging aspect và là mặc định tệ cho một security aspect. Hai aspect cùng không có order thì giữa chúng không có thứ tự xác định nào cả — đừng để một cặp aspect mà vị trí tương đối có ý nghĩa mà lại thiếu @Order tường minh.

Thứ tự thật sự gây đau trên production là giữa aspect của bạn và aspect của chính Spring. @Transactional được áp bởi BeanFactoryTransactionAttributeSourceAdvisor, mặc định ở Ordered.LOWEST_PRECEDENCE, nên hầu như mọi aspect có @Order tường minh đều chạy bên ngoài transaction — nó thấy lời gọi trước khi transaction bắt đầu và sau khi transaction đã commit. Nếu aspect của bạn phải chạy bên trong transaction, hãy cho nó order lớn hơn order của transaction advisor, hoặc đặt @EnableTransactionManagement(order = …) và xếp aspect của bạn lên trên.

Self-invocation, mổ sâu hơn @Transactional

Bài 30 của khoá Basics cho thấy triệu chứng: một method @Transactional gọi từ bên trong cùng class thì chạy mà không có transaction. Phần này là nguyên nhân, và nó gói gọn trong một dòng output.

src/main/java/com/example/demo/selfcall/ReportService.java
@Service
public class ReportService {
 
    private final ObjectProvider<ReportService> selfProvider;
    private final ReportService lazySelf;
 
    ReportService(ObjectProvider<ReportService> selfProvider, @Lazy ReportService lazySelf) {
        this.selfProvider = selfProvider;
        this.lazySelf = lazySelf;
    }
 
    /** The bug: an internal call runs on the target, so it never re-enters the proxy. */
    public void viaThis() {
        Trace.note("viaThis():            this = " + id(this));
        generate(1);
    }
 
    @Reported
    @Transactional
    public void generate(int n) {
        Trace.note("    generate(" + n + "): transaction active = "
                + TransactionSynchronizationManager.isActualTransactionActive());
    }
 
    public static String id(Object o) {
        return o.getClass().getSimpleName() + "@" + Integer.toHexString(System.identityHashCode(o));
    }
}

@Reported là một annotation tự viết, được một @Before advice khớp và in ra ADVICE RAN, nhờ vậy lần chạy cho thấy advice của AOP và transaction cùng hỏng một lúc. Danh tính của các object là toàn bộ lời giải thích:

Text
== 8. self-invocation
    the bean the caller holds: ReportService$$SpringCGLIB$$1@c247b02
    --- viaThis() ---
    viaThis():            this = ReportService@272c5abd
        generate(1): transaction active = false

Caller giữ ReportService$$SpringCGLIB$$1@c247b02. Bên trong method, thisReportService@272c5abd — một object khác, thuộc class khác, ở địa chỉ khác. generate(1) biên dịch thành this.generate(1), một lời gọi virtual bình thường trên target. Không advice, không transaction, không cảnh báo, không dòng log. Không có gì trong JVM ở vị trí có thể nhận ra chuyện này.

Lời gọi từ ngoài đi qua proxy, lời gọi this bên trong đi tắt, và cách sửa đưa lời gọi nội bộ quay lại qua proxy

Ba cách sửa, có đo

Tự inject chính mình. Hỏi container lấy lại bean; thứ quay về là proxy, vì đó mới là thứ được đăng ký dưới tên đó.

src/main/java/com/example/demo/selfcall/ReportService.java
    /** Fix 1: ask the container for the bean again; what comes back is the proxy. */
    public void viaObjectProvider() {
        Trace.note("viaObjectProvider():  this = " + id(this) + ", self = " + id(selfProvider.getObject()));
        selfProvider.getObject().generate(2);
    }
 
    /** Fix 1b: the same idea with @Lazy on a constructor parameter. */
    public void viaLazySelf() {
        Trace.note("viaLazySelf():        this = " + id(this) + ", self = " + id(lazySelf));
        lazySelf.generate(3);
    }
Text
    --- viaObjectProvider() ---
    viaObjectProvider():  this = ReportService@272c5abd, self = ReportService$$SpringCGLIB$$1@c247b02
        [ReportAspect] ADVICE RAN for generate
        generate(2): transaction active = true
    --- viaLazySelf() ---
    viaLazySelf():        this = ReportService@272c5abd, self = ReportService$$SpringCGLIB$$0@7a85454b
        [ReportAspect] ADVICE RAN for generate
        generate(3): transaction active = true

Cả hai đều chạy, và khác biệt giữa chúng hiện ra ngay ở tên class. ObjectProvider.getObject() trả về chính bean đó, $$SpringCGLIB$$1@c247b02 — đúng object mà caller đang giữ. @Lazy trả về $$SpringCGLIB$$0@7a85454b, một proxy thứ hai được tạo ra để hoãn việc tra cứu, rồi nó mới uỷ quyền xuống proxy thứ nhất. Chạy được, nhưng nó nhét thêm một object và một chặng nữa vào bức tranh vốn đã đủ khó hình dung.

AopContext.currentProxy(). Proxy có thể tự đăng ký vào một ThreadLocal trong suốt lời gọi, nhưng chỉ khi bạn yêu cầu:

src/main/java/com/example/demo/config/ExposeProxyConfig.java
@Configuration
@EnableAspectJAutoProxy(exposeProxy = true) 
public class ExposeProxyConfig {
}

Thiếu annotation đó thì lời gọi hỏng, và hỏng to tiếng, ít ra cũng là thành thật:

Text
    --- viaAopContext() ---
    viaAopContext():      this = ReportService@272c5abd
      threw java.lang.IllegalStateException
      Cannot find current proxy: Set 'exposeProxy' property on Advised to 'true' to make it available, and ensure that AopContext.currentProxy() is invoked in the same thread as the AOP invocation context.

Có nó rồi thì lời gọi nội bộ được advise như mọi lời gọi khác:

Text
    --- viaAopContext() ---
    viaAopContext():      this = ReportService@7fcbc336
        [ReportAspect] ADVICE RAN for generate
        generate(4): transaction active = true

Cái giá không nằm trong đoạn code này: exposeProxy = true là công tắc cho toàn context, khiến mọi lời gọi qua proxy trong ứng dụng phải push và pop một ThreadLocal, và AopContext.currentProxy() chỉ chạy trên đúng thread đã đi vào proxy — đẩy lời gọi sang một executor là nó ném lại đúng IllegalStateException đó. Việc ép kiểu về ReportService cũng trói code vào loại proxy: chạy được trên CGLIB proxy và ném ClassCastException trên JDK proxy.

Chuyển method sang bean khác. Khi đó mọi lời gọi đều đi qua proxy, vì nó là lời gọi tới một object khác:

src/main/java/com/example/demo/selfcall/ReportBatch.java
package com.example.demo.selfcall;
 
import com.example.demo.lab.Trace;
import org.springframework.stereotype.Service;
 
/** Fix 3: the loop lives in another bean, so every call crosses the proxy. */
@Service
public class ReportBatch {
 
    private final ReportService reports;
 
    ReportBatch(ReportService reports) {
        this.reports = reports;
    }
 
    public void run() {
        Trace.note("ReportBatch.run():    injected ReportService = " + ReportService.id(reports));
        reports.generate(5);
    }
}
Text
    --- another bean calls it ---
    ReportBatch.run():    injected ReportService = ReportService$$SpringCGLIB$$1@c247b02
        [ReportAspect] ADVICE RAN for generate
        generate(5): transaction active = true
Cách sửaCái giá phải trảKhi nào hợp lý
bean khácthêm một classmặc định — việc tách ra thường làm thiết kế tốt lên
ObjectProvider<Self>thêm một field, một lời gọi getObject()khi thật sự lặp qua một method có annotation bên trong cùng một service gắn kết
tự inject bằng @Lazythêm một field, cộng một proxy thứ haikhông hơn gì ObjectProvider; hãy chọn cái tường minh hơn
AopContext.currentProxy()một ThreadLocal cho mọi lời gọi trong context, một lần ép kiểu, phụ thuộc threadcode cũ không tái cấu trúc được

Loạt bài này khuyên cách thứ nhất. Một method cần được áp annotation của riêng nó là một method có trách nhiệm riêng, và chuyển nó sang một collaborator nói ra điều đó bằng type system chứ không phải bằng comment. ObjectProvider là lựa chọn thứ hai chấp nhận được khi hai method thật sự thuộc về nhau; exposeProxy là phương án cuối cùng.

Giá phải trả trong thực tế: @Transactional và @Async

Hai annotation hỏng theo kiểu này cũng chính là hai annotation quan trọng nhất, và cách hỏng của chúng khác nhau.

@Transactional hỏng thành transaction active = false ở trên: không transaction nào được mở, nên mỗi lời gọi repository tự mở transaction riêng, dirty checking không ghi gì, và một lỗi sau đó chẳng rollback được gì. Bài Basics 30 đã đo tận SQL nào tới được database và SQL nào không.

@Async hỏng theo kiểu chạy trên chính thread của caller. Đó là mất trắng toàn bộ tính năng, trong im lặng:

Text
    --- @Async through this ---
    asyncViaThis():       caller thread = main
        generateAsync(this): thread = main
    --- @Async through the proxy ---
        generateAsync(another bean): thread = task-1
        generateAsync(proxy): thread = task-2

Qua proxy thì method chạy trên task-1task-2, các thread của executor pool. Qua this thì nó chạy trên main, đồng bộ, tuần tự, chặn caller đúng bằng thời gian nó cần. Một method @Async lẽ ra bắn đi ba email giờ cộng thêm ba vòng round trip mạng vào chính request đã kích hoạt nó, và triệu chứng duy nhất là endpoint chậm đi.

Một proxy tốn bao nhiêu

Hai method trên cùng một bean: add, khớp với một @Around rỗng, và subtract, không khớp advice nào. Mỗi cái gọi 20 triệu lần, bỏ ba vòng khởi động, lấy vòng tốt nhất trong bảy vòng đo, so với lời gọi trực tiếp trên target lấy từ ((Advised) proxy).getTargetSource().getTarget(). Các con số chỉ để tham khảo.

Với CGLIB proxy, mặc định của Boot:

Text
    proxy is com.example.demo.bench.CalculatorService$$SpringCGLIB$$0
    advisors matching add(int,int)      [ExposeInvocationInterceptor, AspectJAroundAdvice]
    advisors matching subtract(int,int) [ExposeInvocationInterceptor]
    direct call on the target         0.24 ns/call
    CGLIB proxy, no aspect advice      44.97 ns/call
    CGLIB proxy, one @Around aspect    65.98 ns/call

và với spring.aop.proxy-target-class=false:

Text
    proxy is jdk.proxy2.$Proxy101
    advisors matching add(int,int)      [ExposeInvocationInterceptor, AspectJAroundAdvice]
    advisors matching subtract(int,int) [ExposeInvocationInterceptor]
    direct call on the target         0.24 ns/call
    JDK proxy, no aspect advice       40.33 ns/call
    JDK proxy, one @Around aspect     65.33 ns/call

Hãy đọc mấy con số này một cách thành thật. Lời gọi trực tiếp là a + b tại một call site monomorphic, thứ mà JIT inline đi gần hết — 0.24 ns không phải một lời gọi method, nó là một lần tăng biến đếm. Vậy nên các con số của proxy gần như là toàn bộ chi phí, chứ không phải phần dôi ra so với một mốc tương đương.

JDK và CGLIB nhanh như nhau. 40 ns so với 45 ns cho phần dispatch, 65 ns so với 66 ns khi có một aspect; một cặp lần chạy trước đó cho 41 và 44. Chênh lệch nằm trong nhiễu giữa các lần chạy. Chọn giữa hai loại là chuyện chúng proxy được cái gì, không bao giờ là chuyện tốc độ.

Phần lớn chi phí nằm ở việc đi qua proxy. Khoảng 40 ns trong số 65 ns là dispatch: box argument vào một Object[], dựng một ReflectiveMethodInvocation, đi hết chuỗi interceptor và gọi target bằng reflection. Advice @Around cộng nốt phần còn lại — 21 ns trên CGLIB proxy, 25 ns trên JDK proxy — chủ yếu là MethodInvocationProceedingJoinPoint mà nó cần. Lưu ý subtract không phải là không có proxy — ExposeInvocationInterceptor mang Pointcut.TRUE và được thêm vào mọi bean mà bất kỳ advisor AspectJ nào chạm tới.

Với một service method thì chuyện này không đáng bận tâm. 65 ns là 0,000065 ms. Một nghìn lời gọi có advice trong một request tốn 0,065 ms; một câu SELECT qua mạng tốn gấp hàng trăm lần. Các trường hợp thật sự đáng lo thì có thật nhưng hẹp: một method bị proxy nằm trong vòng lặp chặt, một lần tra @Cacheable mà nhánh hit chỉ là đọc HashMap, một aspect trên method repository được gọi mỗi dòng dữ liệu. Hãy đo trước khi phỏng đoán; đừng gỡ proxy khỏi service theo nguyên tắc.

Bean này có được advise không, và bởi cái gì?

Mọi Spring AOP proxy đều implement org.springframework.aop.framework.Advised, nên bản thân proxy sẽ khai ra cấu hình của nó. Đây là công cụ debug nên dùng đầu tiên:

src/main/java/com/example/demo/lab/AopLab.java
Object bean = context.getBean(name);
if (bean instanceof Advised advised) {
    Trace.note(name + " -> " + bean.getClass().getSimpleName()
            + ", " + advised.getAdvisors().length + " advisor(s), exposeProxy=" + advised.isExposeProxy());
    for (Advisor a : advised.getAdvisors()) {
        Trace.note("    " + a);
    }
}
Text
    reportService -> ReportService$$SpringCGLIB$$1, 4 advisor(s), exposeProxy=false
        org.springframework.scheduling.annotation.AsyncAnnotationAdvisor@382c90c2
        org.springframework.aop.interceptor.ExposeInvocationInterceptor.ADVISOR
        org.springframework.transaction.interceptor.BeanFactoryTransactionAttributeSourceAdvisor: advice org.springframework.transaction.interceptor.TransactionInterceptor@63cd2cd2
        InstantiationModelAwarePointcutAdvisor: expression [@annotation(com.example.demo.selfcall.Reported)]; advice method [public void com.example.demo.selfcall.ReportAspect.before(org.aspectj.lang.JoinPoint)]; perClauseKind=SINGLETON
    betaService -> BetaService$$SpringCGLIB$$0, 4 advisor(s), exposeProxy=false
        org.springframework.aop.interceptor.ExposeInvocationInterceptor.ADVISOR
        InstantiationModelAwarePointcutAdvisor: expression [within(com.example.demo.designator..*) && args(sku, quantity)]; advice method [public void com.example.demo.designator.DesignatorAspect.byArgs(org.aspectj.lang.JoinPoint,java.lang.String,int)]; perClauseKind=SINGLETON
        InstantiationModelAwarePointcutAdvisor: expression [bean(betaService)]; advice method [public void com.example.demo.designator.DesignatorAspect.byBean(org.aspectj.lang.JoinPoint)]; perClauseKind=SINGLETON
        InstantiationModelAwarePointcutAdvisor: expression [within(com.example.demo.designator..*)]; advice method [public void com.example.demo.designator.DesignatorAspect.byWithin(org.aspectj.lang.JoinPoint)]; perClauseKind=SINGLETON

Mỗi InstantiationModelAwarePointcutAdvisor in ra biểu thức pointcut và đúng method advice của nó, biến "aspect của tôi không chạy" thành "biểu thức của tôi không khớp" chỉ trong một cái liếc.

Một bean không có advisor nào thì đơn giản là không phải proxy, và phép kiểm tra cũng báo luôn điều đó:

Text
    listPriceService: not a proxy (com.example.demo.pricing.ListPriceService)

Advisor gắn theo từng bean; còn chuyện một advisor có áp cho một method cụ thể hay không là câu hỏi riêng, do chính pointcut của advisor trả lời:

src/main/java/com/example/demo/lab/Advisors.java
for (Advisor advisor : advised.getAdvisors()) {
    boolean applies = !(advisor instanceof PointcutAdvisor pointcutAdvisor)
            || (pointcutAdvisor.getPointcut().getClassFilter().matches(targetClass)
                    && pointcutAdvisor.getPointcut().getMethodMatcher().matches(method, targetClass));
    if (applies) {
        names.add(advisor.getAdvice().getClass().getSimpleName());
    }
}
Text
    --- which advisors apply to one method ---
    reportService.generate  [ExposeInvocationInterceptor, TransactionInterceptor, AspectJMethodBeforeAdvice]
    reportService.viaThis   [ExposeInvocationInterceptor]

Khi đến thế vẫn chưa đủ, logging.level.org.springframework.aop=TRACE in ra từng quyết định ngay lúc chúng được đưa ra khi khởi động:

Text
TRACE AnnotationAwareAspectJAutoProxyCreator: Creating implicit proxy for bean 'listPriceService' with 0 common interceptors and 2 specific interceptors
TRACE CglibAopProxy: Creating CGLIB proxy: SingletonTargetSource for target object [com.example.demo.pricing.ListPriceService@6aa792]
DEBUG CglibAopProxy: Final method [public final java.lang.String com.example.demo.pricing.ListPriceService.describe()] cannot get proxied via CGLIB: Calls to this method will NOT be routed to the target instance and might lead to NPEs against uninitialized fields in the proxy instance.
TRACE CglibAopProxy: Unable to apply any optimizations to advised method: public java.math.BigDecimal com.example.demo.pricing.ListPriceService.quote(java.lang.String,int)
TRACE AnnotationAwareAspectJAutoProxyCreator: Did not attempt to auto-proxy infrastructure class [org.springframework.transaction.interceptor.TransactionInterceptor]

Nó rất dài dòng — riêng CglibAopProxy đã 320 dòng trên ứng dụng nhỏ này — nên chỉ bật khi phép kiểm tra qua Advised chưa trả lời được câu hỏi.

Vượt qua proxy: weaving, aspect trên aspect, và Micrometer

Ba thứ đóng khung chủ đề này, mỗi thứ một câu.

AspectJ weaving là cách vượt qua mọi giới hạn trong bài này. Weaving lúc compile hoặc lúc load sẽ viết lại bytecode của chính class thay vì bọc nó, nên method final, method private, constructor, truy cập field và cả self-invocation đều advise được — đổi lại là một bước weaving trong build hoặc một -javaagent trên dòng lệnh, và đó là lý do gần như không ai chọn nó.

Bản thân aspect thì không bị advise. AnnotationAwareAspectJAutoProxyCreator bỏ qua các bean @Aspect, cũng như các advisor và interceptor mà nó xếp vào loại infrastructure — vết chạy lúc khởi động có dòng Creating implicit proxy for bean cho cả chín service của ứng dụng và không có dòng nào cho bất kỳ aspect nào, nên một aspect không thể vô tình advise chính nó thành vòng lặp.

Phần lớn aspect đáng có thì đã có người viết rồi. io.micrometer.core.aop.TimedAspect trong micrometer-coreio.micrometer.observation.aop.ObservedAspect trong micrometer-observation là các class @Aspect bình thường; đăng ký một trong hai thành bean là bạn có timer, trace và metric trên mọi method mang @Timed hoặc @Observed, được kiểm thử kỹ hơn cái timing aspect mà bạn định tự viết.

FAQ

Vì sao không tìm thấy spring-boot-starter-aop trên Spring Boot 4?

Vì nó không còn tồn tại. Nó vắng mặt trong spring-boot-dependencies-4.1.1.pom, bản cuối cùng công bố trên Maven Central là 4.0.0-M2, và metadata 4.1.1 của Spring Initializr không có mục AOP hay AspectJ nào — hỏi dependencies=aop thì nhận 400 Unknown dependency 'aop' check project metadata. Hãy dùng org.springframework.boot:spring-boot-starter-aspectj (và spring-boot-starter-aspectj-test cho test), nó resolve ra 4.1.1 và kéo theo spring-aop 7.0.9 cùng org.aspectj:aspectjweaver 1.9.25.1.

Spring dùng JDK proxy hay CGLIB proxy?

Trên Spring Boot là CGLIB, kể cả khi target có implement interface: spring.aop.proxy-target-class mặc định true, và class của bean đo được là ListPriceService$$SpringCGLIB$$0. Đặt property thành false thì ra jdk.proxy2.$Proxy103, thứ chỉ implement PriceService nên không inject vào, cũng không ép kiểu về, implementation class được. Spring Framework thuần không có Boot thì mặc định dùng JDK proxy khi có interface, và truyền miệng bắt nguồn từ đó.

Vì sao @Transactional không chạy khi tôi gọi method từ cùng một class?

Vì annotation được hiện thực bởi proxy, mà lời gọi nội bộ không bao giờ tới được proxy. Đo ở đây: caller giữ ReportService$$SpringCGLIB$$1@c247b02 còn this bên trong method là ReportService@272c5abd, nên this.generate(1) là lời gọi virtual bình thường trên target — transaction active = false, và advice của AOP cũng không chạy. Điều tương tự đúng với @Async, @Cacheable, @Retryable và mọi annotation dựa trên proxy khác.

Spring AOP có advise được method final, private hay package-private không?

Method package-private và protected thì có — cả hai đều có advice trong lần chạy này, vì CGLIB subclass được sinh ra trong cùng package và cùng class loader nên override được chúng. Method finalprivate thì không: subclass không override được cái nào, nên advice bị bỏ qua trong im lặng. Method final là ca tệ hơn, vì khi đó nó chạy trên chính proxy instance, nơi Objenesis chưa bao giờ khởi tạo field — kết quả đo được là NullPointerException: Cannot invoke "java.util.Map.size()" because "this.catalog" is null.

@Before, @Around, @After và @AfterReturning chạy theo thứ tự nào?

Đo trên Spring Framework 7.0.9: @Around cho tới proceed(), rồi @Before, rồi target method, rồi @AfterReturning (hoặc @AfterThrowing), rồi @After, và chỉ sau đó mới tới phần còn lại của @Around. Chỗ người ta hay nhầm là hai bước cuối: @After chạy trước khi @Around chạy tiếp, đây là ngữ nghĩa "after finally advice" của AspectJ và là chỗ các sơ đồ cũ hay vẽ ngược.

Làm sao điều khiển thứ tự của nhiều aspect?

Đặt @Order lên class aspect, hoặc implement Ordered. Giá trị nhỏ nhất nằm ngoài cùng — đo được là @Order(10), rồi @Order(20), rồi target, và tháo ngược lại. Aspect không có order bị coi như LOWEST_PRECEDENCE nên nằm trong cùng, sát target, còn hai aspect cùng không có order thì giữa chúng không có thứ tự xác định. Lưu ý transaction advisor cũng nằm ở LOWEST_PRECEDENCE, nên bất kỳ aspect nào bạn đánh order tường minh đều chạy bên ngoài transaction.

Spring AOP có chậm tới mức đáng lo không?

Không, với bất cứ thứ gì có I/O. Đo trên 20 triệu lời gọi, lấy vòng tốt nhất trong bảy vòng đã khởi động: 0,24 ns cho lời gọi trực tiếp đã bị JIT inline mất, 44,97 ns qua CGLIB proxy không có advice của aspect, 65,98 ns khi có một @Around; con số của JDK proxy là 40,33 và 65,33, bằng nhau trong phạm vi nhiễu. Tức 0,065 ms cho mỗi nghìn lời gọi có advice, so với hàng trăm micro giây cho một vòng round trip tới database. Chỉ lo khi một method bị proxy nằm trong vòng lặp chặt.

spring.aop.auto=false thật sự tắt cái gì?

Aspect, và chỉ aspect. Khi bật property này, AopAutoConfiguration bị bỏ qua nên @EnableAspectJAutoProxy không bao giờ được áp, và auto-proxy creator lùi về InfrastructureAdvisorAutoProxyCreator — đo được là listPriceService quay về class thuần không có advisor nào, còn reportService vẫn là CGLIB proxy mang AsyncAnnotationAdvisorTransactionInterceptor. Nghĩa là @Transactional@Async vẫn chạy còn mọi @Aspect trong ứng dụng ngừng hoạt động, mà không có lấy một cảnh báo.

Kết luận

Mọi thứ trong Spring AOP đều bắt nguồn từ một phép tráo: AnnotationAwareAspectJAutoProxyCreator trả về một proxy ở đúng chỗ bean của bạn từng đứng, và advice sống trên proxy. Boot dựng proxy đó thành CGLIB subclass theo mặc định — ListPriceService$$SpringCGLIB$$0 chứ không phải jdk.proxy2.$Proxy103 — nhờ vậy inject theo implementation class vẫn chạy, và cũng vì vậy mà một class final làm chết ứng dụng trong khi một method final lặng lẽ mất advice rồi ném NullPointerException vào các field mà Objenesis chưa bao giờ khởi tạo. Trên Boot 4.1.1 phần đóng gói cũng đổi: spring-boot-starter-aop biến mất, spring-boot-starter-aspectj thay chỗ, và trên ứng dụng JPA thì AspectJ đã nằm sẵn trên classpath trước khi bạn kịp yêu cầu.

Bản thân aspect là phần dễ — @Aspect@Component, một named @Pointcut, và năm loại advice với thứ tự đo được đặt @Around ngoài cùng và @After trước khi @Around chạy tiếp. Phần khó là cái ranh giới: lời gọi nội bộ là lời gọi trên this, this chính là target, và target thì không có advice nào trên nó. Điều đó được chứng minh ở đây bằng hai danh tính in cạnh nhau, kèm transaction active = false và một method @Async chạy trên main. Hãy chuyển method sang bean khác; dùng ObjectProvider của chính mình khi hai method thật sự thuộc về nhau; để dành exposeProxy cho code bạn không sửa được. Và khi một aspect không chạy, hãy hỏi chính proxy: Advised#getAdvisors in ra mọi biểu thức pointcut và method advice đang gắn vào bean.

Bài tiếp theo vẫn ở trong container nhưng bỏ hẳn proxy: cơ chế event của chính Spring — ApplicationEventPublisher, @EventListener, và @TransactionalEventListener cho công việc chỉ được phép chạy sau khi transaction đã commit.

Bài viết liên quan

[Advanced Spring Boot] Caching trong Spring Boot: cache abstraction, Caffeine, Redis và chiến lược invalidation

Caching trong Spring Boot 4.1.1 trên một lookup sản phẩm với PostgreSQL và Redis: vì sao vẫn cần @EnableCaching, @Cacheable, @CachePut, @CacheEvict với allEntries và @Caching, key SimpleKey và SpEL, condition, unless và null từ Optional được cache, LazyInitializationException do cache entity, eviction của Caffeine và metric cache.gets, vì sao Boot chọn Redis thay vì Caffeine, JDK serialization so với GenericJacksonJsonRedisSerializer cho Jackson 3 và type validator từ chối BigDecimal, hai cách cấu hình serializer âm thầm làm mất setting, latency khi không cache, với Caffeine và với Redis, cache stampede mà sync = true không chặn được trên Redis, race evict-trước-commit và transactionAware(), đọc dữ liệu cũ giữa hai instance, và request ra sao khi Redis dừng hoặc treo, có và không có CacheErrorHandler.

[Advanced Spring Boot] Spring Events: ApplicationEventPublisher, @EventListener và @TransactionalEventListener

Spring event trên Spring Boot 4.1.1: publish một record qua ApplicationEventPublisher và lớp bọc PayloadApplicationEvent, bằng chứng @EventListener chạy trên thread và transaction của caller, @Order cùng condition SpEL, listener trả về event khác, vì sao listener cho generic OrderEvent<Product> không bao giờ nhận được gì nếu thiếu ResolvableTypeProvider, listener throw exception khi sync và khi @Async, đủ bốn phase của @TransactionalEventListener với log của từng lần chạy, fallbackExecution, cái bẫy ghi database trong AFTER_COMMIT làm mất row và REQUIRES_NEW cứu nó, @Async so với applicationEventMulticaster async, và chỗ event in-process hết là một cơ chế đáng tin.

[Advanced Spring Boot] Multi-tenancy và soft delete với Spring Boot và Hibernate

Multi-tenancy và soft delete trên Spring Boot 4.1.1 với Hibernate và PostgreSQL: filter đọc X-Tenant-Id với ThreadLocal bị rò rỉ trên thread Tomcat được tái sử dụng và bị mất trên @Async, discriminator column với @TenantId và CurrentTenantIdentifierResolver (predicate tenant trên find, JPQL, derived query, Specification và bulk update, không có trên native SQL hay JdbcClient), schema per tenant với MultiTenantConnectionProvider, bẫy tái sử dụng connection giữa setSchema và SET search_path trên HikariCP, Flyway cho từng tenant schema và TenantSchemaMapper của Hibernate, database per tenant với 100 connection cho mười tenant, row-level security của PostgreSQL với set_config và FORCE, các strategy của @SoftDelete so với @SQLDelete và @SQLRestriction, lỗi to-one LAZY, partial unique index cho SKU đã soft delete, và khôi phục các dòng đã xóa.

[Advanced Spring Boot] NoSQL với Spring Data: MongoDB và Redis

Spring Data MongoDB và Spring Data Redis trên Spring Boot 4.1.1: @Document, id là String hay ObjectId, field _class, embedded hay @DocumentReference cùng các query mỗi cách gửi đi, MongoRepository và MongoTemplate kèm command đã log, $push/$inc so với load-modify-save dưới 50 thread, @Version, Boot có tạo index từ @Indexed không, COLLSCAN và IXSCAN trên 300.000 document, một aggregation pipeline, transaction trên replica set, một field bị đổi tên, serializer của RedisTemplate, INCR, sorted set, hash, TTL, key của @RedisHash và phantom key, pipelining và Lettuce.