SSM - Spring_day03 - 4.AOP配置管理

4,AOP配置管理

4.1 AOP切入点表达式

前面的案例中,有涉及到如下内容:

对于AOP中切入点表达式,我们总共会学习三个内容,分别是语法格式通配符书写技巧

4.1.1 语法格式

首先我们先要明确两个概念:

  • 切入点:要进行增强的方法

  • 切入点表达式:要进行增强的方法的描述方式

对于切入点的描述,我们其实是有两中方式的,先来看下前面的例子

描述方式一:执行com.itheima.dao包下的BookDao接口中的无参数update方法

execution(void com.itheima.dao.BookDao.update())

描述方式二:执行com.itheima.dao.impl包下的BookDaoImpl类中的无参数update方法

execution(void com.itheima.dao.impl.BookDaoImpl.update())

因为调用接口方法的时候最终运行的还是其实现类的方法,所以上面两种描述方式都是可以的。

对于切入点表达式的语法为:

  • 切入点表达式标准格式:动作关键字(访问修饰符 返回值 包名.类/接口名.方法名(参数) 异常名)

对于这个格式,我们不需要硬记,通过一个例子,理解它:

execution(public User com.itheima.service.UserService.findById(int))
  • execution:动作关键字,描述切入点的行为动作,例如execution表示执行到指定切入点

  • public:访问修饰符,还可以是public,private等,可以省略

  • User:返回值,写返回值类型

  • com.itheima.service:包名,多级包使用点连接

  • UserService:类/接口名称

  • findById:方法名

  • int:参数,直接写参数的类型,多个类型用逗号隔开

  • 异常名:方法定义中抛出指定异常,可以省略

切入点表达式就是要找到需要增强的方法,所以它就是对一个具体方法的描述,但是方法的定义会有很多,所以如果每一个方法对应一个切入点表达式,想想这块就会觉得将来编写起来会比较麻烦,有没有更简单的方式呢?

就需要用到下面所学习的通配符。

🧠 理论理解
切入点表达式是AOP最核心的“定位器”,它本质上是一套描述方法签名的语法规则,用来精准指定哪些方法会被增强。
它基于execution()这种结构,描述方法的访问修饰符、返回类型、包路径、类名、方法名和参数列表等。
为什么有两种方式(接口 vs 实现类)?因为Java动态代理通常基于接口,而CGLIB基于实现类,所以两种都能生效

🏢 企业实战理解
🔹 阿里巴巴:在电商业务中,大量使用 execution(* com.alibaba.xxx.service.*Service.*(..)),对所有业务服务类的方法做性能统计和异常捕获。
🔹 字节跳动:微服务场景下,接口粒度很小,字节强调“精准拦截”机制,表达式往往写得非常细,以免误伤其他模块。
🔹 Google:在使用Spring Boot构建中小型后端时,表达式里会约定包级别,防止框架类被误拦截,比如 execution(* com.google.cloud.xxx..*ServiceImpl.*(..))
🔹 OpenAI:早期API平台通过切入点表达式锁定数据校验的层面,保护AI接口不被非法请求攻击。

 

4.1.2 通配符

我们使用通配符描述切入点,主要的目的就是简化之前的配置,具体都有哪些通配符可以使用?

  • *:单个独立的任意符号,可以独立出现,也可以作为前缀或者后缀的匹配符出现

    execution(public * com.itheima.*.UserService.find*(*))

    匹配com.itheima包下的任意包中的UserService类或接口中所有find开头的带有一个参数的方法

  • ..:多个连续的任意符号,可以独立出现,常用于简化包名与参数的书写

    execution(public User com..UserService.findById(..))

    匹配com包下的任意包中的UserService类或接口中所有名称为findById的方法

  • +:专用于匹配子类类型

    execution(* *..*Service+.*(..))

    这个使用率较低,描述子类的,咱们做JavaEE开发,继承机会就一次,使用都很慎重,所以很少用它。*Service+,表示所有以Service结尾的接口的子类。

接下来,我们把案例中使用到的切入点表达式来分析下:

execution(void com.itheima.dao.BookDao.update())
匹配接口,能匹配到
execution(void com.itheima.dao.impl.BookDaoImpl.update())
匹配实现类,能匹配到
execution(* com.itheima.dao.impl.BookDaoImpl.update())
返回值任意,能匹配到
execution(* com.itheima.dao.impl.BookDaoImpl.update(*))
返回值任意,但是update方法必须要有一个参数,无法匹配,要想匹配需要在update接口和实现类添加参数
execution(void com.*.*.*.*.update())
返回值为void,com包下的任意包三层包下的任意类的update方法,匹配到的是实现类,能匹配
execution(void com.*.*.*.update())
返回值为void,com包下的任意两层包下的任意类的update方法,匹配到的是接口,能匹配
execution(void *..update())
返回值为void,方法名是update的任意包下的任意类,能匹配
execution(* *..*(..))
匹配项目中任意类的任意方法,能匹配,但是不建议使用这种方式,影响范围广
execution(* *..u*(..))
匹配项目中任意包任意类下只要以u开头的方法,update方法能满足,能匹配
execution(* *..*e(..))
匹配项目中任意包任意类下只要以e结尾的方法,update和save方法能满足,能匹配
execution(void com..*())
返回值为void,com包下的任意包任意类任意方法,能匹配,*代表的是方法
execution(* com.itheima.*.*Service.find*(..))
将项目中所有业务层方法的以find开头的方法匹配
execution(* com.itheima.*.*Service.save*(..))
将项目中所有业务层方法的以save开头的方法匹配

后面两种更符合我们平常切入点表达式的编写规则

 

🧠 理论理解
通配符是为了让切入点“从繁到简”,本质上是匹配一类方法,而不是某一个方法。* 匹配一个片段,.. 匹配多个包层或参数。它提升了切入点的复用性,但同时也带来匹配过宽的风险
重点理解:包路径、类名、方法名和参数列表都可以用通配符去适配。

🏢 企业实战理解
🔹 阿里:为应对“双11”的高并发,阿里在切入点设计上非常重视“批量监控”,通常会写 execution(* com.taobao.*.*Service.*(..)) 监控某类接口性能。
🔹 字节跳动:做灰度发布时,字节有专门的“切面”来监控灰度流量,用 ..Gray*Service.*(..) 这种表达式抓灰度逻辑。
🔹 NVIDIA:训练平台中,针对日志切面使用 execution(* *..LogService.*(..)) 自动捕获训练日志,保证GPU任务的可追溯性。
🔹 OpenAI:为降低误报,OpenAI会将切入点严格限制为公开API层,避免后台任务执行时的无意义增强。

4.1.3 书写技巧

