☰
Spring AOP入门实战:核心概念、动态代理与高频问题排查
2026/10/14 5:42:07 网站建设 项目流程

1. 聊一聊Spring AOP这堂课到底在讲什么

如果你正在学Java EE这套体系,到Spring AOP这一章的时候,很容易被各种概念名词搞懵。什么切面、连接点、通知、切入点、织入,刚接触的时候确实有点劝退,但它其实是Spring框架里一个非常实用的设计思路,学会了之后你会发现很多业务代码可以写得特别干净。

AOP全称叫Aspect Oriented Programming,翻译过来就是面向切面编程。它的核心思想是把那些分散在各个业务方法里的公共逻辑,比如日志记录、权限校验、事务管理、性能监控,从业务代码里抽离出来,做成独立的模块,再通过某种机制在合适的位置切入到原有方法里。说白了就是“不修改原方法的前提下,给方法增加新能力”。

这个需求在真实项目里太常见了。举个例子,你写了一个订单服务,里面有创建订单、取消订单、查询订单好几个方法,现在领导要求每个方法执行前都记录一下操作日志,最简单的做法就是在每个方法第一行写一句log.info。但等你有几十个方法的时候,这种重复代码就非常烦人,而且以后想改日志格式还得一个方法一个方法去改,很容易漏。AOP就是专门解决这种横切逻辑重复问题的。

这一章的内容安排其实很有意思,它先带你把AOP相关的核心概念一个个捋清楚,然后让你通过一个注解驱动的入门案例去感受整个切面是怎么生效的。我在实际带项目的时候发现,很多同学急着跑案例,结果概念一知半解,后面遇到问题根本不知道从哪个环节排查。所以这篇内容我会先讲清楚原理,再带着你做案例,然后专门整理一些我见过的高频问题和排查经验。

适合谁来学?正在学Spring框架的同学、准备做Java后端开发的应届生、或者工作中第一次接触切面编程的开发者都可以好好看一看这篇。就算你之前完全没接触过AOP,只要跟着把环境搭起来、把案例跑通,再回过来看概念,理解成本会低很多。

2. 为什么要在程序里引入“切面”这种编程思想

2.1 从OOP的局限性说起

面向对象编程解决的是“数据和行为怎么组织”的问题。我们用类、接口、继承、多态把业务模型表达得很清晰,但有一类代码是OOP很难优雅处理的,那就是横切关注点。

什么叫横切关注点?就是那些跨越多个业务模块、跟核心业务逻辑没有直接关系但又不得不写的功能。最常见的就是日志记录、事务控制、权限校验、异常处理、性能统计。这些逻辑往往要作用在很多方法上,如果按照OOP的套路来做,只能把公共方法写成一个工具类,然后在每个业务方法里手动调用。这样做的结果就是业务代码里充满了跟业务无关的样板代码,不仅难看,而且维护成本高。

我个人在学习的时候觉得一个类比特别贴切:OOP解决的是“纵向”的代码组织,一个类对应一类职责,方法之间通过调用关系串联成业务流程;而AOP解决的是“横向”的重复逻辑,它把那些散落在各个方法里的公共逻辑像切一刀一样整体处理。有了这个视角,你再看那些设计模式、框架源码,思路会开阔很多。

2.2 AOP能帮我们解决哪些实际问题

实际开发中,AOP的典型应用场景比我上面说的要多不少。

第一个是访问日志和操作审计。系统里很多敏感操作需要记录“谁在什么时间做了什么”,如果用AOP,只需要定义一个切面拦截特定包下的方法,自动记录方法名、参数、返回值、耗时,业务代码完全不用感知。

第二个是事务管理。Spring的声明式事务本质上就是用AOP实现的。你只需要在方法上写一个@Transactional注解,Spring会在方法执行前开启事务、方法正常结束就提交、抛出异常就回滚。这一切对业务代码来说是透明的,不用你自己写begin、commit、rollback那一堆模板代码。

第三个是权限校验。很多项目里会在Service层或者Controller层做接口级别的权限控制,用AOP可以拦截方法执行,先判断当前用户有没有权限,没有就直接抛异常,有才放行继续执行。

