1. AOP到底解决什么问题
不知道你有没有过这种经历:一个老项目里几十个Service方法,暂时不说业务逻辑,光是“记录日志”“校验权限”“统计耗时”这些重复代码,就散落得到处都是。想给所有查询接口加个耗时统计,改完一个方法发现还有三十个在等你。想给部分接口加个权限判断,复制粘贴了五六遍代码,还没想清楚漏了哪个方法。这就是面向对象编程(OOP)解决不了的核心痛点:横切关注点。
AOP(Aspect-Oriented Programming,面向切面编程)就是专门处理这类问题的。它的核心思想不复杂:把那些横跨多个业务模块的公共逻辑,从业务代码里抽出来,单独做成一个“切面”,然后在合适的时机“织入”到业务方法的执行链路里。业务代码保持干净,公共逻辑统一维护,互不污染。这就是AOP的本质——解耦、复用、无侵入。
在Spring全家桶里,AOP和IOC(控制反转)被并称为两大核心基石。IOC解决对象创建和依赖管理的问题,让对象不再自己new自己;AOP解决横切逻辑复用的问题,让业务的每个方法都不必重复写公共代码。理解了AOP,你就理解了Spring事务注解为什么能“一个注解搞定所有方法”,理解了权限框架(如Spring Security)底层为什么能在你毫无感知的情况下拦截请求,也理解了MyBatis的分页插件为什么能自动地在查询前后做手脚。
这篇文章我会从零带你拆解AOP:先讲清楚它是怎么想出来的、核心概念都有哪些,再深入到Spring AOP的两种底层实现(JDK动态代理和CGLIB动态代理)的完整代码,然后给出实际项目里可以立刻抄走的Spring Boot切面写法,最后整理一批你面试时大概率会被问到的AOP高频题。对刚学Spring的人、写业务的老手、准备面试的同学,都有参考价值。
2. 核心设计思路:从OOP到AOP的思维转变
2.1 OOP的局限和横切关注点
面向对象编程(OOP)把系统拆成一个个对象(类),每个对象承担具体的业务职责。这种做法在处理业务逻辑本身时非常顺手,但有一类逻辑是“水平穿越”所有对象的,比如日志记录、性能统计、事务控制、权限校验。这类逻辑有一个专门的名字:横切关注点(Cross-Cutting Concerns)。
画个图理解:你有个电商系统,里面十几个Service类各自处理订单、商品、用户、购物车。如果要在每个业务方法里记录操作日志,代码形状是这样的——订单Service里有日志代码、商品Service里有日志代码、用户Service里也有。日志逻辑横穿了所有这些Service。用OOP的思路很难干净地解决这种问题:你可以抽一个LogUtil工具类,但调用还是得每个方法手动写一行;你可以做一个基类,但Java单继承的限制让这招并不好用,而且会把业务的继承体系搞乱。
AOP的思维方式是翻转的:既然日志逻辑是横切的,那我不在业务代码里调用日志了。我定义好规则,比如“凡是包名下以find开头的所有方法,执行前先记一条日志,执行后再记一条执行结果”。然后把这段规则想办法“织入”到这些方法里去。业务代码保持原本的样子,日志逻辑单独维护。这个思路就是AOP的雏形。它不是让业务代码“知道”自己会被附加什么逻辑,而是让横切代码“决定”去哪些方法上附加自己。
2.2 AOP的三大优势:解耦、复用、无侵入
这三个词是对AOP价值最精炼的总结,我拆开说:
解耦:业务代码和横切逻辑不再混在一起。你的订单方法里不用再写“try-finally记录耗时”,那段记录耗时的代码只在切面里存在一份。业务方法的职责更纯粹了,读代码的人看到的都是业务逻辑本身。
复用:一套权限校验逻辑,不加任何业务代码的情况下,可以应用到所有需要的方法上。新增一个业务方法时,只要它满足切点匹配规则,公共逻辑自动生效。这比“每加一个业务方法就要记得去调用一次公共逻辑”要可靠得多——要知道,人总会忘的,规则不会。
无侵入:这是最被低估的特性。在做老项目改造时,无侵入意味着你不需要改动现有业务代码。比如老系统的某些接口没有做权限校验,你只需要加一个AOP切面,用切点表达式精准命中这些接口,权限逻辑就生效了。几十个方法不用动一行代码,项目风险被控制到极小。
换个角度说:OOP是纵向的模块化——把系统切成一个个对象;AOP是横向的模块化——把跨越所有对象的共同逻辑统一管理。两者互补,不是替代关系。
2.3 AOP、AspectJ和Spring AOP的关系
聊AOP必然会提到AspectJ。这俩容易搞混,我一句话说清:AspectJ是完整的AOP方案,它有自己的编译器,能做到编译期织入甚至修改字节码;Spring AOP是Spring框架自己的一套AOP实现,基于动态代理,运行时织入。Spring AOP在底层大量借鉴了AspectJ的注解和概念(比如@Aspect、@Before这些注解是AspectJ定义的),但机制上是两套东西。
Spring AOP的优势是够用、方便、和Spring无缝集成,写个@Aspect类就可以用了;短板是只能拦截Spring容器管理的Bean的方法调用,对静态方法、final方法、非Spring管理的对象无能为力。AspectJ能力更强、性能更好(编译期直接把代码织进目标类了),但配置和使用门槛要高得多。
日常业务开发,Spring AOP完全够用;追求极致性能或需要织入很多非Spring管理类的场景,才需要考虑AspectJ。我后面讲Spring AOP做实操,底层原理会展开这两种动态代理方式。
2.4 AOP在Spring全家桶里的生态位
Spring框架里,AOP不是孤零零的一个功能模块,它是很多高层特性的地基。最典型的是@Transactional——它之所以能让你一个注解就能管理事务,底层就是AOP在拦截方法调用,执行前开启事务、方法正常返回后提交事务、方法抛出异常时回滚事务。其次Spring Security的权限校验也建立在Filter和AOP机制之上,方法级别的权限注解如@PreAuthorize就是AOP在起作用。另外Spring Cache缓存注解、异步注解@Async、定时任务相关,以及各种第三方框架(MyBatis分页插件、多数据源切换、接口幂等校验),十有八九都用了AOP。
所以弄懂AOP,不光是为了懂一个概念,更是为了看懂Spring整个生态的设计脉络。面试问“IOC和AOP的原理”,本质上是在考察你对Spring最底层两块地基的理解程度。
3. AOP核心概念逐层拆解:切面、切点、通知
先给个整体图:AOP体系里你只需要搞清楚六个词——切面、连接点、切点、通知、目标对象、织入。每个词都有准确的技术定义,但在项目中到底对应什么代码形态,下面拆开讲。
3.1 五个基础术语,一句话理解
为了方便记忆,我用自己的话把这几个概念过一遍,顺便建立一张和代码的对应关系:
连接点(Join Point):程序执行的某个位置。Spring AOP里连接点就是“方法调用的时机点”,比如“这个方法刚被调用的时候”“这个方法的返回值已经算出来了的时候”。理论上字段赋值、构造器调用也可以作为连接点,但Spring AOP支持的连接点是方法调用(更准确地说是方法执行到特定阶段)。简单理解:候选的插入点。
切点(PointCut):从所有候选的插入点里,按表达式筛选出一批具体的方法。比如execution(* com.example.service.*.*(..))就是匹配service包下所有类的所有方法。切点本质是一个过滤规则,决定了你的横切逻辑要附加到谁身上。
通知(Advice):横切逻辑本身,也就是那个要插入的具体代码块(记日志、算耗时、检查权限)。通知定义了“干什么”和“插在什么时机”。
切面(Aspect):把“切点 + 通知”打包在一起的模块。一个切面可以包含多个通知和多个切点。
目标对象(Target Object):被织入横切逻辑的业务对象,也就是你原本写的那个Service类。AOP动态代理包裹的就是它。
织入(Weaving):把切面代码应用到目标对象并创建出代理对象的过程。Spring AOP的织入发生在运行时,别名就叫动态代理。
3.2 五种通知类型详解
通知类型决定了代码在什么时机执行,Spring AOP一共五种。我直接用表格对比,每个给一个最简单的比喻:
| 通知类型 | 执行时机 | 生活化类比 | 实际典型用途 |
|---|---|---|---|
| @Before(前置通知) | 目标方法执行前 | 进门前先检查身份证 | 权限校验、参数校验、操作日志记录 |
| @After(后置通知) | 目标方法执行后(无论是否抛异常) | 出门后随手关门 | 资源清理、释放锁 |
| @AfterReturning(返回后通知) | 目标方法正常返回后(不抛异常) | 收到快递后确认签收 | 记录成功日志、统计成功次数、处理返回值 |
| @AfterThrowing(异常通知) | 目标方法抛出异常后 | 出事后报警 | 记录异常日志、统一异常包装、告警通知 |
| @Around(环绕通知) | 目标方法执行前后都能干预,最强大 | 全流程陪同,其前后都能操作 | 耗时统计、分布式锁、事务控制、限流熔断 |
用代码看最直观。环绕通知是能控制整个调用链的,前四种本质上是它的语法糖变体,某些简单场景用起来更简洁:
@Around("execution(* com.example.service.OrderService.*(..))") public Object around(ProceedingJoinPoint pjp) throws Throwable { // 这里相当于@Before long start = System.currentTimeMillis(); try { // 调用目标方法,拿到返回值 Object result = pjp.proceed(); // 这里相当于@AfterReturning return result; } catch (Exception e) { // 这里相当于@AfterThrowing throw e; } finally { // 这里相当于@After System.out.println("方法耗时: " + (System.currentTimeMillis() - start) + "ms"); } }注意:pjp.proceed()是必须调的——你不调,目标方法根本不会执行。这是环绕通知和其他四种最大的区别。
3.3 切点表达式:一口吃下execution、within和注解匹配
切点表达式是AOP实战里最容易写错也最容易踩坑的地方。我推荐掌握三种匹配方式,足以覆盖绝大多数场景。
execution(最常用、最精确):按方法签名匹配。格式看起来长,拆开记就不难:
execution(修饰符? 返回类型 类路径?方法名(参数类型) 异常模式?)实际我写代码时常见写法是:
// 匹配某个包下所有类的所有方法(常用) execution(* com.example.service..*(..)) // 匹配某个包下所有以create开头的方法 execution(* com.example.service..create*(..)) // 匹配指定类的指定方法 execution(public String com.example.service.UserService.getName(..))注意:..在类路径部分表示包及其子包;在参数部分表示任意数量的参数。
within(按类匹配):比execution粒度粗,适合匹配整个类的所有方法。比如within(com.example.service..*)匹配service包下所有类的所有方法。
@annotation(按注解匹配):这是项目里最优雅的方式——你自定义一个注解,写在需要横切的方法上,切点表达式去匹配“带这个注解的方法”。这种方式精准、直观、可控,比包名匹配清晰得多,后面实操部分我会演示。
另外还有bean()匹配Spring Bean名称、@within匹配类上标有注解的所有方法。初学阶段,execution + @annotation 够用。
4. 深入原理:JDK动态代理和CGLIB动态代理
前面说的织入、动态代理这些词,终端里看起来有点虚。这一节我用代码把Spring AOP的底层逻辑完全摊开。理解了这个,你面试环节“AOP实现原理”这种题随便聊。
4.1 为什么需要代理
假设你写了UserService类,里面有一个addUser()方法。Spring AOP想要做到“不修改UserService源码的情况下给addUser()加日志”,唯一可行的运行期办法就是:不要直接给你用UserService对象,而是给你一个经过加工包装后的代理对象。你调用addUser()时,真正执行的是代理对象里的增强代码,代理对象再去调用你原本的addUser()。这就是动态代理的核心思路。
Spring容器里所有的Bean,如果被AOP切面命中,容器中实际放着的就是代理对象,不是原始对象。这点很关键,很多“切面不生效”的问题都是在这里没搞明白(后面会展开)。
4.2 JDK动态代理:基于接口
JDK动态代理是Java内置的代理机制,核心是java.lang.reflect.Proxy和InvocationHandler。原理一句话:运行时为接口生成一个实现类的字节码,这个实现类通过你传入的InvocationHandler来转发所有方法调用。限制也很明显——目标类必须实现了至少一个接口,因为JDK代理生成的代理对象是接口的实现类,和你的目标类没关系。
看个完整可运行的例子:
public interface UserService { String getUserName(Long id); } public class UserServiceImpl implements UserService { @Override public String getUserName(Long id) { return "用户-" + id; } } public class LogInvocationHandler implements InvocationHandler { private final Object target; public LogInvocationHandler(Object target) { this.target = target; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { System.out.println("======= JDK Proxy进入方法: " + method.getName()); Object result = method.invoke(target, args); // 反射调用目标方法 System.out.println("======= JDK Proxy方法执行完了,返回值: " + result); return result; } } // 使用方式 UserService targetService = new UserServiceImpl(); UserService proxy = (UserService) Proxy.newProxyInstance( targetService.getClass().getClassLoader(), targetService.getClass().getInterfaces(), new LogInvocationHandler(targetService) ); System.out.println(proxy.getUserName(1L));输出效果:
======= JDK Proxy进入方法: getUserName ======= JDK Proxy方法执行完了,返回值: 用户-1 用户-1注意:调用方的变量类型是UserService接口,持有的是代理对象,代理对象内部通过反射method.invoke(target, args)把调用转发给真实的UserServiceImpl。Spring AOP默认走的就是这条路——只要目标Bean实现了接口,Spring就优先使用JDK动态代理。
4.3 CGLIB动态代理:基于继承
如果目标类没有实现任何接口,Spring会退而求其次使用CGLIB。CGLIB(Code Generation Library)的原理是:运行时生成一个目标类的子类,通过重写父类方法来实现增强。调用代理对象的方法时,实际执行的是子类里重写过的方法,子类方法里对目标逻辑(父类方法)和增强逻辑做一个编排。
典型CGLIB示例:
// 注意:目标类不需要实现任何接口 public class OrderService { public void createOrder() { System.out.println("创建订单,核心业务逻辑"); } } // 引入CGLIB依赖(Spring已经内置了它) public class CglibProxyExample { public static void main(String[] args) { OrderService target = new OrderService(); Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(target.getClass()); // 设置父类 enhancer.setCallback(new MethodInterceptor() { @Override public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable { System.out.println("======= CGLIB前置增强: 准备创建订单"); Object result = proxy.invokeSuper(obj, args); // 调用父类(目标)方法 System.out.println("======= CGLIB后置增强: 订单创建完成"); return result; } }); OrderService proxy = (OrderService) enhancer.create(); proxy.createOrder(); } }这里有个关键点:proxy.invokeSuper(obj, args)是CGLIB特有的调用方式,直接调父类的方法,避免绕过增强逻辑或性能损失。如果用method.invoke(target, args)这种反射调用,会丢失代理增强链路。
关于Spring Boot 2.x+:从Spring Boot 2.0开始,Spring对代理方式的选择逻辑发生了变化——即使类有接口,默认也会优先使用CGLIB(即spring.aop.proxy-target-class=true成为默认)。这个设计是为了避免有些类实现了接口但想被代理时还得手动配置的麻烦。如果你的项目是传统Spring配置(非Spring Boot),默认仍然是接口存在优先JDK代理。
4.4 核心对比:JDK代理 vs CGLIB
| 对比维度 | JDK动态代理 | CGLIB动态代理 |
|---|---|---|
| 目标要求 | 目标类必须实现接口 | 目标类不能是final类(final方法无法重写) |
| 代理实现机制 | 为接口生成实现类 | 生成目标类的子类 |
| 是否需要额外依赖 | 不需要,JDK内置 | 需要引入cglib或spring-core自带的CGLIB |
| 性能特点 | 创建代理对象时速度较快,运行时反射调用有开销 | 创建代理对象较慢(需要生成字节码),运行时通过MethodProxy调父类方法略快 |
| 方法级限制 | 只能代理接口中声明的方法 | final方法无法代理、static方法无法代理 |
| 调用方式 | method.invoke(target, args) | proxy.invokeSuper(obj, args) |
实际使用中,对绝大多数业务系统而言两者的性能差距微乎其微,不需要过度纠结。真正需要关心的是:如果目标类没实现接口,记得不能代理final方法(因为final方法不能被重写),以及如果目标方法在接口里没有声明,JDK代理会漏掉它。
5. 手把手实战:Spring Boot里写一个可落地的AOP切面
理解原理之后,我们来点真东西。下面这个案例我会完整地写出依赖、代码、执行效果,你照着敲一遍就能跑通。
5.1 项目准备和依赖
新建一个Spring Boot项目(2.x或3.x都可以),核心场景是:给一堆查询接口加“接口耗时统计 + 打印入参出参 + 记录操作日志”的能力。
关键依赖只有两个,web和aop(Spring Boot 2.x里spring-boot-starter-aop就包含AspectJ运行时):
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency>Spring Boot的自动配置会在类路径发现@Aspect注解的类后自动启用AOP代理,不需要额外加@EnableAspectJAutoProxy——Spring Boot已经默认开了。只有在原生Spring项目里才需要手写这个注解。
5.2 第一种写法:基于包路径的切面(简单粗暴)
先来最直接的方案:拦截com.example.demo.service包下的所有方法。
@Aspect @Component public class ServiceLogAspect { private static final Logger logger = LoggerFactory.getLogger(ServiceLogAspect.class); // 切点表达式:匹配service包下所有类的所有方法 @Pointcut("execution(* com.example.demo.service..*.*(..))") public void servicePointcut() {} // 环绕通知 @Around("servicePointcut()") public Object around(ProceedingJoinPoint pjp) throws Throwable { // 拿到目标方法信息 MethodSignature signature = (MethodSignature) pjp.getSignature(); String className = signature.getDeclaringType().getSimpleName(); String methodName = signature.getName(); Object[] args = pjp.getArgs(); long start = System.currentTimeMillis(); logger.info("[AOP] {}.{} 入参: {}", className, methodName, Arrays.toString(args)); Object result = null; try { result = pjp.proceed(); // 正常返回:记录耗时和返回值 long cost = System.currentTimeMillis() - start; logger.info("[AOP] {}.{} 返回: {},耗时: {}ms", className, methodName, result, cost); return result; } catch (Throwable throwable) { long cost = System.currentTimeMillis() - start; logger.error("[AOP] {}.{} 异常: {},耗时: {}ms", className, methodName, throwable.getMessage(), cost); throw throwable; } } }一些经验细节:
- 入参打印时小心敏感信息(密码、token等),别直接
Arrays.toString(args)全打出来。工作中的做法是自定义序列化策略,敏感字段脱敏。 - 记录耗时一定要放在
finally里或者用异常分支兜底,否则方法一抛异常耗时就被吞了。 - 就算日志框架对
{}占位符做了惰性拼接,也要注意如果入参对象重写了toString发生过重的序列化,性能会有损耗。压测环境下慎用大对象入参打印。
5.3 第二种写法:自定义注解 + 切面(推荐用于正式项目)
基于包路径的写法在大型项目里不够精准——你只想要少数关键接口记录日志,而不是service包下所有方法都记录。我的习惯做法是:自定义一个注解,然后切面只切有这个注解的方法。这样做有几个好处:精确控制范围、可以通过注解参数传递业务信息(比如操作类型)、代码自解释。
效果如下:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface OperationLog { String module(); // 模块名,比如"用户管理" String operation(); // 操作名,比如"新增用户" }切面写法:
@Aspect @Component public class OperationLogAspect { private static final Logger logger = LoggerFactory.getLogger(OperationLogAspect.class); // 切点直接匹配带@OperationLog注解的方法 @Pointcut("@annotation(com.example.demo.annotation.OperationLog)") public void logPointcut() {} // 通过proceed方法内的注解对象拿到注解里的业务信息 @Around("logPointcut()") public Object around(ProceedingJoinPoint pjp) throws Throwable { MethodSignature signature = (MethodSignature) pjp.getSignature(); Method method = signature.getMethod(); OperationLog annotation = method.getAnnotation(OperationLog.class); String module = annotation.module(); String operation = annotation.operation(); // 这里你可以注入一个日志Service,把操作记录写到数据库或MQ // opLogService.saveLog(module, operation, params, result, cost, userId, ip); return saveLogAround(pjp, module, operation); } }用法:
@Service public class UserService { // 只需要一行注解,日志逻辑自动生效 @OperationLog(module = "用户管理", operation = "新增用户") public void addUser(UserAddDTO dto) { // 业务逻辑 } }这种“注解驱动切面”的模式,在真实项目中是最常见的。后面如果你要加幂等校验、限流、分布式锁,思路一模一样——定义注解 → 定义切面 → 注解打在需要的方法上。
5.4 控制多个切面的执行顺序:@Order
有时候多个切面都命中同一个方法,比如一个切面做权限校验,一个切面做日志记录,它们之间的执行顺序就很重要。总不能先记录日志再校验权限——那可能把未授权操作也记进去了。Spring AOP中用@Order控制顺序,值越小优先级越高(越先进入):
@Aspect @Component @Order(1) public class PermissionAspect { // 先执行权限校验 @Around("@annotation(com.example.demo.annotation.RequirePermission)") public Object checkPermission(ProceedingJoinPoint pjp) throws Throwable { // 校验权限,失败直接抛异常 return pjp.proceed(); } } @Aspect @Component @Order(2) public class LogAspect { // 后执行日志记录 // ... }嵌套关系和钓鱼“洋葱皮”类似:@Order(1)在最外层,最后进入目标方法。
5.5 获取参数及返回值的细节技巧
切面代码获取参数有几种途径,效果各有不同,我按推荐程度排个序:
- 用
ProceedingJoinPoint.getArgs()获取参数数组。适用性最广,但参数顺序和位置不一定容易对应。 - 用
MethodSignature的getParameterNames()获取参数名,和getArgs()的索引对应。在Spring Boot 2.x+项目里如果开启了-parameters编译参数(Spring Boot Maven插件默认会带),参数名可以拿到。 - 用joinPoint的
getTarget()拿目标对象,再反射拿一些上下文信息,但一般用得少。
要拿到方法的返回值,也有讲究:
- 在
@Around里直接用proceed()的返回值即可。 - 在
@AfterReturning里可以用returning属性指定返回值注入参数名:
@AfterReturning(pointcut = "servicePointcut()", returning = "result") public void afterReturning(JoinPoint joinPoint, Object result) { // result就是目标方法的返回值 }最后提一个容易踩的坑:切面方法本身不要抛异常,尽量不要影响原主流程。环绕通知里如果对返回值做了包装或修改,一定要注意返回值类型不能被改变——比如目标方法返回User,你的环绕通知返回了一个String,强转直接报ClassCastException。
6. 使用场景地图:AOP适合做什么,不适合做什么
6.1 日志记录与操作审计
这是AOP最常见的落地场景之一。尤其是“一键记录用户所有操作行为”:通过AOP拦截所有标注了操作日志注解的方法,记录操作人、操作时间、入参关键信息、操作结果。这种横切逻辑和业务代码天然分离,换任何监控系统或审计系统都不会影响业务逻辑本身。
我的经验:操作审计的切面里重点记录“谁、何时、做了什么、结果是成功还是失败”,而不是把全部入参和返回体都存下来。数据量太大了一种尽收眼底的日志表,三天就能把数据库撑膨胀,而且隐私安全问题严重。
6.2 接口权限校验
Spring Security的@PreAuthorize注解底层就是基于AOP的。你也可以自己实现轻量的接口权限校验。比如有一个@RequirePermission("order:create")注解,切面里查询当前用户的权限集合,如果不包含则抛出无权限异常。好处是:权限逻辑集中在一个切面类里,多个接口共享同一套校验逻辑;以后升级权限模型,只改一个地方。
6.3 性能监控与耗时统计
这是我自己项目上线前必做的一步:用AOP切面统计核心方法的耗时,发现慢接口。可以把耗时分成几个级别处理:超过500ms警告、超过1s报错,并上报到监控系统。这个思路配合Nacos、Prometheus等监控体系,性能问题基本能在用户感知之前被发现。
6.4 事务管理和分布式锁
@Transactional本身就是一个生动的例子。但更典型的自定义场景是分布式锁:在秒杀或提现金额更新场景中,你希望某个方法在执行前获取Redis分布式锁,执行后释放锁。用AOP完美契合——@DistributedLock(key = "#userId")这种注解打上去,切面负责加锁、解锁、处理获取锁失败的情况。业务代码里不出现任何锁逻辑。
6.5 统一异常包装和服务降级
在开发对外暴露的REST API时,经常需要把底层异常统一包装成合适的错误码返回给前端。可以在切面里捕获特定类型异常,做一次统一转换。同理,服务降级逻辑(比如调用第三方接口超时时返回默认值)也可以做成切面,不过要小心切面的粒度粒度和是否真的是横切逻辑。如果只是一两个方法需要降级,直接在业务代码里写反而更清晰,没必要硬上AOP。
6.6 不适合用AOP的场景
AOP不是万能药。我见过不少把AOP用歪的案例,提醒你避开:
- 业务状态强依赖场景:比如一个方法是否执行取决于前一个方法是否成功更新了数据库状态,这种强耦合业务逻辑不适合放进切面,否则排查问题时你要在业务代码和切面代码之间来回跳,维护成本很高。
- 高频热点方法的超轻量增强:如果方法被千万级QPS调用,切面本身就增加了方法调用链路和反射桩开销(虽然JDK代理和CGLIB代理性能都还行,但终归是有开销的)。这种场景下直接在方法里写死更可控。
- 需要精细化调试的场景:AOP在日志里表现为一层代理调用,定位问题时多一层“隐身跳板”,排障的人如果不懂AOP概念会非常困惑。你在大规模团队成员的新人培训里,需要额外讲解清楚AOP的存在,否则会出现“这个方法明明有人调用,为什么日志里没有”之类的排查事故。
7. 典型面试题速查(含深度解析)
这一节面向正在准备面试的读者。整理几个高频问题,我尽量用“面试官为什么这么问”的角度来回答。
7.1 什么是AOP?它和OOP的区别是什么?
AOP是面向切面编程,把横切关注点从业务逻辑中抽取出来并统一管理,通过代理机制在运行时织入。OOP是纵向模块化,把系统拆分为对象;AOP是横向模块化,解决跨对象的公共逻辑。两者互补,不是替代。
7.2 Spring AOP的实现原理是什么?
核心是动态代理。如果目标类实现了接口,Spring使用JDK动态代理(生成接口实现类);如果目标类没有实现接口,Spring使用CGLIB动态代理(生成目标类的子类)。在Spring Boot 2.x后,代理策略默认优先CGLIB。
7.3 JDK动态代理和CGLIB动态代理有什么区别?
这是必考题,按这张表答基本能覆盖全部得分点:一个基于接口,一个基于继承;一个JDK自带,一个额外引入CGLIB库;一个用InvocationHandler转发,一个用MethodInterceptor和MethodProxy.invokeSuper;JDK代理只能代理接口方法,CGLIB不能代理final方法。
7.4 同一个类内部方法调用,AOP为什么不生效?
典型坑。比如UserService里methodA()调用了本类的methodB(),只给methodB()加了切面,调methodA()时发现methodB()的切面没生效。原因是:AOP代理的原理是外部拿到的是代理对象,但methodA()内部用this调用的methodB(),this指向的是原始对象,不是代理对象。所以methodB()的调用没有经过代理。
解决办法一般是:让methodB()通过注入的代理对象来调用(依赖注入自己)、把methodB()挪到另一个Bean里去、或者直接在methodA()上加上同样的切面规则。
7.5 Spring AOP和AspectJ有什么区别?
Spring AOP是运行时织入,基于动态代理;AspectJ是编译期静态织入,功能更强,性能更好,但使用复杂。Spring AOP只支持方法级别的连接点;AspectJ支持字段、构造器、甚至初始化块的连接点。日常业务里Spring AOP够用。
7.6 环绕通知和前置通知、后置通知的关系?
环绕通知最强大,可以自己控制目标方法的执行时机;前置和后置是特殊的单向通知。@Around里调用proceed()前是前置逻辑,正常返回后是返回后逻辑,抛异常后是异常逻辑。实际项目中复杂逻辑用环绕,简单场景用定向通知更清晰。
7.7 Spring事务的AOP实现:@Transactional为什么能在异常时自动回滚?
@Transactional被AOP拦截后,等同环绕通知。目标方法执行前,事务切面获取数据库连接并开启事务(取消自动提交);方法正常返回后,事务切面提交事务;方法抛出RuntimeException等异常时,事务切面执行回滚操作,并把异常继续向上抛出让上层感知。你可以把它理解为一条“竖起耳朵听着目标方法结果的环绕监听器”。
7.8 如何让多个切面按指定顺序执行?
用@Order注解,值越小优先级越高。需要注意:这个顺序在嵌套模型里理解为外层到内层,执行“进入目标”的顺序和“退出目标”的顺序是相反的。举个例子,@Order(1)的权限切面先进入目标方法,但在退出时反而比@Order(2)的日志切面更晚执行——想象一下剥洋葱和套娃,一层层包裹,最后剥的时候先启后剥。
8. 实战问题排查:切面为什么不生效?如何优雅规避
写AOP的难点往往不在“会写”,而在“出问题时怎么定位”。这里把最常遇到的坑集中列一下,附上排查思路。
| 现象 | 可能原因 | 排查方式和解决手段 |
|---|---|---|
| 切面类明明写了,但方法就是不进切面 | 切面类没被Spring扫描到、切点表达式写法有误、目标方法自己调自己 | 检查@Component或@Aspect是否生效;用@Pointcut里的表达式先在测试类里试验;检查是否有内部调用 |
| 接口方法里有增强,非接口方法没增强 | 用了JDK动态代理,代理对象是接口实现类,接口没声明的方法根本不在代理范围 | 改成CGLIB代理;或确保目标方法声明在接口中 |
| 目标方法是final的,增强不生效 | CGLIB无法重写final方法 | 去掉final修饰符;或改走接口+JDK代理 |
@Around里调用了proceed(),但业务方法抛了异常,切面的异常分支没执行 | 可能是proceed()本体方法抛的异常类型不对 | 检查是否正确捕获了Throwable并在finally里做清理(我习惯环绕通知里一定要有finally块) |
| 同一个方法有多个切面时,日志记录时机不对 | 切面顺序没控制 | 给不同切面统一分配@Order值,形成约定 |
| Spring Boot 3.x项目里AOP不生效 | Spring Boot 3.x是Spring Framework 6.x,AOP模块名称和包路径有调整 | 确认导入了spring-boot-starter-aop(而非老项目的spring-boot-starter里隐式包含是否不一样了) |
还有一个高发坑:写了切面但目标方法是被别的Bean调用,那个Bean也是代理对象,但代理是懒加载模式下在某个时创建失败的。排查时最直接的手段是加一个启动后的测试接口,把UserService的类型打出来——打印出来看着像com.sun.proxy.$Proxy20就是JDK代理,看着像UserService$$EnhancerBySpringCGLIB$$xxx就是CGLIB。如果打印出来是普通的UserServiceImpl,恭喜你,你的切面根本没生效,先检查Spring配置。
写AOP切面的一点个人风格建议:尽量使用@annotation或within加注解的组合做精准匹配,少用大范围包扫描的execution;切面类内部逻辑要越轻越好——你是在给所有方法织入额外开销,如果切面里又做了重的DB查询或外部调用,等于给所有业务方法累加了双倍负担。
8.2 升级版:切面里的耗时统计要不要做成可动态开关的?
在做监控类切面时,通常会遇到“线上要不要关掉切面日志”的争论。我的建议是一开始就设计成可配置开关。最简单的手段是配合@ConditionalOnProperty或者直接在切面里读一个配置项:
@Value("${app.aop.enabled:true}") private boolean aopEnabled; @Around("logPointcut()") public Object around(ProceedingJoinPoint pjp) throws Throwable { if (!aopEnabled) { return pjp.proceed(); // 关闭切面时直接放行 } // 正常切面逻辑 }这样在压测、异常排查时可以动态关闭切面,避免正常故障排查时被自己的日志洪流淹没。
最后分享一个方法论上的体会:AOP确实强大,但它解决的问题本质上属于代码组织方式的问题。我见过团队把AOP写得极其飘逸——一个方法上挂着七八个注解,每个注解背后又是一个复杂的切面,代码确实“优雅”了,但排查问题时长路漫漫。我的原则是:AOP只服务于跨多个类的横切逻辑,单一方法局部逻辑永远优先用普通代码写清楚;切面数量控制在5个以内,每个切面职责单一。AOP是工具,服务于代码可维护性,我反复强调这一点——不要为了炫技而过度使用AOP,那是把维护成本从今天转移到明天。