对于切入点表达式的编写其实是很灵活的,那么在编写的时候,有没有什么好的技巧让我们用用:

  • 所有代码按照标准规范开发,否则以下技巧全部失效

  • 描述切入点通==常描述接口==,而不描述实现类,如果描述到实现类,就出现紧耦合了

  • 访问控制修饰符针对接口开发均采用public描述(==可省略访问控制修饰符描述==

  • 返回值类型对于增删改类使用精准类型加速匹配,对于查询类使用*通配快速描述

  • ==包名==书写==尽量不使用..匹配==,效率过低,常用*做单个包描述匹配,或精准匹配

  • ==接口名/类名==书写名称与模块相关的==采用*匹配==,例如UserService书写成*Service,绑定业务层接口名

  • ==方法名==书写以==动词==进行==精准匹配==,名词采用匹配,例如getById书写成getBy,selectAll书写成selectAll

  • 参数规则较为复杂,根据业务方法灵活调整

  • 通常==不使用异常==作为==匹配==规则

🧠 理论理解
AOP切入点写得好不好,直接影响系统的“增强范围”与“性能”。实战中常见技巧包括:

  • 小步迭代(从小范围慢慢扩大测试)

  • 包粒度明确(一般控制到servicecontroller层)

  • 命名约定(方法名尽量保持有规律,方便写切入点)

  • 避免全局匹配(如 execution(* *..*.*(..)) 这种会拖垮性能)

🏢 企业实战理解
🔹 美团:上线新AOP功能时,通常会先小范围切入几个核心业务线,用“分层测试+灰度放量”验证切入点表达式的安全性。
🔹 腾讯云:要求所有切入点表达式必须过安全审核,防止“全局匹配”意外引发线上性能问题。
🔹 谷歌:工程团队要求所有AOP表达式必须配备注释,解释匹配范围及原因,避免二次开发时误用。

 

面试题 1:什么是切入点表达式?它的作用是什么?
👉 答:切入点表达式是 Spring AOP 中用于匹配目标方法的表达式。它的作用是精确定位哪些方法需要被增强,确定“切面”要织入的位置。比如 execution(* com.xx.service.*Service.*(..)) 就是典型的切入点表达式,用于匹配所有 Service 层的方法。

面试题 2:切入点表达式中 execution() 的完整语法结构是什么?
👉 答:完整格式是:
execution(访问修饰符 返回值类型 方法的全限定名(参数列表) throws 异常类型),不过访问修饰符和异常类型都可以省略。比如:
execution(public void com.xxx.dao.UserDao.save())。大厂常要求能灵活拆解这几个部分。

面试题 3:execution 和 within、this、target 区别?
👉 答:

  • execution:匹配方法签名(最常用)

  • within:匹配某个类或包下的所有方法

  • this:匹配代理对象的类型

  • target:匹配目标对象的类型
    BAT 面试经常会问“execution 匹配的是静态还是动态类型?”,标准答案是它是静态匹配,而 thistarget动态匹配

面试题 4:你实际开发中如何优化切入点表达式的可维护性?
👉 答:推荐将切入点抽离到单独的方法上,例如 @Pointcut("execution(...)") 然后通过 pt() 引用,避免硬编码、提升可读性。字节跳动、阿里内部也有切入点表达式的标准规范库。

场景题 1:
你在字节跳动负责一个音视频内容审核平台,项目中有 20 多个微服务,包含内容发布、评论审核、用户举报等多个接口。领导要求你实现一个日志埋点系统,对所有以 Service 结尾的接口层方法做统一埋点,同时要求日志中标明是哪个模块的服务。请问你会如何设计切入点表达式?切入点要支持未来扩展到其他包。

答题要点:

  • 切入点表达式需通用、可维护

  • 推荐写法:execution(* com.bytedance.*.service.*Service.*(..))

  • 推荐再抽取 @Pointcut,命名如 logPointcut()

  • 强调可以扩展到 com.bytedance.content.servicecom.bytedance.user.service 等子包

  • 在项目中定义公共模块,如 LogAspect,实现对所有 Service 方法的自动日志记录

  • 结合字节跳动内部做法,会配合灰度发布,在上线新切面时用 feature toggle 动态开关控制

 

场景题 1 回答:

这个场景很典型,字节跳动这种大厂的微服务架构里,接口层往往非常多,所以“如何用一个表达式搞定一大片”是核心。

我会首先抽象一个 Pointcut,比如叫 @Pointcut("execution(* com.bytedance.*.service.*Service.*(..))"),它表示匹配 com.bytedance 下各个模块包里的 *Service 结尾的所有类的方法,比如 ContentServiceUserService 等。

举例:

@Pointcut("execution(* com.bytedance.*.service.*Service.*(..))")
private void logPointcut() {}

这样定义的好处是,如果未来新模块比如 auditfeedback 这些包也有 Service 类,不用改切面,自动生效。

企业实战补充:
在字节这种超大规模公司,实际还会结合注解,例如用 @AnnotationPointcut 来“标记式”扫描,比如 @LoggableService,防止无关方法误匹配。另外,灰度发布时会用 feature toggle(开关)动态启用/关闭埋点。

总结:切入点表达式一定要通用+可扩展+安全,一开始就设计好规范非常重要。

 

4.2 AOP通知类型

前面的案例中,有涉及到如下内容:

它所代表的含义是将通知添加到切入点方法执行的==前面==。

除了这个注解外,还有没有其他的注解,换个问题就是除了可以在前面加,能不能在其他的地方加?

🧠 理论理解
五大通知类型其实就是AOP对“方法生命周期”的精准控制:

  • @Before → 方法前

  • @After → 方法后(无论成功或失败)

  • @AfterReturning → 方法正常返回后

  • @AfterThrowing → 方法异常后

  • @Around → 包裹整个方法,掌控一切

它们本质上是代码增强的插入点,结合使用可实现事务控制、权限校验、日志监控等功能。

🏢 企业实战理解
🔹 阿里巴巴:订单服务里,@AfterReturning 常用来采集下单成功后的埋点数据;@AfterThrowing 则用来自动捕获失败的下单事件并报警。
🔹 字节跳动:飞书用 @Around 做多租户权限校验,确保每个接口都经过租户身份验证。
🔹 Google:GCP监控中用 @After 通知采集请求响应时间,作为SLI/SLO的重要数据来源。
🔹 OpenAI:ChatGPT API中用 @Before 检查请求签名是否合法,用 @AfterReturning 打埋点记录API调用量。

 

4.2.1 类型介绍

我们先来回顾下AOP通知:

  • AOP通知描述了抽取的共性功能,根据共性功能抽取的位置不同,最终运行代码时要将其加入到合理的位置

通知具体要添加到切入点的哪里?

共提供了5种通知类型:

  • 前置通知

  • 后置通知

  • ==环绕通知(重点)==

  • 返回后通知(了解)

  • 抛出异常后通知(了解)

为了更好的理解这几种通知类型,我们来看一张图

(1)前置通知,追加功能到方法执行前,类似于在代码1或者代码2添加内容

(2)后置通知,追加功能到方法执行后,不管方法执行的过程中有没有抛出异常都会执行,类似于在代码5添加内容

(3)返回后通知,追加功能到方法执行后,只有方法正常执行结束后才进行,类似于在代码3添加内容,如果方法执行抛出异常,返回后通知将不会被添加

(4)抛出异常后通知,追加功能到方法抛出异常后,只有方法执行出异常才进行,类似于在代码4添加内容,只有方法抛出异常后才会被添加

(5)环绕通知,环绕通知功能比较强大,它可以追加功能到方法执行的前后,这也是比较常用的方式,它可以实现其他四种通知类型的功能,具体是如何实现的,需要我们往下学习。

 

4.2.2 环境准备
  • 创建一个Maven项目

  • pom.xml添加Spring依赖

    <dependencies>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-context</artifactId>
            <version>5.2.10.RELEASE</version>
        </dependency>
        <dependency>
          <groupId>org.aspectj</groupId>
          <artifactId>aspectjweaver</artifactId>
          <version>1.9.4</version>
        </dependency>
    </dependencies>

  • 添加BookDao和BookDaoImpl类

    public interface BookDao {
        public void update();
        public int select();
    }
    ​
    @Repository
    public class BookDaoImpl implements BookDao {
        public void update(){
            System.out.println("book dao update ...");
        }
        public int select() {
            System.out.println("book dao select is running ...");
            return 100;
        }
    }

  • 创建Spring的配置类

    @Configuration
    @ComponentScan("com.itheima")
    @EnableAspectJAutoProxy
    public class SpringConfig {
    }

  • 创建通知类

    @Component
    @Aspect
    public class MyAdvice {
        @Pointcut("execution(void com.itheima.dao.BookDao.update())")
        private void pt(){}
    ​
        public void before() {
            System.out.println("before advice ...");
        }
    ​
        public void after() {
            System.out.println("after advice ...");
        }
    ​
        public void around(){
            System.out.println("around before advice ...");
            System.out.println("around after advice ...");
        }
    ​
        public void afterReturning() {
            System.out.println("afterReturning advice ...");
        }
        
        public void afterThrowing() {
            System.out.println("afterThrowing advice ...");
        }
    }

  • 编写App运行类

    public class App {
        public static void main(String[] args) {
            ApplicationContext ctx = new AnnotationConfigApplicationContext(SpringConfig.class);
            BookDao bookDao = ctx.getBean(BookDao.class);
            bookDao.update();
        }
    }

最终创建好的项目结构如下:

🧠 理论理解
AOP依赖Spring上下文管理Bean和AspectJ实现织入,核心是 @EnableAspectJAutoProxy 开启动态代理,以及 aspectjweaver 支持运行时增强。
Spring AOP默认用JDK动态代理(基于接口),若无接口则用CGLIB。

🏢 企业实战理解
🔹 美团:所有微服务都默认开启AOP配置,并内置标准化的project-advice切面模块。
🔹 腾讯:每个服务启动时会检测切面是否生效,避免线上因配置错误而失效。
🔹 字节跳动:内部AOP开发模板里已预置依赖、切点扫描策略,减少开发者重复配置的成本。
🔹 Google:推荐通过Spring Boot Starter的方式封装AOP模块,提高跨项目复用性。

 

 

4.2.3 通知类型的使用
前置通知

修改MyAdvice,在before方法上添加@Before注解

@Component
@Aspect
public class MyAdvice {
    @Pointcut("execution(void com.itheima.dao.BookDao.update())")
    private void pt(){}
    
    @Before("pt()")
    //此处也可以写成 @Before("MyAdvice.pt()"),不建议
    public void before() {
        System.out.println("before advice ...");
    }
}

 

后置通知
@Component
@Aspect
public class MyAdvice {
    @Pointcut("execution(void com.itheima.dao.BookDao.update())")
    private void pt(){}
    
    @Before("pt()")
    public void before() {
        System.out.println("before advice ...");
    }
    @After("pt()")
    public void after() {
        System.out.println("after advice ...");
    }
}

 

环绕通知
基本使用
 
@Component
@Aspect
public class MyAdvice {
    @Pointcut("execution(void com.itheima.dao.BookDao.update())")
    private void pt(){}
    
    @Around("pt()")
    public void around(){
        System.out.println("around before advice ...");
        System.out.println("around after advice ...");
    }
}
 

运行结果中,通知的内容打印出来,但是原始方法的内容却没有被执行。

因为环绕通知需要在原始方法的前后进行增强,所以环绕通知就必须要能对原始操作进行调用,具体如何实现?

@Component
@Aspect
public class MyAdvice {
    @Pointcut("execution(void com.itheima.dao.BookDao.update())")
    private void pt(){}
    
    @Around("pt()")
    public void around(ProceedingJoinPoint pjp) throws Throwable{
        System.out.println("around before advice ...");
        //表示对原始操作的调用
        pjp.proceed();
        System.out.println("around after advice ...");
    }
}

说明:proceed()为什么要抛出异常?

原因很简单,看下源码就知道了

再次运行,程序可以看到原始方法已经被执行了

注意事项

(1)原始方法有返回值的处理

  • 修改MyAdvice,对BookDao中的select方法添加环绕通知,

@Component
@Aspect
public class MyAdvice {
    @Pointcut("execution(void com.itheima.dao.BookDao.update())")
    private void pt(){}
    
    @Pointcut("execution(int com.itheima.dao.BookDao.select())")
    private void pt2(){}
    
    @Around("pt2()")
    public void aroundSelect(ProceedingJoinPoint pjp) throws Throwable {
        System.out.println("around before advice ...");
        //表示对原始操作的调用
        pjp.proceed();
        System.out.println("around after advice ...");
    }
}
  • 修改App类,调用select方法

public class App {
    public static void main(String[] args) {
        ApplicationContext ctx = new AnnotationConfigApplicationContext(SpringConfig.class);
        BookDao bookDao = ctx.getBean(BookDao.class);
        int num = bookDao.select();
        System.out.println(num);
    }
}

运行后会报错,错误内容为:

Exception in thread "main" org.springframework.aop.AopInvocationException: ==Null return value from advice does not match primitive return type for: public abstract int com.itheima.dao.BookDao.select()== at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:226) at com.sun.proxy.$Proxy19.select(Unknown Source) at com.itheima.App.main(App.java:12)