第四个是性能监控。可以用AOP给一些核心方法加上耗时统计,方法执行时间超过了阈值就告警,方便定位系统的性能瓶颈。

当然,AOP也不是万能的。它擅长处理的是那些“结构统一、逻辑横切”的场景,如果业务逻辑本身特别复杂且各不相同,强行用AOP反而会让代码变难懂。这一点我后面会细说。

2.3 与Spring经典的动态代理机制的关系

既然AOP这么好用,那它是怎么在不修改原方法代码的情况下实现功能增强的?这块就得提到Spring底层动态代理了。

Spring AOP在运行时会让容器创建出来的bean其实是一个代理对象。当外部调用bean的方法时,真正执行的可能是代理对象里的增强逻辑,再通过反射或者直接调用去触发目标方法。Spring里默认有两种代理方式,一种是基于JDK的动态代理,一种是基于CGLIB的字节码增强。

JDK动态代理要求目标类必须有接口,它通过实现同样的接口来创建代理对象。CGLIB则不需要接口,它通过生成目标类的子类来实现代理。Spring会根据目标类的情况自动选择:有接口就用JDK代理,没有接口就用CGLIB。我在工作里遇到过一些跟代理方式相关的坑,后面会在常见问题环节专门展开。

这一章你在学入门案例的时候,心里要记住一个底层事实:你用的其实不是原始对象,而是增强后的代理对象。这个意识建立起来后,后面排查问题时思路会清晰不少。

3. 理清AOP里那些绕不开的概念

3.1 切面、连接点和切入点到底分别是什么

先来看这六个核心概念:切面、连接点、切入点、通知、目标对象、织入。我刚学的时候理解它们花了不少功夫,但其实细细拆开来看并不难。

连接点是程序执行过程中的一个位置,比如某个方法的调用、异常的抛出。但严格来说,Spring AOP里的连接点指的就是方法的执行。所以你可以把连接点理解成“所有可能被拦截的方法”。

切入点是用来筛选连接点的表达式。它解决的是“到底拦截哪些方法”这个问题。比如你想拦截某个包下所有以Service结尾的类里的所有方法,那就写一个切入点表达式匹配这些方法,匹配上的就成了目标连接点。

切面是切入点和通知的组合体。一个切面类里可以定义多个通知方法,每个通知方法配一个切入点表达式,讲清楚“在什么时候、对哪些方法、做什么增强”。在Spring里通常用一个加上@Aspect注解的普通类来表示切面。

通知是增强的逻辑体。它说明了在什么时候执行什么逻辑,比如方法调用之前、方法返回之后、方法抛出异常之后、或者方法前后都包一层。

目标对象就是被切面增强的那个原始业务对象。织入则是把切面逻辑应用到目标对象、创建出代理对象的过程。

这一组概念经常出现在面试里,提问方式往往是“说说你对AOP概念的理解”。我的建议是你别背定义,而是用一个具体例子把它串起来,比如登录模块的权限校验切面:连接点是用户服务里的每个方法,切入点是其中匹配到需要校验权限的那些方法,通知是执行前的权限检查逻辑,切面就是整个权限校验模块,目标对象是用户服务类。你用这个例子去讲,对方一听就知道你是真懂了。

3.2 五种通知类型的执行时机

Spring AOP里一共定义了五种通知类型,每种对应不同的执行时机。

前置通知(Before)在目标方法执行前运行,通常用来做参数校验、权限检查、初始化资源。它不能阻止目标方法的执行,除非抛出异常。

后置通知(AfterReturning)在目标方法正常返回后运行,可以拿到方法的返回值,适合做结果记录、数据后处理。如果方法抛异常了,这个通知不会执行。

异常通知(AfterThrowing)在目标方法抛出异常后运行,一般用来捕获异常、记录错误日志、做告警。

最终通知(After)不管方法是正常结束还是抛异常,都会执行,适合做资源释放,比如关闭连接、清理临时数据。需要注意它拿不到方法的返回值。