错误大概的意思是:空的返回不匹配原始方法的int返回

  • void就是返回Null

  • 原始方法就是BookDao下的select方法

所以如果我们使用环绕通知的话,要根据原始方法的返回值来设置环绕通知的返回值,具体解决方案为:

@Component
@Aspect
public class MyAdvice {
    @Pointcut("execution(void com.itheima.dao.BookDao.update())")
    private void pt(){}
    
    @Pointcut("execution(int com.itheima.dao.BookDao.select())")
    private void pt2(){}
    
    @Around("pt2()")
    public Object aroundSelect(ProceedingJoinPoint pjp) throws Throwable {
        System.out.println("around before advice ...");
        //表示对原始操作的调用
        Object ret = pjp.proceed();
        System.out.println("around after advice ...");
        return ret;
    }
}

说明:

为什么返回的是Object而不是int的主要原因是Object类型更通用。

在环绕通知中是可以对原始方法返回值就行修改的。

返回后通知
@Component
@Aspect
public class MyAdvice {
    @Pointcut("execution(void com.itheima.dao.BookDao.update())")
    private void pt(){}
    
    @Pointcut("execution(int com.itheima.dao.BookDao.select())")
    private void pt2(){}
    
    @AfterReturning("pt2()")
    public void afterReturning() {
        System.out.println("afterReturning advice ...");
    }
}

注意:返回后通知是需要在原始方法select正常执行后才会被执行,如果select()方法执行的过程中出现了异常,那么返回后通知是不会被执行。后置通知是不管原始方法有没有抛出异常都会被执行。这个案例大家下去可以自己练习验证下。

异常后通知
@Component
@Aspect
public class MyAdvice {
    @Pointcut("execution(void com.itheima.dao.BookDao.update())")
    private void pt(){}
    
    @Pointcut("execution(int com.itheima.dao.BookDao.select())")
    private void pt2(){}
    
    @AfterReturning("pt2()")
    public void afterThrowing() {
        System.out.println("afterThrowing advice ...");
    }
}

 

注意:异常后通知是需要原始方法抛出异常,可以在select()方法中添加一行代码int i = 1/0即可。如果没有抛异常,异常后通知将不会被执行。

学习完这5种通知类型,我们来思考下环绕通知是如何实现其他通知类型的功能的?

因为环绕通知是可以控制原始方法执行的,所以我们把增强的代码写在调用原始方法的不同位置就可以实现不同的通知类型的功能,如:

🧠 理论理解
环绕通知是最强大的通知类型,因为它可完全掌控方法的执行顺序,能模拟其他四种通知。其他通知则是语义化的简写,方便针对特定场景使用。
需要特别注意环绕通知的 pjp.proceed() 调用,否则增强会“吃掉”原始方法的执行。

🏢 企业实战理解
🔹 美团外卖:环绕通知实现“接口慢调用报警”功能,用环绕记录开始/结束时间,发现慢接口即告警。
🔹 字节跳动:字节内部框架强调“前置校验 + 环绕性能监控 + 后置埋点”三位一体的通知组合模式。
🔹 OpenAI:早期API流控通过环绕通知实现限流逻辑,并结合 AfterThrowing 捕获异常请求。

 

通知类型总结
知识点1:@After
名称@After
类型方法注解
位置通知方法定义上方
作用设置当前通知方法与切入点之间的绑定关系,当前通知方法在原始切入点方法后运行
知识点2:@AfterReturning
名称@AfterReturning
类型方法注解
位置通知方法定义上方
作用设置当前通知方法与切入点之间绑定关系,当前通知方法在原始切入点方法正常执行完毕后执行
知识点3:@AfterThrowing
名称@AfterThrowing
类型方法注解
位置通知方法定义上方
作用设置当前通知方法与切入点之间绑定关系,当前通知方法在原始切入点方法运行抛出异常后执行
知识点4:@Around
名称@Around
类型方法注解
位置通知方法定义上方
作用设置当前通知方法与切入点之间的绑定关系,当前通知方法在原始切入点方法前后运行

==环绕通知注意事项==

  1. 环绕通知必须依赖形参ProceedingJoinPoint才能实现对原始方法的调用,进而实现原始方法调用前后同时添加通知

  2. 通知中如果未使用ProceedingJoinPoint对原始方法进行调用将跳过原始方法的执行

  3. 对原始方法的调用可以不接收返回值,通知方法设置成void即可,如果接收返回值,最好设定为Object类型

  4. 原始方法的返回值如果是void类型,通知方法的返回值类型可以设置成void,也可以设置成Object

  5. 由于无法预知原始方法运行后是否会抛出异常,因此环绕通知方法必须要处理Throwable异常

介绍完这么多种通知类型,具体该选哪一种呢?

我们可以通过一些案例加深下对通知类型的学习。

面试题 1:Spring AOP 提供哪几种通知类型?
👉 答:一共有 5 种:

  • @Before 前置通知

  • @After 后置通知(无论是否异常都会执行)

  • @AfterReturning 返回后通知

  • @AfterThrowing 异常后通知

  • @Around 环绕通知(功能最强大,可取代其他四种)

面试题 2:@After 和 @AfterReturning 的区别?
👉 答:@After 是类似于 finally 块的效果——无论是否抛异常都会执行。而 @AfterReturning 只有当方法正常执行完成才会触发。如果有异常发生,@AfterReturning 不会执行。

面试题 3:环绕通知比其他通知强在哪?
👉 答:环绕通知能控制方法是否执行修改入参捕获异常修改返回值,实现 AOP 中最灵活的控制逻辑。阿里 P7+ 面试很喜欢问“为什么实战中推荐环绕通知实现性能埋点?”答案就是因为它能在调用前后做打点、还能拿到时间差、异常等信息。

面试题 4:Spring AOP 环绕通知如何获取方法签名和入参?
👉 答:通过 ProceedingJoinPoint,它有 getArgs() 方法获取参数,有 getSignature() 方法拿到方法名、类名等信息,基本都是大厂面试的必问点。

 

场景题 2:
你是阿里巴巴天猫技术部的开发,你们维护的下单接口经常遇到高并发情况下偶发性卡顿。Leader 让你添加性能监控埋点,并且在异常时能自动报警。请说明你会如何选择通知类型?如何实现接口的“执行前打点”、“正常返回打点”、“抛出异常打点”这 3 种情况?

答题要点:

  • 选型:使用 @Around 实现前后埋点;@AfterReturning 捕获正常返回日志;@AfterThrowing 捕获异常打点

  • 在环绕通知中记录开始时间 & 结束时间,计算接口耗时

  • @AfterReturning 注解记录返回值摘要

  • @AfterThrowing 捕获异常信息并推送到钉钉报警/Prometheus

  • 强调阿里巴巴内部会在埋点上挂上 traceId,链路追踪到每个调用环节

场景题 2 回答:

阿里巴巴天猫这种场景,订单接口既要性能埋点又要异常报警,所以肯定是多通知组合的思路。

我的实现策略:

1️⃣ 环绕通知 (@Around):
在这里打埋点记录“开始时间”和“结束时间”,计算接口执行时长。例如:

@Around("orderPointcut()")
public Object monitor(ProceedingJoinPoint pjp) throws Throwable {
    long start = System.currentTimeMillis();
    Object result = pjp.proceed();
    long end = System.currentTimeMillis();
    log.info("接口耗时: {}ms", (end - start));
    return result;
}

2️⃣ 返回后通知 (@AfterReturning):
接口正常返回时,记录返回值摘要:

@AfterReturning(value = "orderPointcut()", returning = "ret")
public void afterReturning(Object ret) {
    log.info("返回值: {}", JSON.toJSONString(ret));
}

3️⃣ 异常后通知 (@AfterThrowing):
接口抛出异常时,立刻报警:

@AfterThrowing(value = "orderPointcut()", throwing = "ex")
public void afterThrowing(Throwable ex) {
    alertService.notify("接口异常: " + ex.getMessage());
}

企业实战补充:
阿里内部通常用全链路追踪系统(如 EagleEye),所以我还会补充 traceId,挂到日志和报警上,方便问题定位。而且报警不会直接打钉钉,通常走消息中间件汇总后再报警,防止打爆钉钉。

 

4.3 业务层接口执行效率

4.3.1 需求分析

这个需求也比较简单,前面我们在介绍AOP的时候已经演示过:

  • 需求:任意业务层接口执行均可显示其执行效率(执行时长)

这个案例的目的是查看每个业务层执行的时间,这样就可以监控出哪个业务比较耗时,将其查找出来方便优化。

具体实现的思路:

(1) 开始执行方法之前记录一个时间

(2) 执行方法

(3) 执行完方法之后记录一个时间

(4) 用后一个时间减去前一个时间的差值,就是我们需要的结果。

所以要在方法执行的前后添加业务,经过分析我们将采用环绕通知

说明:原始方法如果只执行一次,时间太快,两个时间差可能为0,所以我们要执行万次来计算时间差。

🧠 理论理解
AOP天然适合做性能监控。环绕通知可精准记录“方法执行的起止时间”,并统计出耗时,结合方法签名还可以定位慢查询、慢操作。
高频接口要注意避免在AOP中进行复杂逻辑,否则监控本身会带来负担。

🏢 企业实战理解
🔹 阿里巴巴:大促期间,用AOP做接口慢调用的实时检测,超过阈值自动触发降级或限流。
🔹 美团:业务埋点系统全部基于AOP性能监控,所有 Service 层方法都纳入指标采集。
🔹 Google:内部日志系统结合AOP自动统计接口99线响应时间,作为SRE核心指标。

 

4.3.2 环境准备
  • 创建一个Maven项目

  • pom.xml添加Spring依赖

    <dependencies>
        <dependency>
          <groupId>org.springframework</groupId>
          <artifactId>spring-context</artifactId>
          <version>5.2.10.RELEASE</version>
        </dependency>
        <dependency>
          <groupId>org.springframework</groupId>
          <artifactId>spring-jdbc</artifactId>
          <version>5.2.10.RELEASE</version>
        </dependency>
        <dependency>
          <groupId>org.springframework</groupId>
          <artifactId>spring-test</artifactId>
          <version>5.2.10.RELEASE</version>
        </dependency>
        <dependency>
          <groupId>org.aspectj</groupId>
          <artifactId>aspectjweaver</artifactId>
          <version>1.9.4</version>
        </dependency>
        <dependency>
          <groupId>mysql</groupId>
          <artifactId>mysql-connector-java</artifactId>
          <version>5.1.47</version>
        </dependency>
        <dependency>
          <groupId>com.alibaba</groupId>
          <artifactId>druid</artifactId>
          <version>1.1.16</version>
        </dependency>
        <dependency>
          <groupId>org.mybatis</groupId>
          <artifactId>mybatis</artifactId>
          <version>3.5.6</version>
        </dependency>
        <dependency>
          <groupId>org.mybatis</groupId>
          <artifactId>mybatis-spring</artifactId>
          <version>1.3.0</version>
        </dependency>
        <dependency>
          <groupId>junit</groupId>
          <artifactId>junit</artifactId>
          <version>4.12</version>
          <scope>test</scope>
        </dependency>
      </dependencies>

  • 添加AccountService、AccountServiceImpl、AccountDao与Account类

    public interface AccountService {
        void save(Account account);
        void delete(Integer id);
        void update(Account account);
        List<Account> findAll();
        Account findById(Integer id);
    }
    ​
    @Service
    public class AccountServiceImpl implements AccountService {
    ​
        @Autowired
        private AccountDao accountDao;
    ​
        public void save(Account account) {
            accountDao.save(account);
        }
    ​
        public void update(Account account){
            accountDao.update(account);
        }
    ​
        public void delete(Integer id) {
            accountDao.delete(id);
        }
    ​
        public Account findById(Integer id) {
            return accountDao.findById(id);
        }
    ​
        public List<Account> findAll() {
            return accountDao.findAll();
        }
    }
    public interface AccountDao {
    ​
        @Insert("insert into tbl_account(name,money)values(#{name},#{money})")
        void save(Account account);
    ​
        @Delete("delete from tbl_account where id = #{id} ")
        void delete(Integer id);
    ​
        @Update("update tbl_account set name = #{name} , money = #{money} where id = #{id} ")
        void update(Account account);
    ​
        @Select("select * from tbl_account")
        List<Account> findAll();
    ​
        @Select("select * from tbl_account where id = #{id} ")
        Account findById(Integer id);
    }
    ​
    public class Account implements Serializable {
    ​
        private Integer id;
        private String name;
        private Double money;
        //setter..getter..toString方法省略
    }

  • resources下提供一个jdbc.properties

    jdbc.driver=com.mysql.jdbc.Driver
    jdbc.url=jdbc:mysql://localhost:3306/spring_db?useSSL=false
    jdbc.username=root
    jdbc.password=root

  • 创建相关配置类

    //Spring配置类:SpringConfig
    @Configuration
    @ComponentScan("com.itheima")
    @PropertySource("classpath:jdbc.properties")
    @Import({JdbcConfig.class,MybatisConfig.class})
    public class SpringConfig {
    }
    //JdbcConfig配置类
    public class JdbcConfig {
        @Value("${jdbc.driver}")
        private String driver;
        @Value("${jdbc.url}")
        private String url;
        @Value("${jdbc.username}")
        private String userName;
        @Value("${jdbc.password}")
        private String password;
    ​
        @Bean
        public DataSource dataSource(){
            DruidDataSource ds = new DruidDataSource();
            ds.setDriverClassName(driver);
            ds.setUrl(url);
            ds.setUsername(userName);
            ds.setPassword(password);
            return ds;
        }
    }
    //MybatisConfig配置类
    public class MybatisConfig {
    ​
        @Bean
        public SqlSessionFactoryBean sqlSessionFactory(DataSource dataSource){
            SqlSessionFactoryBean ssfb = new SqlSessionFactoryBean();
            ssfb.setTypeAliasesPackage("com.itheima.domain");
            ssfb.setDataSource(dataSource);
            return ssfb;
        }
    ​
        @Bean
        public MapperScannerConfigurer mapperScannerConfigurer(){
            MapperScannerConfigurer msc = new MapperScannerConfigurer();
            msc.setBasePackage("com.itheima.dao");
            return msc;
        }
    }
  • 编写Spring整合Junit的测试类

    @RunWith(SpringJUnit4ClassRunner.class)
    @ContextConfiguration(classes = SpringConfig.class)
    public class AccountServiceTestCase {
        @Autowired
        private AccountService accountService;
    ​
        @Test
        public void testFindById(){
            Account ac = accountService.findById(2);
        }
    ​
        @Test
        public void testFindAll(){
            List<Account> all = accountService.findAll();
        }
    ​
    }