环绕通知(Around)是最强大的通知类型,它把目标方法的调用包在中间,可以在调用前做逻辑,调用后再做逻辑,甚至可以不调用目标方法。这个通知的入参是ProceedingJoinPoint,通过它的proceed方法手动触发目标方法的执行。

这五种通知配合起来,几乎能覆盖所有横切需求。入门阶段你首先要把前置通知和环绕通知用熟练,一个是逻辑最简单,一个是功能最全,先把这两个掌握住,后面的都是水到渠成。

3.3 理解切入点表达式的常用写法

切入点表达式决定了切面作用在哪些方法上,入门案例里一般会用到execution表达式。它的完整语法看起来有点吓人,其实仔细拆开就清晰了。

一个常用的execution表达式长这样:execution(* com.example.service..(..))。

最前面的代表方法的返回值类型,用表示不限制任何返回值。然后是包名类名部分,com.example.service是包路径,第一个表示这个包下的任意类,第二个表示任意方法名,最后括号里的(..)表示任意参数,两个点代表零个或多个任意参数。

如果你想精确拦截某个方法,可以写成execution(public String com.example.service.OrderService.createOrder(String, long)),这样签名就完全固定了。

除了execution,还有within、args、@annotation等指示符。within用来按类型匹配,比如within(com.example.service..*)表示service包及其子包下的所有类。args用来按方法参数类型匹配,比如args(String)表示只拦截参数是String的方法。@annotation比较高级,可以匹配带有指定注解的方法,适合做自定义注解驱动的切面。

对于入门阶段,execution这一种表达式能覆盖大多数场景,先把它的各个部分拆清楚,后面那些指示符随着项目需求自然就会遇到了。

4. 动手实现一个Spring AOP入门案例

4.1 环境准备与Spring Boot版本选型

这一章的入门案例我强烈建议用Spring Boot来做,因为它省掉了大量XML配置,直接通过注解就能把AOP跑起来。我见过很多初学同学直接用传统Spring写,光配配置文件就折腾半天,还容易踩到版本不兼容的坑,很容易劝退。

我这边常用的环境是这样的:JDK 1.8及以上都可以,Spring Boot我建议用2.5.x或者2.7.x版本,这两个版本社区资料丰富,碰到问题随便一搜就有答案。如果你用的是Spring Boot 3.x,那就要注意JDK版本至少17,而且它使用的新版Jakarta命名空间,跟老资料的注解包名不一样,很多同学在这一块会踩坑。

创建一个Spring Boot项目最简单的办法就是到官网或者直接用开发工具里的初始化器生成。项目创建好之后,需要用到一个starter,Maven工程的话只要在pom.xml里加上:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency>

注意这个starter并不是独立的一个库,它会传递引入Spring AOP相关的依赖,同时Spring Boot的自动配置会帮我们开启AOP的支持。实际上在Spring Boot中,只要加了spring-boot-starter-aop这个依赖,就会自动启用@EnableAspectJAutoProxy,你不需要额外去写这个开启注解。这一点对新手来说很友好,你只需要关注写自己的业务类和切面类就行。

项目结构上,我建议大家建两个包:一个是service包,放业务类;一个是aspect包,放切面类。这么分是为了切入点表达式写起来更清晰,到时候直接拦截service包下的所有方法就完事了。

4.2 写一个最基础的业务Service类

第一步要把被增强的目标对象准备出来。我举个例子,假设我们做一个模拟的用户服务,里面有一个创建用户的方法和查询用户的方法,代码如下。

@Service public class UserService { public void createUser(String username) { System.out.println("创建用户:" + username); } public String findUserById(Long id) { System.out.println("查询用户,id=" + id); return "用户" + id; } }

这个类非常简单,两个方法里只有一句打印输出。正常情况下,你想在方法调用前打一条日志,可能得自己往方法里加代码。我们接下来要做的就是把这个“记录日志”的横切逻辑放到切面里,让UserService代码保持干净。