最终创建好的项目结构如下:

4.3.3 功能开发
步骤1:开启SpringAOP的注解功能

在Spring的主配置文件SpringConfig类中添加注解

@EnableAspectJAutoProxy
步骤2:创建AOP的通知类
  • 该类要被Spring管理,需要添加@Component

  • 要标识该类是一个AOP的切面类,需要添加@Aspect

  • 配置切入点表达式,需要添加一个方法,并添加@Pointcut

@Component
@Aspect
public class ProjectAdvice {
    //配置业务层的所有方法
    @Pointcut("execution(* com.itheima.service.*Service.*(..))")
    private void servicePt(){}
    
    public void runSpeed(){
        
    } 
}
步骤3:添加环绕通知

在runSpeed()方法上添加@Around

@Component
@Aspect
public class ProjectAdvice {
    //配置业务层的所有方法
    @Pointcut("execution(* com.itheima.service.*Service.*(..))")
    private void servicePt(){}
    //@Around("ProjectAdvice.servicePt()") 可以简写为下面的方式
    @Around("servicePt()")
    public Object runSpeed(ProceedingJoinPoint pjp){
        Object ret = pjp.proceed();
        return ret;
    } 
}

注意:目前并没有做任何增强

步骤4:完成核心业务,记录万次执行的时间
@Component
@Aspect
public class ProjectAdvice {
    //配置业务层的所有方法
    @Pointcut("execution(* com.itheima.service.*Service.*(..))")
    private void servicePt(){}
    //@Around("ProjectAdvice.servicePt()") 可以简写为下面的方式
    @Around("servicePt()")
    public void runSpeed(ProceedingJoinPoint pjp){
        
        long start = System.currentTimeMillis();
        for (int i = 0; i < 10000; i++) {
           pjp.proceed();
        }
        long end = System.currentTimeMillis();
        System.out.println("业务层接口万次执行时间: "+(end-start)+"ms");
    } 
}
步骤5:运行单元测试类

注意:因为程序每次执行的时长是不一样的,所以运行多次最终的结果是不一样的。

步骤6:程序优化

目前程序所面临的问题是,多个方法一起执行测试的时候,控制台都打印的是:

业务层接口万次执行时间:xxxms

我们没有办法区分到底是哪个接口的哪个方法执行的具体时间,具体如何优化?

@Component
@Aspect
public class ProjectAdvice {
    //配置业务层的所有方法
    @Pointcut("execution(* com.itheima.service.*Service.*(..))")
    private void servicePt(){}
    //@Around("ProjectAdvice.servicePt()") 可以简写为下面的方式
    @Around("servicePt()")
    public void runSpeed(ProceedingJoinPoint pjp){
        //获取执行签名信息
        Signature signature = pjp.getSignature();
        //通过签名获取执行操作名称(接口名)
        String className = signature.getDeclaringTypeName();
        //通过签名获取执行操作名称(方法名)
        String methodName = signature.getName();
        
        long start = System.currentTimeMillis();
        for (int i = 0; i < 10000; i++) {
           pjp.proceed();
        }
        long end = System.currentTimeMillis();
        System.out.println("万次执行:"+ className+"."+methodName+"---->" +(end-start) + "ms");
    } 
}
步骤7:运行单元测试类

==补充说明==

当前测试的接口执行效率仅仅是一个理论值,并不是一次完整的执行过程。

这块只是通过该案例把AOP的使用进行了学习,具体的实际值是有很多因素共同决定的。

🧠 理论理解
核心逻辑是利用 ProceedingJoinPoint 包裹原始方法,用 System.currentTimeMillis()StopWatch 记录时间差,并在增强中打日志
执行万次是为了规避单次时间过短的测量误差,但实际生产环境中,通常采用采样或自定义打点SDK。

🏢 企业实战理解
🔹 字节跳动:用自研链路追踪系统整合AOP埋点,避免单独开发;同时万次循环测量用于开发阶段的基准测试。
🔹 NVIDIA:GPU大任务下,AOP + 性能监控系统会记录每个接口的平均执行耗时,作为自动扩容依据。
🔹 OpenAI:利用环绕通知对API调用埋点,在内部实时看板上呈现接口响应时间分布。

 

面试题 1:如何通过 AOP 实现业务方法的性能监控?
👉 答:使用环绕通知,在方法执行前记录开始时间 System.currentTimeMillis(),方法执行后记录结束时间,计算差值,最后把结果打入日志或推送到监控平台。美团、京东都有类似的 AOP 埋点方案。

面试题 2:性能监控埋点的注意事项有哪些?
👉 答:

  • 只埋点关键路径接口,避免全局监控带来性能开销

  • 埋点逻辑异步化处理,不要影响主业务

  • 记录方法签名、入参摘要,防止数据过大

  • 错误情况也要记录,避免遗漏高延迟问题

面试题 3:环绕通知如何兼顾有返回值/无返回值的场景?
👉 答:必须使用 Object ret = pjp.proceed(),最后 return ret;,即便方法本身无返回值,AOP也要求环绕通知返回 Object 类型,保证兼容性。这点字节、腾讯在面试里爱考察细节。

面试题 4:如何区分多个方法的耗时记录?
👉 答:通过 pjp.getSignature().getDeclaringTypeName() 获取类名,再用 getName() 获取方法名,组合成 类.方法 名称进行日志记录。

场景题 3:
你在美团外卖负责“骑手实时定位”服务,定位 API 每天有数十亿次请求。现在你的任务是用 AOP 提供一个“接口性能监控系统”,要求输出日志时包含类名、方法名、执行时间,并且考虑到线上压力要避免对性能造成影响。请问你会如何设计?遇到什么坑需要避免?

答题要点:

  • 切入点:execution(* com.meituan.*.service.*Service.*(..))

  • 使用 @Around 实现环绕通知,利用 ProceedingJoinPoint 获取类名 & 方法名

  • 执行前 System.currentTimeMillis() 记录开始,执行后获取结束时间

  • 日志中输出格式如:[性能埋点] com.meituan.rider.service.LocationService.updateLocation 耗时 15ms

  • 避免的坑:AOP 日志写入一定要异步(例如 Kafka 异步入链路追踪系统),否则会阻塞主业务

  • 提到美团内部是用 Cat/Logan 系统做 trace 追踪,埋点会附带 traceId、入参摘要

 

场景题 3 回答:

这个题目重点是性能监控,并且高并发场景下不能影响性能

我会采用 @Around,实现逻辑类似于:

@Around("execution(* com.meituan.*.service.*Service.*(..))")
public Object logPerformance(ProceedingJoinPoint pjp) throws Throwable {
    Signature sig = pjp.getSignature();
    String className = sig.getDeclaringTypeName();
    String methodName = sig.getName();
    long start = System.nanoTime();
    Object result = pjp.proceed();
    long end = System.nanoTime();
    long durationMs = (end - start) / 1_000_000;
    log.info("[性能埋点] {}.{} 执行耗时: {}ms", className, methodName, durationMs);

    // 实际生产中,这里会把数据异步推送到 Cat/Logan 系统
    return result;
}

避免的坑:

  • 日志不能同步写文件,必须异步写(或者用 Kafka 先落盘)

  • 不能打印大对象(例如方法返回值为大对象时要打摘要)

  • 性能监控逻辑不能出异常,出错时要 catch swallow 防止影响主流程

企业实战补充:
美团通常还会附加“业务标识符”(如 orderId、riderId),用来跨服务追踪性能瓶颈点。

4.4 AOP通知获取数据

目前我们写AOP仅仅是在原始方法前后追加一些操作,接下来我们要说说AOP中数据相关的内容,我们将从获取参数获取返回值获取异常三个方面来研究切入点的相关信息。

前面我们介绍通知类型的时候总共讲了五种,那么对于这五种类型都会有参数,返回值和异常吗?

我们先来一个个分析下:

  • 获取切入点方法的参数,所有的通知类型都可以获取参数

    • JoinPoint:适用于前置、后置、返回后、抛出异常后通知

    • ProceedingJoinPoint:适用于环绕通知

  • 获取切入点方法返回值,前置和抛出异常后通知是没有返回值,后置通知可有可无,所以不做研究

    • 返回后通知

    • 环绕通知

  • 获取切入点方法运行异常信息,前置和返回后通知是不会有,后置通知可有可无,所以不做研究

    • 抛出异常后通知

    • 环绕通知

4.4.1 环境准备
  • 创建一个Maven项目

  • pom.xml添加Spring依赖

    <dependencies>
        <dependency>
          <groupId>org.springframework</groupId>
          <artifactId>spring-context</artifactId>
          <version>5.2.10.RELEASE</version>
        </dependency>
        <dependency>
          <groupId>org.aspectj</groupId>
          <artifactId>aspectjweaver</artifactId>
          <version>1.9.4</version>
        </dependency>
      </dependencies>

  • 添加BookDao和BookDaoImpl类

    public interface BookDao {
        public String findName(int id);
    }
    @Repository
    public class BookDaoImpl implements BookDao {
    ​
        public String findName(int id) {
            System.out.println("id:"+id);
            return "itcast";
        }
    }

  • 创建Spring的配置类

    @Configuration
    @ComponentScan("com.itheima")
    @EnableAspectJAutoProxy
    public class SpringConfig {
    }

  • 编写通知类

    @Component
    @Aspect
    public class MyAdvice {
        @Pointcut("execution(* com.itheima.dao.BookDao.findName(..))")
        private void pt(){}
    ​
        @Before("pt()")
        public void before() {
            System.out.println("before advice ..." );
        }
    ​
        @After("pt()")
        public void after() {
            System.out.println("after advice ...");
        }
    ​
        @Around("pt()")
        public Object around() throws Throwable{
            Object ret = pjp.proceed();
            return ret;
        }
        @AfterReturning("pt()")
        public void afterReturning() {
            System.out.println("afterReturning advice ...");
        }
    ​
    ​
        @AfterThrowing("pt()")
        public void afterThrowing() {
            System.out.println("afterThrowing advice ...");
        }
    }

  • 编写App运行类

    public class App {
        public static void main(String[] args) {
            ApplicationContext ctx = new AnnotationConfigApplicationContext(SpringConfig.class);
            BookDao bookDao = ctx.getBean(BookDao.class);
            String name = bookDao.findName(100);
            System.out.println(name);
        }
    }

最终创建好的项目结构如下:

🧠 理论理解
Spring AOP使用 JoinPoint / ProceedingJoinPoint 来封装方法调用的上下文,包括参数、目标对象、代理对象、方法签名等。这个阶段的核心就是:

  • 各类通知方法里“如何接收数据”?

  • 非环绕用 JoinPoint,环绕用 ProceedingJoinPoint
    这些类帮助开发者在切面中“窥视”并操作执行上下文。

🏢 企业实战理解
🔹 阿里巴巴:统一封装了 BaseAdvice 模板,规范了获取参数、返回值的标准方式,减少开发者手写细节。
🔹 字节跳动:内部AOP模块封装了 JoinPointUtils 工具,批量获取参数、校验用户上下文,兼容多版本微服务。
🔹 Google:用 ProceedingJoinPoint 实现动态权限校验,例如 BigQuery 的访问权限即通过这种机制分层检查。
🔹 OpenAI:早期ChatGPT API在切面中用 JoinPoint 获取请求ID、IP等数据,做防刷逻辑。

 

4.4.2 获取参数
非环绕通知获取方式

在方法上添加JoinPoint,通过JoinPoint来获取参数

@Component
@Aspect
public class MyAdvice {
    @Pointcut("execution(* com.itheima.dao.BookDao.findName(..))")
    private void pt(){}
​
    @Before("pt()")
    public void before(JoinPoint jp) 
        Object[] args = jp.getArgs();
        System.out.println(Arrays.toString(args));
        System.out.println("before advice ..." );
    }
    //...其他的略
}

运行App类,可以获取如下内容,说明参数100已经被获取

思考:方法的参数只有一个,为什么获取的是一个数组?

因为参数的个数是不固定的,所以使用数组更通配些。

如果将参数改成两个会是什么效果呢?

(1)修改BookDao接口和BookDaoImpl实现类

public interface BookDao {
    public String findName(int id,String password);
}
@Repository
public class BookDaoImpl implements BookDao {
​
    public String findName(int id,String password) {
        System.out.println("id:"+id);
        return "itcast";
    }
}

(2)修改App类,调用方法传入多个参数

public class App {
    public static void main(String[] args) {
        ApplicationContext ctx = new AnnotationConfigApplicationContext(SpringConfig.class);
        BookDao bookDao = ctx.getBean(BookDao.class);
        String name = bookDao.findName(100,"itheima");
        System.out.println(name);
    }
}

(3)运行App,查看结果,说明两个参数都已经被获取到

说明:

使用JoinPoint的方式获取参数适用于前置后置返回后抛出异常后通知。剩下的大家自行去验证。

环绕通知获取方式

环绕通知使用的是ProceedingJoinPoint,因为ProceedingJoinPoint是JoinPoint类的子类,所以对于ProceedingJoinPoint类中应该也会有对应的getArgs()方法,我们去验证下:

@Component
@Aspect
public class MyAdvice {
    @Pointcut("execution(* com.itheima.dao.BookDao.findName(..))")
    private void pt(){}
​
    @Around("pt()")
    public Object around(ProceedingJoinPoint pjp)throws Throwable {
        Object[] args = pjp.getArgs();
        System.out.println(Arrays.toString(args));
        Object ret = pjp.proceed();
        return ret;
    }
    //其他的略
} 

运行App后查看运行结果,说明ProceedingJoinPoint也是可以通过getArgs()获取参数

注意:

  • pjp.proceed()方法是有两个构造方法,分别是:

    • 调用无参数的proceed,当原始方法有参数,会在调用的过程中自动传入参数

    • 所以调用这两个方法的任意一个都可以完成功能

    • 但是当需要修改原始方法的参数时,就只能采用带有参数的方法,如下:

      @Component
      @Aspect
      public class MyAdvice {
          @Pointcut("execution(* com.itheima.dao.BookDao.findName(..))")
          private void pt(){}
      ​
          @Around("pt()")
          public Object around(ProceedingJoinPoint pjp) throws Throwable{
              Object[] args = pjp.getArgs();
              System.out.println(Arrays.toString(args));
              args[0] = 666;
              Object ret = pjp.proceed(args);
              return ret;
          }
          //其他的略
      }

      有了这个特性后,我们就可以在环绕通知中对原始方法的参数进行拦截过滤,避免由于参数的问题导致程序无法正确运行,保证代码的健壮性。

🧠 理论理解
非环绕通知通过 JoinPoint.getArgs() 获取方法入参数组,环绕通知同理用 pjp.getArgs()。参数通常是数组形式,哪怕只有一个参数也是数组。
环绕通知更强大:不仅可以获取,还能修改参数再传入 pjp.proceed(args)

🏢 企业实战理解
🔹 美团:AOP层实时获取业务方法参数,记录下单接口的关键参数(如用户ID、商品ID)做风控分析。
🔹 字节跳动:通过 pjp.getArgs() 实现了自动参数脱敏功能,比如日志中只保留手机号的后四位。
🔹 Google:GCP API切面层获取参数后进行结构验证,如果参数非法会中断调用,提升安全性。
🔹 OpenAI:为ChatGPT API打日志时,会在AOP中截取用户传参并存档,为安全审计做准备。

 

4.4.3 获取返回值

对于返回值,只有返回后AfterReturing和环绕Around这两个通知类型可以获取,具体如何获取?

环绕通知获取返回值
@Component
@Aspect
public class MyAdvice {
    @Pointcut("execution(* com.itheima.dao.BookDao.findName(..))")
    private void pt(){}
​
    @Around("pt()")
    public Object around(ProceedingJoinPoint pjp) throws Throwable{
        Object[] args = pjp.getArgs();
        System.out.println(Arrays.toString(args));
        args[0] = 666;
        Object ret = pjp.proceed(args);
        return ret;
    }
    //其他的略
}

上述代码中,ret就是方法的返回值,我们是可以直接获取,不但可以获取,如果需要还可以进行修改。

返回后通知获取返回值
@Component
@Aspect
public class MyAdvice {
    @Pointcut("execution(* com.itheima.dao.BookDao.findName(..))")
    private void pt(){}
​
    @AfterReturning(value = "pt()",returning = "ret")
    public void afterReturning(Object ret) {
        System.out.println("afterReturning advice ..."+ret);
    }
    //其他的略
}

==注意:==

(1)参数名的问题

(2)afterReturning方法参数类型的问题

参数类型可以写成String,但是为了能匹配更多的参数类型,建议写成Object类型