为了测试效果更直观,我们再准备一个入口类来做调用测试。你可以直接写一个测试类,也可以在一个CommandLineRunner里调用,总之目的是让Spring容器启动后能执行到这些方法。如果用测试类,记得加上@SpringBootTest注解。

4.3 定义一个切面类并配置切入点

接下来是重头戏:定义切面类。我们先建一个类,加上@Aspect注解,同时加上@Component注解把它交给Spring管理。注意光是@Aspect注解还不够,Spring不会自动把切面类当作bean管理,你必须配合@Component或者配置类里的@Bean。

@Aspect @Component public class LogAspect { @Before("execution(* com.example.demo.service.*.*(..))") public void beforeLog() { System.out.println("[AOP前置通知] 方法即将执行..."); } @AfterReturning("execution(* com.example.demo.service.*.*(..))") public void afterReturningLog() { System.out.println("[AOP后置通知] 方法正常返回..."); } }

这里我定义了两个通知方法,一个用@Before标注,一个用@AfterReturning标注,切入点表达式是execution(* com.example.demo.service..(..)),表示拦截com.example.demo.service这个包下所有类的所有方法。

运行项目后,控制台输出的顺序应该是:先打印前置通知的日志,再打印业务方法里的内容,最后打印后置通知的日志。你看到这个效果的时候,就意味着AOP已经成功介入了方法调用过程。

有一个小细节值得提一下:在Spring Boot 2.x版本里,@Aspect类如果还写了其他带有@Pointcut注解的方法,那把切入点表达式统一定义到一个方法上会更好维护。我平时更推荐把切入点表达式提取出来,用一个无返回值空方法加@Pointcut声明,这样后面多个通知方法复用同一个切入点会很方便。

@Aspect @Component public class LogAspect { @Pointcut("execution(* com.example.demo.service.*.*(..))") public void servicePointcut() {} @Before("servicePointcut()") public void beforeLog() { System.out.println("[AOP前置通知] 方法即将执行..."); } @AfterReturning("servicePointcut()") public void afterReturningLog() { System.out.println("[AOP后置通知] 方法正常返回..."); } }

这样改写后,如果后面修改切入点表达式,只需要改一处就行。这个习惯一定要在入门阶段就养成,等切面数量变多之后,你会感谢自己当初这么写。

4.4 通过环绕通知拿到方法执行细节

前置通知和后置通知最大的缺点是拿不到方法名、参数、返回值这些细节。如果日志只打印一句“方法即将执行”,对问题排查帮助不大。所以更常用的是环绕通知。

环绕通知的代码结构相对固定,我直接给你一个完整示例:

@Aspect @Component public class LogAspect { @Around("execution(* com.example.demo.service.*.*(..))") public Object aroundLog(ProceedingJoinPoint joinPoint) throws Throwable { String methodName = joinPoint.getSignature().getName(); Object[] args = joinPoint.getArgs(); System.out.println("[环绕通知] 方法 " + methodName + " 开始执行,参数=" + Arrays.toString(args)); long startTime = System.currentTimeMillis(); Object result = joinPoint.proceed(); long endTime = System.currentTimeMillis(); System.out.println("[环绕通知] 方法 " + methodName + " 执行完成,返回=" + result + ",耗时=" + (endTime - startTime) + "ms"); return result; } }

这个方法里最重要的一行是joinPoint.proceed(),它表示执行目标方法本身。如果你不调这个方法,目标方法永远不会执行,这个特性在权限校验场景里很有用:判断到当前用户没权限就直接抛异常或者返回默认值,业务方法根本不会被执行。

通过joinPoint.getSignature().getName()能拿到方法名,通过joinPoint.getArgs()能拿到参数列表,有了这些信息,日志就能写得非常有价值。我在项目中通常会把方法名、类名、参数、返回值、耗时全部拼进一条日志,这样线上出了问题,翻日志就能定位到具体是哪个方法慢、哪个参数异常。

4.5 自定义注解配合切面的一种扩展玩法

入门案例跑通之后,有精力的同学可以玩一种很常见的扩展玩法:自定义一个注解,然后定义一个针对这个注解的切面。这种做法在权限控制、日志审计、接口幂等等场景里用得非常广泛,而且它跟execution表达式的区别在于,注解驱动的切面控制粒度更精细——不是拦截整个包,而是只拦截打了指定注解的方法。

举个例子,自定义一个日志注解:

@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface OperationLog { String action() default ""; }

然后在UserService的createUser方法上加上这个注解。接着在切面里用@annotation写法来定义切入点:

@Aspect @Component public class OperationLogAspect { @Before("@annotation(operationLog)") public void beforeLog(OperationLog operationLog) { System.out.println("执行操作:" + operationLog.action()); } }

这里@annotation(operationLog)会匹配所有带有@OperationLog注解的方法,并且把注解实例注入到通知方法的参数里,这样切面就能读取注解上的属性了。

我实际在项目里用它做过一种通用操作日志切面:给所有需要记录操作日志的接口方法打一个自定义注解,注解上写操作类型和中文描述,切面自动记录操作人、方法名、参数、结果。这个方案最大的价值在于,新增一个要记录日志的操作时,业务代码里只需要加一行注解,切面那边不用改任何东西,扩展成本非常低。

5. 常见问题与排查技巧实录

5.1 切面没有生效,方法调用完全没有被增强

这个是我被问到最多的问题,没有之一。切面定义了、Spring Boot也启动了,调方法的时候控制台就是没有切面日志。排查思路我给你按顺序列好。

第一步,先确认切面类有没有被Spring扫描到。检查项目启动类的包扫描范围,如果启动类在com.example.demo,而切面类在com.example.aspect,那就扫不到。有人会问,那我在切面类上加了@Component也不行吗?确实不行,因为@Component只是告诉你Spring可以把它当bean,但“可以当”的前提是它得在扫描范围内。

第二步,确认AOP依赖有没有加进来。很多同学写了切面类但pom.xml里压根没有spring-boot-starter-aop,那Spring根本不会有代理处理器,切面理所当然不会生效。如果你不确定依赖在不在,可以看Maven依赖树,抖音上好多教程都说加了这个依赖就行,我建议你直接搜索依赖树输出确认一下。

第三步,确认调用方式是走代理的。这一点特别关键,也特别隐蔽。AOP的本质是代理对象增强,如果你在UserService内部直接调用它的另一个方法,比如createUser方法内部直接调用findUserById,那拦截是不生效的。因为this.findUserById调用的是原始对象自身的方法,根本没有经过代理对象。解决办法是从容器里重新获取代理对象,或者把两个方法拆到不同类里。这个问题在Spring事务里也经常遇到,建议你直接养成“不要同类内部调用被拦截方法”的习惯。

5.2 前置通知和后置通知都执行了,但没有拿到返回值

有些同学可能刚开始学的时候,会试图在@AfterReturning里用returning参数去接收返回值,却发现接收到的值是null,或者干脆编译报错。这个问题的根源在于@AfterReturning的returning属性里的参数名必须和方法参数名保持一致,如果方法写的是Object retVal,注解里写returning="result",Spring会根据名称去匹配,匹配不上就直接报错了。

正确写法是这样的,方法参数里加一个Object result,并且注解里用returning = "result",名字必须严格一致。这个细节很容易在搬别人代码时忽略。

5.3 多个切面同时作用时,执行顺序不明确

当项目里存在多个切面时,它们的执行顺序默认是不确定的,这一点很多人没意识到。Spring AOP支持在切面类上加@Order注解来控制顺序,数字越小优先执行。

@Aspect @Component @Order(1) public class FirstAspect { ... } @Aspect @Component @Order(2) public class SecondAspect { ... }

这里需要理清楚一个有点反直觉的执行顺序:多个切面的前置通知是按@Order的值从小到大执行的,但后置通知是按从大到小执行的。可以理解成洋葱模型,越靠外的切面先执行前置逻辑,最后才轮到内层切面的逻辑,但退出的时候刚好反过来。