(3)afterReturning方法参数的顺序问题

运行App后查看运行结果,说明返回值已经被获取到

🧠 理论理解
只有 @AfterReturning@Around 能拿到返回值:

  • @AfterReturningreturning="xxx" 指定参数

  • 环绕通知里 Object ret = pjp.proceed() 获取返回值
    特别注意:环绕通知还可以修改返回值,这比其他通知灵活得多。

🏢 企业实战理解
🔹 阿里巴巴:在 @AfterReturning 通知中提取返回结果,做“接口数据一致性检查”并异步入库。
🔹 字节跳动:AOP模块标准化输出日志,统一记录“方法名 + 参数 + 返回值”,极大方便排查问题。
🔹 NVIDIA:在GPU任务接口返回值里自动注入任务ID,AOP切面中完成这一逻辑,业务方无感知。
🔹 OpenAI:ChatGPT API中环绕通知会对返回结果进行二次加工,比如加上traceId供前端追踪。

 

4.4.4 获取异常

对于获取抛出的异常,只有抛出异常后AfterThrowing和环绕Around这两个通知类型可以获取,具体如何获取?

环绕通知获取异常

这块比较简单,以前我们是抛出异常,现在只需要将异常捕获,就可以获取到原始方法的异常信息了

@Component
@Aspect
public class MyAdvice {
    @Pointcut("execution(* com.itheima.dao.BookDao.findName(..))")
    private void pt(){}
​
    @Around("pt()")
    public Object around(ProceedingJoinPoint pjp){
        Object[] args = pjp.getArgs();
        System.out.println(Arrays.toString(args));
        args[0] = 666;
        Object ret = null;
        try{
            ret = pjp.proceed(args);
        }catch(Throwable throwable){
            t.printStackTrace();
        }
        return ret;
    }
    //其他的略
}

在catch方法中就可以获取到异常,至于获取到异常以后该如何处理,这个就和你的业务需求有关了。

抛出异常后通知获取异常
@Component
@Aspect
public class MyAdvice {
    @Pointcut("execution(* com.itheima.dao.BookDao.findName(..))")
    private void pt(){}
​
    @AfterThrowing(value = "pt()",throwing = "t")
    public void afterThrowing(Throwable t) {
        System.out.println("afterThrowing advice ..."+t);
    }
    //其他的略
}

如何让原始方法抛出异常,方式有很多,

@Repository
public class BookDaoImpl implements BookDao {
​
    public String findName(int id,String password) {
        System.out.println("id:"+id);
        if(true){
            throw new NullPointerException();
        }
        return "itcast";
    }
}

==注意:==

运行App后,查看控制台,就能看的异常信息被打印到控制台

至此,AOP通知如何获取数据就已经讲解完了,数据中包含参数返回值异常(了解)

🧠 理论理解

  • @AfterThrowing 是专门为捕获异常设计的,throwing="xxx" 用于传入异常对象。

  • 环绕通知中通过 try...catch 自行捕获异常。
    这是实现“统一异常处理/报警机制”的基础工具。

🏢 企业实战理解
🔹 阿里巴巴:为提升可观测性,@AfterThrowing 通知会捕获异常并推送到内部监控系统。
🔹 美团:AOP捕获异常后自动重试机制实现,降低偶发性错误导致的业务中断。
🔹 字节跳动:所有线上系统都要求异常必须被埋点,@AfterThrowing 自动汇报异常类型、调用链路。
🔹 OpenAI:API接口如果抛出异常,会在环绕通知内捕获并统一包装成JSON格式返回,同时发出告警。

 

面试题 1:JoinPoint 和 ProceedingJoinPoint 有什么区别?
👉 答:JoinPoint 用于非环绕通知(@Before、@After等),只能获取入参、签名、目标对象;ProceedingJoinPoint 只能用于环绕通知,它继承自 JoinPoint,除了能获取数据外,还能控制方法执行proceed())。

面试题 2:非环绕通知能修改入参吗?为什么?
👉 答:不可以。非环绕通知只能“读取”参数,无法修改参数传递到目标方法。这是 Spring AOP 的机制限制。只有环绕通知才能做到 pjp.proceed(newArgs) 传递修改后的参数。

面试题 3:@AfterReturning 如何获取返回值?
👉 答:在注解上写 returning="xxx",并在方法参数里加同名参数,比如 @AfterReturning(pointcut = "pt()", returning = "ret") public void afterReturning(Object ret),就能接收返回值。美团的 API 埋点就大量用到这个。

面试题 4:如何捕获异常并上报监控?
👉 答:在 @AfterThrowing 中通过 throwing="t" 注入异常对象,然后结合日志/埋点系统进行上报。例如字节跳动会把异常类型、堆栈、参数都一起埋点。

场景题 4:
你在腾讯云做云数据库的 API 服务,团队决定为所有 API 接口增加请求参数日志和返回值摘要记录(用于数据分析和问题定位)。但是由于安全要求,涉及账号密码的参数要自动隐藏中间 4 位,返回值中的敏感数据也不能完整打出来。请问你会如何使用 AOP 实现这个功能?如何获取参数 & 返回值?