我遇到过最典型的一个场景是事务切面和日志切面共存。如果日志切面的优先级高于事务切面,日志打印的时候事务还没有提交,记录的“最终状态”可能跟数据库不一致;如果优先级低于事务切面,日志才是在事务完成之后记录的实际结果。所以切面顺序不是拍脑袋定的,要结合业务逻辑仔细考虑。

5.4 拦截到Controller方法时出现类型转换问题

有同学想用AOP给Controller层做接口耗时监控,结果发现方法参数里有HttpServletRequest这样是接口类型的对象,代理使用的是JDK动态代理,做法上要注意类型转换问题。其实对于Controller这种有接口的类,Spring默认会选择接口代理,而Spring MVC的Controller一般没接口,所以会走CGLIB,这种情况反而少出问题。

但有一种情况经常翻车:某个Service类实现了接口,但你的AOP拦截方法签名里直接写了具体实现类类型做参数,代理切面里做强制类型转换时就会报ClassCastException。原因是JDK动态代理生成的代理对象只实现了目标接口,跟具体实现类并不是父子关系。解决办法很简单,Spring Boot默认的代理方式是CGLIB,在配置文件里设置spring.aop.proxy-target-class=true,强制使用CGLIB。虽然这算是个必须知道的实操点,但顺带提一句它已经不再是Spring Boot默认关闭的状态了,我印象中Spring Boot 2.x之后默认就是CGLIB。

5.5 日志切面打印参数时把手感数据打出来了

这个问题看似不起眼,但一旦踩到会有很多麻烦。切面日志如果直接把方法的入参直接打印出来,而参数对象重写了toString或者包含敏感信息,日志系统里就会长期存着一堆敏感数据,这在安全审计时风险很大。

我的做法是对参数做脱敏处理,定义一个工具类,在切面里对手机号、身份证、银行卡这些字段做掩码处理再拼进日志。如果你负责的系统数据特别敏感,建议直接在切面日志里只打印参数类型和参数个数,不打印具体值。这个经验看似简单,但很多项目上线后才发现日志里全是明文敏感信息,那时候再清洗就非常痛苦。

6. 入门之后可以往哪些方向继续深入

案例跑通、常见问题也了解个大概之后,你会发现AOP其实是一把非常好用的瑞士军刀。但我也要提醒你,AOP用得好能简化代码,用得过度反而会让流程变得神出鬼没,排查问题困难。

我自己给团队定的几条实践原则是:切面里不要写复杂的业务逻辑,切面只做通用横切;切入点表达式尽量缩小范围,别图省事一个*拦截一切;切面方法不要依赖执行顺序隐含的上下文,真要依赖就显式用@Order控制;切面里抛出的异常要格外小心,因为环绕通知里一旦抛异常,目标方法就算正常执行完,调用方看到的也是异常。

如果你想把AOP再学深一层,我建议你去看几个方向:第一个是Spring事务的源码,从@Transactional这个注解入手,看看Spring是怎么用AOP实现事务提交和回滚的;第二个是Spring Boot的自动配置里跟AOP相关的部分,看看@EnableAspectJAutoProxy是怎么实现代理开启的;第三个是学习一下AspectJ的编译期织入,这个跟Spring AOP的运行时代理机制不同,能帮你建立完整的切面编程认知。

我个人在实际项目中用得最多的AOP场景还是操作日志和性能监控,这两个场景投入产出比极高,一套切面写好后,后续业务代码完全不用再关心日志和监控的事。如果你在公司里有机会参与公共组件开发,把AOP配合自定义注解的玩法掌握好,很容易做出让同事觉得“真好用”的工具。

最后分享一个小技巧:当你写完一个切面不确定有没有正常拦截时,可以先启动项目,用Spring容器拿一下这个bean的类型,如果发现它是代理类而不是原始类,那说明AOP已经生效了。具体代码可以这样写。

@Autowired private UserService userService; System.out.println(userService.getClass().getName());

如果输出里带$$或者EnhancerBySpringCGLIB这类字样,说明确实走了代理。这个方法很简单,但在刚入门时能帮你快速建立“代理对象存在”的感性认识。搞明白AOP的原理之后,数字里这些内部运作就都串起来了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询