答题要点:

  • 切入点匹配所有 APIController 接口方法

  • @Before 中用 JoinPoint.getArgs() 拿到参数列表,遍历参数,如果是 String 类型,检测是否为账号/手机号,做模糊处理(如 138****8888

  • @AfterReturning 中通过 returning="ret" 获取返回值,判断是否为对象/JSON,提取需要脱敏的字段,打印日志时进行内容隐藏

  • 安全考虑:日志存储前统一做 XSS 过滤

  • 提及腾讯云内部有数据合规平台,AOP 日志通常对接专门的“合规检查模块”

 

场景题 4 回答:

腾讯云 API 涉及敏感数据,这里需要用 @Before + @AfterReturning 组合来做参数脱敏 & 返回值脱敏。

1️⃣ 参数处理(@Before):

@Before("apiPointcut()")
public void logRequest(JoinPoint jp) {
    Object[] args = jp.getArgs();
    for (Object arg : args) {
        if (arg instanceof String) {
            String param = (String) arg;
            if (isSensitive(param)) {
                param = mask(param); // 中间4位隐藏
            }
            log.info("入参: {}", param);
        }
    }
}

2️⃣ 返回值处理(@AfterReturning):

@AfterReturning(value = "apiPointcut()", returning = "ret")
public void logResponse(Object ret) {
    String json = JSON.toJSONString(ret);
    json = maskSensitiveFields(json); // 替换 JSON 中的敏感字段
    log.info("返回值: {}", json);
}

企业实战补充:
腾讯内部合规要求很严,日志除了脱敏还要保证“不落盘敏感信息”。AOP 日志通常会通过内部审计系统扫描关键字,确保合规。

4.5 百度网盘密码数据兼容处理

4.5.1 需求分析

需求: 对百度网盘分享链接输入密码时尾部多输入的空格做兼容处理。

问题描述:

  • 点击链接,会提示,请输入提取码,如下图所示

  • 当我们从别人发给我们的内容中复制提取码的时候,有时候会多复制到一些空格,直接粘贴到百度的提取码输入框

  • 但是百度那边记录的提取码是没有空格的

  • 这个时候如果不做处理,直接对比的话,就会引发提取码不一致,导致无法访问百度盘上的内容

  • 所以多输入一个空格可能会导致项目的功能无法正常使用。

  • 此时我们就想能不能将输入的参数先帮用户去掉空格再操作呢?

答案是可以的,我们只需要在业务方法执行之前对所有的输入参数进行格式处理——trim()

  • 是对所有的参数都需要去除空格么?

也没有必要,一般只需要针对字符串处理即可。

  • 以后涉及到需要去除前后空格的业务可能会有很多,这个去空格的代码是每个业务都写么?

可以考虑使用AOP来统一处理。

  • AOP有五种通知类型,该使用哪种呢?

我们的需求是将原始方法的参数处理后在参与原始方法的调用,能做这件事的就只有环绕通知。

综上所述,我们需要考虑两件事: ①:在业务方法执行之前对所有的输入参数进行格式处理——trim() ②:使用处理后的参数调用原始方法——环绕通知中存在对原始方法的调用

🧠 理论理解
这个案例的本质是“输入数据预处理”:对参数做统一校验、格式化,特别是字符串类型的 trim() 操作,防止因为用户输入不规范而导致业务失败。
用AOP实现这种功能最大好处是解耦,业务方法里不需要手写 xxx.trim(),而是让切面在调用前统一处理。

🏢 企业实战理解
🔹 阿里巴巴:在表单接口中,AOP层会自动“去空格、过滤特殊字符”,降低SQL注入/脚本攻击风险。
🔹 字节跳动:为了支持多语言输入,AOP统一做了空格/换行/特殊符号清洗,提升跨平台体验。
🔹 美团:点评模块接口中,所有用户评论参数都经过AOP“清理过滤”,确保数据合规后再入库。
🔹 OpenAI:API切面层对请求参数做了敏感词过滤、空格清理,防止恶意提交影响大模型输入。

 

4.5.2 环境准备
  • 创建一个Maven项目

  • pom.xml添加Spring依赖

    <dependencies>
        <dependency>
          <groupId>org.springframework</groupId>
          <artifactId>spring-context</artifactId>
          <version>5.2.10.RELEASE</version>
        </dependency>
        <dependency>
          <groupId>org.aspectj</groupId>
          <artifactId>aspectjweaver</artifactId>
          <version>1.9.4</version>
        </dependency>
      </dependencies>

  • 添加ResourcesService,ResourcesServiceImpl,ResourcesDao和ResourcesDaoImpl类

    public interface ResourcesDao {
        boolean readResources(String url, String password);
    }
    @Repository
    public class ResourcesDaoImpl implements ResourcesDao {
        public boolean readResources(String url, String password) {
            //模拟校验
            return password.equals("root");
        }
    }
    public interface ResourcesService {
        public boolean openURL(String url ,String password);
    }
    @Service
    public class ResourcesServiceImpl implements ResourcesService {
        @Autowired
        private ResourcesDao resourcesDao;
    ​
        public boolean openURL(String url, String password) {
            return resourcesDao.readResources(url,password);
        }
    }
  • 创建Spring的配置类

    @Configuration
    @ComponentScan("com.itheima")
    public class SpringConfig {
    }

  • 编写App运行类

    public class App {
        public static void main(String[] args) {
            ApplicationContext ctx = new AnnotationConfigApplicationContext(SpringConfig.class);
            ResourcesService resourcesService = ctx.getBean(ResourcesService.class);
            boolean flag = resourcesService.openURL("http://pan.baidu.com/haha", "root");
            System.out.println(flag);
        }
    }

最终创建好的项目结构如下:

现在项目的效果是,当输入密码为"root"控制台打印为true,如果密码改为"root "控制台打印的是false

需求是使用AOP将参数进行统一处理,不管输入的密码root前后包含多少个空格,最终控制台打印的都是true。

4.5.3 具体实现
步骤1:开启SpringAOP的注解功能
@Configuration
@ComponentScan("com.itheima")
@EnableAspectJAutoProxy
public class SpringConfig {
}
步骤2:编写通知类
@Component
@Aspect
public class DataAdvice {
    @Pointcut("execution(boolean com.itheima.service.*Service.*(*,*))")
    private void servicePt(){}
    
}
步骤3:添加环绕通知
@Component
@Aspect
public class DataAdvice {
    @Pointcut("execution(boolean com.itheima.service.*Service.*(*,*))")
    private void servicePt(){}
    
    @Around("DataAdvice.servicePt()")
    // @Around("servicePt()")这两种写法都对
    public Object trimStr(ProceedingJoinPoint pjp) throws Throwable {
        Object ret = pjp.proceed();
        return ret;
    }
    
}
步骤4:完成核心业务,处理参数中的空格
@Component
@Aspect
public class DataAdvice {
    @Pointcut("execution(boolean com.itheima.service.*Service.*(*,*))")
    private void servicePt(){}
    
    @Around("DataAdvice.servicePt()")
    // @Around("servicePt()")这两种写法都对
    public Object trimStr(ProceedingJoinPoint pjp) throws Throwable {
        //获取原始方法的参数
        Object[] args = pjp.getArgs();
        for (int i = 0; i < args.length; i++) {
            //判断参数是不是字符串
            if(args[i].getClass().equals(String.class)){
                args[i] = args[i].toString().trim();
            }
        }
        //将修改后的参数传入到原始方法的执行中
        Object ret = pjp.proceed(args);
        return ret;
    }
    
}
步骤5:运行程序

不管密码root前后是否加空格,最终控制台打印的都是true

步骤6:优化测试

为了能更好的看出AOP已经生效,我们可以修改ResourcesImpl类,在方法中将密码的长度进行打印

@Repository
public class ResourcesDaoImpl implements ResourcesDao {
    public boolean readResources(String url, String password) {
        System.out.println(password.length());
        //模拟校验
        return password.equals("root");
    }
}

再次运行成功,就可以根据最终打印的长度来看看,字符串的空格有没有被去除掉。

注意:

 

🧠 理论理解
环绕通知通过 pjp.getArgs() 获取参数数组,然后遍历所有参数,判断类型为 String 的字段做 trim() 处理。处理后的参数通过 pjp.proceed(newArgs) 调用原始业务逻辑。
这是一个典型的“参数前置加工”场景,实战中常见于数据清洗、注入防御、预处理等场景

🏢 企业实战理解
🔹 腾讯云:在云函数API中,AOP层自动修剪参数空格、处理全角半角符号,避免乱码请求失败。
🔹 字节跳动:字节内部把这种参数清洗机制做成了AOP“公共库”,每个服务一键接入,开发者不再手动写trim。
🔹 谷歌:Google表单接口中,所有输入都会在AOP环绕层完成预处理,包括编码修正、空白字符替换等。
🔹 OpenAI:API输入层通过环绕通知机制清理多余空格,确保prompt内容规范,减少无效Token消耗。

 

面试题 1:为什么要用 AOP 处理参数的 trim()?直接在业务里写不行吗?
👉 答:AOP 是横切关注点,它能解耦业务逻辑与公共处理逻辑,比如参数校验、格式清洗、权限检查等。直接在业务代码里写会导致代码重复、维护困难,而 AOP 实现一次写好,全局生效。BAT、Google 等大厂都非常重视解耦和高内聚低耦合的设计。

面试题 2:环绕通知中如何修改参数再传入方法?
👉 答:核心步骤是:
1️⃣ Object[] args = pjp.getArgs()
2️⃣ 遍历参数数组,修改对应的参数值
3️⃣ 调用 pjp.proceed(newArgs) 将新参数传入

面试题 3:这种“参数预处理”机制在企业级应用里还有哪些场景?
👉 答:

  • 参数脱敏(比如手机号、身份证号隐藏)

  • 参数安全校验(防止SQL注入/XSS攻击)

  • 国际化处理(多语言输入做统一转换)

  • 日志打点参数摘要生成

字节跳动飞书、阿里钉钉、OpenAI 的 API 层都普遍采用这种 AOP 参数预处理机制。

场景题 5:
你在网易云做大文件分享功能,用户经常反馈输入密码解锁时明明输入正确却提示密码错误。你排查后发现很多是因为用户在密码前后多输了一些空格。Leader 让你用 AOP 在不修改核心业务的情况下解决这个问题。你会怎么实现?除了去空格,还有哪些类似的“输入兼容优化”你觉得值得一起加上?

答题要点:

  • 切入点:匹配 ResourceService.openResource(..) 方法

  • @Around 通知中通过 pjp.getArgs() 获取参数,遍历入参

  • 对 String 类型做 trim(),确保空格被去除

  • 调用 pjp.proceed(newArgs) 传入处理后的参数

  • 提议其他优化:

    • 输入转小写(比如邮箱、域名等通常大小写不敏感)

    • 特殊字符兼容(如中文标点转英文标点)

    • 输入长度限制检测

  • 提及网易云内部类似问题解决后,用户体验显著提升,同时在 UI 侧也做了输入提示校验

 

场景题 5 回答:

这个问题非常实用,网易云这种场景下用户输入经常带空格甚至中文标点。

我会用 @Around 环绕通知实现:

@Around("execution(boolean com.netease.service.ResourceService.openResource(..))")
public Object cleanInput(ProceedingJoinPoint pjp) throws Throwable {
    Object[] args = pjp.getArgs();
    for (int i = 0; i < args.length; i++) {
        if (args[i] instanceof String) {
            String str = (String) args[i];
            str = str.trim()
                     .replaceAll(",", ",")   // 中文逗号转英文
                     .replaceAll(":", ":");  // 中文冒号转英文
            args[i] = str;
        }
    }
    return pjp.proceed(args);
}

企业实战建议:
除了去空格、标点转化,我建议再加:

  • 大小写转换(比如邮箱、域名)

  • 输入长度检查,防止注入攻击

  • 日志打点记录原始输入 vs 清洗后输入(方便排查)

网易内部也会在前端加一层“输入预处理”,配合后端 AOP 做双保险。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

夏驰和徐策

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值